This commit is contained in:
33333-33333 2026-09-29 23:45:12 +09:00
commit f5fc9b76ce
73 changed files with 32628 additions and 376 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
# Optional local Edge profile used for GPU smoke tests.
.test-edge/
tests/.browser-smoke.generated.html

3
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"liveServer.settings.port": 5501
}

View file

@ -1,4 +1,4 @@
# ECOSPHERE v41 — 生態モデル仕様 # ECOSPHERE v45 — 生態モデル仕様
## 1. 単位と timestep ## 1. 単位と timestep
@ -6,12 +6,12 @@
- `dt = 1/24 day` - `dt = 1/24 day`
- distance: m - distance: m
- animal wet mass: kg - animal wet mass: kg
- animal energy reserve: kJ - animal reserve energy: kJ
- producer / zooplankton: kg biomass m^-2 - producer / zooplankton: kg biomass m^-2
## 2. 表現 ## 2. 表現
- terrestrial/aquatic producer: `ResourceField` grid - terrestrial/aquatic producer: grid biomass field
- zooplankton: cohort biomass grid - zooplankton: cohort biomass grid
- macro consumers: individual agents - macro consumers: individual agents
- food web: fixed sparse consumer-resource graph - food web: fixed sparse consumer-resource graph
@ -28,52 +28,42 @@
`mu = muMax(T) * min(fN,fP) * fLight` `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 を使う。 陸上 producer は fallback として `rMax=0.03 day^-1`, `K=0.5 kg m^-2` と moisture / light / temperature limitation を使う。
## 4. 摂食 ## 4. 摂食
固定 interaction edge は resource type ごとに ### 個体資源
- preference weight 動物個体どうしの捕食では interaction edge の attack / handling / preference を使い、Rall et al. 2012 の mass-temperature scaling を接続する。
- assimilation efficiency
- attack baseline
- handling-time baseline
- functional response type
を持つ。
植物由来 assimilation = 0.45、動物由来 = 0.85。
Holling response:
`Fij = aij * Pij * Nj^q / (1 + sum(aik * Pik * hik * Nk^q))` `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)` でサンプリングする。 捕食イベントは `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. 代謝・死亡・繁殖 ## 5. 代謝・死亡・繁殖
basal metabolism は `B0 * M^0.75` を基本にする。ectotherm は Arrhenius 型温度補正、endotherm は別 normalization。 basal metabolism は `B0 * M^0.75`。ectotherm は Arrhenius 型温度補正、endotherm は別 normalization。
死亡: 死亡要因:
- predation - predation
- starvation - starvation
- lifespan - lifespan
- background hazard - background hazard
- thermal stress - 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. 移動 ## 6. 移動
@ -89,20 +79,40 @@ Hirt et al. 2017 Supplementary Table 4 の
| running | 25.5 | 0.26 | 22.0 | -0.60 | | running | 25.5 | 0.26 | 22.0 | -0.60 |
| swimming | 11.2 | 0.36 | 19.5 | -0.56 | | 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 で切り替える。 `terrestrial / freshwater / marine` profile を分離する。通常UIでは aquatic profile を freshwater / marine で切り替える。
`ClimateProvider` は seasonal fallback を持つ。観測 series を扱える内部機構は残すが、v41 では外部 Worker API を公開しない。 ## 9. 水域
## 8. 水域
初期 procedural water の radius に `sqrt(2)` を一度掛ける。union 面積の厳密測定・最適化はしない。 初期 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` を参照。

View file

@ -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` - `DT = 1/24 day`
- producer: grid biomass field - 水深なし
- zooplankton: grid cohort biomass - 人為的攪乱なし
- consumers: individual agents - experiment/batch API なし
- interaction graph: `ecology/feeding.js` の固定 sparse graph - 詳細診断出力なし
- feeding: Holling denominator + Rall allometric/temperature scaling - user-edited water geometry はそのまま
- predation event: continuous rate → `1-exp(-rate*dt)` - 初期 procedural water のみ `radius × sqrt(2)`
- metabolism: `M^0.75` + ectotherm Arrhenius response - 厳密2倍化アルゴリズムなし
- reproduction: parent survives birth - 通常表示 `×1` target = 1 simulated day / real second
- 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 から支払う
## v41 監査中の修正 検査結果は `VALIDATION.md` に記録する。
追加監査で、陸上 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 係数などは普遍的な実測定数とは主張しない。

View file

@ -1,6 +1,6 @@
{ {
"schemaVersion": 1, "schemaVersion": 1,
"modelVersion": "41.0.0", "modelVersion": "43.0.0",
"parameters": { "parameters": {
"timeStepDays": { "timeStepDays": {
"value": 0.041666666666666664, "value": 0.041666666666666664,
@ -143,7 +143,7 @@
}, },
"movement.behaviourFractions": { "movement.behaviourFractions": {
"sourceType": "model-calibration", "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": { "zooplankton.cohort": {
"sourceType": "model-calibration", "sourceType": "model-calibration",
@ -187,6 +187,53 @@
"unit": "m", "unit": "m",
"sourceType": "compatibility", "sourceType": "compatibility",
"note": "same default as v38" "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"
} }
} }
} }

View file

@ -1,31 +1,34 @@
食物連鎖シミュレータ v41 食物連鎖シミュレータ v45
起動: 起動:
python3 -m http.server 8000 python3 -m http.server 8000
http://localhost:8000/ http://localhost:8000/
Web Worker と JavaScript module を使うため、index.html の直接起動ではなく HTTP 経由を推奨します。 Web Worker と JavaScript module を使うため HTTP 経由で起動してください。
主な仕様: 主な仕様:
- day / m / kg / kJ の実単位ベース - day / m / kg / kJ の実単位
- producer は grid field、zooplankton は cohort、動物は individual agent - producer = grid field、zooplankton = cohort、動物 = individual agent
- 単一 diet を廃止し、固定 consumer-resource interaction graph を使用 - fixed consumer-resource graph
- Holling II を標準とし、handling time と Rall mass/temperature scaling を実計算へ接続 - 動物個体捕食 = Holling + Rall mass/temperature scaling
- producer/zooplankton = biomass density に対する飽和摂食(field density を prey body mass として扱わない)
- 植物由来同化 0.45、動物由来同化 0.85 - 植物由来同化 0.45、動物由来同化 0.85
- 親が生存する出生イベント - 親が生存する出生イベント
- Hirt型 maximum speed、routine / foraging / escape speed、daily movement budget、home range、dispersal - speciesごとに繁殖季節の位相をずらし、全種同時休止を防止
- freshwater / marine profile と seasonal climate fallback - 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) - 実験用 Worker API
- batch 実行ユーティリティ - batch 実行
- consumer×resource flux、assimilation、respiration、occupied area 等の詳細診断出力 - 詳細 trophic flux / assimilation / respiration 等の外部出力
UI内部で必要な最小状態(時刻、気温、個体数、植物量、種一覧、世代)は描画のため残しています。
検証: 検証:
node test/ecology.test.mjs node test/ecology.test.mjs
@ -35,8 +38,3 @@ UI内部で必要な最小状態(時刻、気温、個体数、植物量、種
- IMPLEMENTATION-NOTES.md - IMPLEMENTATION-NOTES.md
- PARAMETER-PROVENANCE.json - PARAMETER-PROVENANCE.json
- VALIDATION.md - VALIDATION.md
最終監査で追加修正:
- 陸上 producer の shade 二重適用を除去
- 動物の構造体重を energy-limited growth 化(年齢だけの自動成長を廃止)
- 初期配置専用処理を seedInitialPopulation() に限定し、実行中の導入 API は持たない

View file

@ -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`.

View file

@ -1,88 +1,109 @@
# v41 最終監査記録 # v45 検証記録
検証日: 2026-09-29 検証日: 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**
対象: 回帰テストは v45 で意図的に簡略化した。確認対象は次の5点のみ。
- `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
## 初期水域面積 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 | | 481516 | 1.9972 |
| 1 | 1.9730 | | 1 | 1.9730 |
| 123456789 | 1.9617 | | 123456789 | 1.9617 |
| 4294967295 | 1.8248 | | 4294967295 | 1.8248 |
ユーザー編集水域は入力 geometry と完全一致する回帰テストを PASS。 ユーザーが編集した水域 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
## 工学検査 ## 工学検査
- 全 `.js/.mjs`: `node --check` PASS - 全 `.js/.mjs`: `node --check` PASS
- local relative import reference: PASS - local relative import resolution: PASS
- JSON parse: PASS - `PARAMETER-PROVENANCE.json`: JSON parse PASS
- removed experiment API strings: production executable code になし - no water-depth runtime: PASS
- removed detailed diagnostic fields: production executable code になし - 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 の較正が必要。 この検証ではブラウザCanvasを用いた完全E2E操作を認証対象にしていない。Worker/World計算、モジュール構文・依存、移動テレメトリ、長期headless runを検証対象とする。
また、life-history、resource K、routine movement/home range、energy reserve/growth cost 等には model-calibration 値が残る。文献から直接採用した係数と較正値は `PARAMETER-PROVENANCE.json` で区別する。

38
app.js
View file

@ -1,32 +1,32 @@
import {habitat} from './water.js'; import {habitat} from './water.js?v=45';
import {drawScene} from './scene.js?v=41'; import {drawScene} from './scene.js?v=45';
import {lineageOrder,drawLineage} from './lineage.js'; import {lineageOrder,drawLineage} from './lineage.js?v=45';
import {paintTerrain} from './terrain.js?v=41'; import {paintTerrain} from './terrain.js?v=45';
import {createTreeSprites} from './tree-sprites.js'; import {createTreeSprites} from './tree-sprites.js?v=45';
import {formatElapsed as elapsed} from './time.js'; import {formatElapsed as elapsed} from './time.js?v=45';
import {roleLabel} from './ecology/feeding.js'; import {roleLabel} from './ecology/feeding.js?v=45';
import {pinchCamera} from './gestures.js'; import {pinchCamera} from './gestures.js?v=45';
import {W,H,GW,GH,CELL,traits} from './engine.js?v=41'; import {W,H,GW,GH,CELL,traits} from './engine.js?v=45';
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; 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 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 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); const esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
function toast(t){$('toast').textContent=t;$('toast').style.opacity=1;clearTimeout(toast.timer);toast.timer=setTimeout(()=>$('toast').style.opacity=0,2500)} 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()} 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)); 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=`<button data-water="add">追加</button><button data-water="edit">移動・変形</button><button data-water="delete">削除</button><span>${tool==='rocks'?'岩は移動を遮ります':'滑らかな岸線の水域を編集'}</span>`;el.querySelectorAll('button').forEach(b=>{b.classList.toggle('active',b.dataset.water===waterMode);b.onclick=()=>{waterMode=b.dataset.water;options()}})}} function options(){const el=$('toolOptions');if(tool==='observe')el.replaceChildren();if(tool==='water'||tool==='rocks'){el.innerHTML=`<button data-water="add">追加</button><button data-water="edit">移動・変形</button><button data-water="delete">削除</button><span>${tool==='rocks'?'岩は移動を遮ります':'滑らかな岸線の水域を編集'}</span>`;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;document.querySelectorAll('[data-tool]').forEach(x=>x.classList.toggle('active',x===b));options();if(state?.statMode&&tool!=='observe')toast('フィールド編集は通常の表示速度で行ってください')}); 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))} 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()}; $('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()} 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}} 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 shapeTool=()=>tool==='water'||tool==='rocks';
const contacts=new Map();let pinch=null; 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.d<v.w.r+20).sort((a,b)=>Math.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.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.d<v.w.r+20).sort((a,b)=>Math.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});} 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<state.animalIds.length;i++){const d=Math.hypot(state.animalData[i*7]-p.x,state.animalData[i*7+1]-p.y);if(d<dist){best=state.animalIds[i];dist=d}}selected=best;worker.postMessage({type:'select',id:selected});if(best)setTab('individual')}else if(shapeTool()&&drag.water!==undefined)sendShapes();drag=null};canvas.onpointercancel=e=>{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<state.animalIds.length;i++){const d=Math.hypot(state.animalData[i*7]-p.x,state.animalData[i*7+1]-p.y);if(d<dist){best=state.animalIds[i];dist=d}}selected=best;worker.postMessage({type:'select',id:selected});if(best)setTab('individual')}else if(shapeTool()&&drag.water!==undefined)sendShapes();drag=null;paintNeeded=true};canvas.onpointercancel=e=>{contacts.delete(e.pointerId);if(contacts.size<2)pinch=null;drag=null;paintNeeded=true};
function updateTerrain(){if(state)paintTerrain(tc,state,layer)} 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()+'<small>個体</small>';$('speciesCount').innerHTML=s.species.length+'<small>種</small>';$('plantMass').innerHTML=(s.plant/1000).toFixed(1)+'<small>t</small>';$('generation').innerHTML=s.generation+'<small>世代</small>';$('actual').textContent=state.actual.toFixed(2)+' 日/秒';$('statOverlay').hidden=!state.statMode;$('mapLegend').innerHTML=layer==='trophic'?'<span>● 一次消費者 <i style="background:#a7d46d"></i></span><span>● 二次消費者 <i style="background:#e9c976"></i></span><span>● 高次消費者 <i style="background:#d88979"></i></span>':layer==='habitat'?'<span>● 陸棲 <i style="background:#d5bc88"></i></span><span>● 両棲 <i style="background:#86c0a1"></i></span><span>● 水棲 <i style="background:#78b8da"></i></span>':'<span><i style="background:#6c7472"></i>岩</span><span><i style="background:#775b37;border-radius:1px;width:12px;height:5px"></i>倒木</span><span><i style="background:#958670"></i>死骸</span>';$('events').innerHTML=state.events.slice(0,10).map(x=>`<div class="event"><time>${elapsed(x.time)}</time><span>${esc(x.text)}</span></div>`).join('');renderDetails();if(redrawChart)drawChart();if(state.limited&&speed){setSpeed(0);toast('個体数が計算上限に達したため停止しました')}} 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()+'<small>個体</small>';$('speciesCount').innerHTML=s.species.length+'<small>種</small>';$('plantMass').innerHTML=(s.plant/1000).toFixed(1)+'<small>t</small>';$('generation').innerHTML=s.generation+'<small>世代</small>';$('actual').textContent=state.actual.toFixed(2)+' 日/秒';$('statOverlay').hidden=!state.statMode;$('mapLegend').innerHTML=layer==='trophic'?'<span>● 一次消費者 <i style="background:#a7d46d"></i></span><span>● 二次消費者 <i style="background:#e9c976"></i></span><span>● 高次消費者 <i style="background:#d88979"></i></span>':layer==='habitat'?'<span>● 陸棲 <i style="background:#d5bc88"></i></span><span>● 両棲 <i style="background:#86c0a1"></i></span><span>● 水棲 <i style="background:#78b8da"></i></span>':'<span><i style="background:#6c7472"></i>岩</span><span><i style="background:#775b37;border-radius:1px;width:12px;height:5px"></i>倒木</span><span><i style="background:#958670"></i>死骸</span>';$('events').innerHTML=state.events.slice(0,10).map(x=>`<div class="event"><time>${elapsed(x.time)}</time><span>${esc(x.text)}</span></div>`).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=`<div class="detail-title"><strong style="color:${sp.color}">${esc(sp.name)}</strong><span>#${a.id}</span></div><p class="note">${roleLabel(sp.trophicRole)} · ${phys} · ${habitat(a.g.waterAffinity)}</p><p class="note">${a.action} · 第${a.generation}世代 · 年齢 ${elapsed(a.age)}</p><div class="meter"><label>エネルギー <span>${a.energy.toFixed(0)} / ${a.maxEnergy.toFixed(0)} kJ</span></label><progress value="${a.energy}" max="${a.maxEnergy}"></progress></div><div class="meter"><label>スタミナ <span>${a.stamina.toFixed(1)} / ${a.maxStamina.toFixed(1)}</span></label><progress value="${a.stamina}" max="${a.maxStamina}"></progress></div><p class="note">現在体重 ${a.mass.toFixed(2)} kg · 隠匿 ${(a.concealment*100).toFixed(0)}%</p><div class="section-heading"><h2>個体形質</h2><span>生態単位系</span></div>${Object.keys(traits).map(k=>`<div class="gene-row"><span>${names[k]}</span><b>${k==='waterAffinity'?habitat(a.g[k]):a.g[k].toFixed(2)}</b></div>`).join('')}`}else if(selected)$('individual').innerHTML='<div class="empty"><span>⌖</span><h2>観察個体は死亡しました</h2><p>別の個体をクリックして観察を続けられます。</p></div>'}} 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=`<div class="detail-title"><strong style="color:${sp.color}">${esc(sp.name)}</strong><span>#${a.id}</span></div><p class="note">${roleLabel(sp.trophicRole)} · ${phys} · ${habitat(a.g.waterAffinity)}</p><p class="note">${a.action} · 第${a.generation}世代 · 年齢 ${elapsed(a.age)}</p><div class="meter"><label>エネルギー <span>${a.energy.toFixed(0)} / ${a.maxEnergy.toFixed(0)} kJ</span></label><progress value="${a.energy}" max="${a.maxEnergy}"></progress></div><div class="meter"><label>スタミナ <span>${a.stamina.toFixed(1)} / ${a.maxStamina.toFixed(1)}</span></label><progress value="${a.stamina}" max="${a.maxStamina}"></progress></div><p class="note">現在体重 ${a.mass.toFixed(2)} kg · 隠匿 ${(a.concealment*100).toFixed(0)}%</p><div class="section-heading"><h2>個体形質</h2><span>生態単位系</span></div>${Object.keys(traits).map(k=>`<div class="gene-row"><span>${names[k]}</span><b>${k==='waterAffinity'?habitat(a.g[k]):a.g[k].toFixed(2)}</b></div>`).join('')}`}else if(selected)$('individual').innerHTML='<div class="empty"><span>⌖</span><h2>観察個体は死亡しました</h2><p>別の個体をクリックして観察を続けられます。</p></div>'}}
@ -36,5 +36,5 @@ function drawChart(){
const {ordered,first,last,byId}=drawLineage(cc,{width:w,height:h,species,hist:state.history,scale:d,layer}); const {ordered,first,last,byId}=drawLineage(cc,{width:w,height:h,species,hist:state.history,scale:d,layer});
$('chartStart').textContent=elapsed(first);$('chartEnd').textContent=elapsed(last);$('chartData').innerHTML=ordered.map(({s})=>`<li>${esc(s.name)}:${s.count}個体。${byId.has(s.parent)?'親系統 '+esc(byId.get(s.parent).name):s.originType==='外来'?'外来種':'初期種'}</li>`).join(''); $('chartStart').textContent=elapsed(first);$('chartEnd').textContent=elapsed(last);$('chartData').innerHTML=ordered.map(({s})=>`<li>${esc(s.name)}:${s.count}個体。${byId.has(s.parent)?'親系統 '+esc(byId.get(s.parent).name):s.originType==='外来'?'外来種':'初期種'}</li>`).join('');
} }
function paint(now){requestAnimationFrame(paint);if(!state||!view.width)return;const frameGap=state.statMode?900:state.animalIds.length>4000?50:state.animalIds.length>1500?33:16;if(now-lastPaint<frameGap)return;lastPaint=now;const t=drawScene(ctx,{state,view,zoom,panX,panY,terrain,tool,drag,water,treeSprites,selected,hover,waterMode,layer});$('scale').textContent=Math.round(50/t.s)+' m';$('loading').hidden=true} function paint(now){requestAnimationFrame(paint);if(!state||!view.width||!paintNeeded)return;const frameGap=state.statMode?900:speed>=100?80:speed>=20?50:state.animalIds.length>4000?50:33;if(now-lastPaint<frameGap)return;lastPaint=now;paintNeeded=false;const t=drawScene(ctx,{state,view,zoom,panX,panY,terrain,tool,drag,water,treeSprites,selected,hover,waterMode,layer});$('scale').textContent=Math.round(50/t.s)+' m';$('loading').hidden=true}
worker.onmessage=({data})=>{if(data.type==='notice'){toast(data.text);return}try{state=state?{...state,...data}:data;if(!drag)water=state[tool==='rocks'?'rocks':'water'];if(data.moisture||layer!=='moisture'&&data.plants)updateTerrain();if(data.stats){render(!!data.history);$('runStatus').textContent=speed?'シミュレーション実行中':'一時停止中'}else $('actual').textContent=state.actual.toFixed(2)+' 日/秒'}catch(e){console.error('状態表示エラー',e);$('runStatus').textContent='表示エラー';$('loading').textContent='表示エラー:再読み込みしてください';toast('表示エラー:再読み込みしてください')}finally{worker.postMessage({type:'ack'})}};worker.onerror=e=>{setSpeed(0);$('runStatus').textContent='計算エラー';$('loading').textContent='計算エラー:再読み込みしてください';toast('計算エラー:再読み込みしてください');console.error(e)};resize();requestAnimationFrame(paint); worker.onmessage=({data})=>{if(data.type==='notice'){toast(data.text);return}try{state=state?{...state,...data}:data;paintNeeded=true;if(!drag)water=state[tool==='rocks'?'rocks':'water'];if(data.moisture||layer!=='moisture'&&data.plants)updateTerrain();if(data.stats){render(!!data.history);$('runStatus').textContent=speed?'シミュレーション実行中':'一時停止中'}else $('actual').textContent=state.actual.toFixed(2)+' 日/秒'}catch(e){console.error('状態表示エラー',e);$('runStatus').textContent='表示エラー';$('loading').textContent='表示エラー:再読み込みしてください';toast('表示エラー:再読み込みしてください')}finally{worker.postMessage({type:'ack'})}};worker.onerror=e=>{setSpeed(0);$('runStatus').textContent='計算エラー';$('loading').textContent='計算エラー:再読み込みしてください';toast('計算エラー:再読み込みしてください');console.error(e)};resize();requestAnimationFrame(paint);

View file

@ -0,0 +1,83 @@
# Final structure cleanup — 2026-09-12
## Intent
Finish the structural cleanup after the component pruning pass without changing Mandelbrot numerical behavior. The production HTML is now the single source of truth for both release and regression.
## Changes
### Single source of truth
- Removed the persistent `tests/index.test.html` copy.
- `tests/regression.mjs` now parses and executes production `index.html` directly.
- `tests/browser_smoke.mjs` generates a temporary instrumented page from production `index.html`, then removes it even when browser connection fails.
- Added a regression assertion that a persistent test HTML copy must not reappear.
### Correctness fixes found during cleanup
- Fixed `cancelRender()` export cancellation/generation update. The production-only typo `truestate.token++` is gone; export cancellation and `state.token++` are separate operations.
- Fixed the render cancellation/error path that called the nonexistent `returnfinishRenderClock(...)` token. Cancelled generations now close the render clock correctly and return.
### Removed write-only state and UI churn
Removed production state that had no readers:
- `lastEngine`
- `frontierRounds`
- `frontierPixels`
- `frontierEscaped`
- `frontierIter`
- `frontierMinimumIter`
- `frontierConverged`
- `gpuUnavailable`
- `qualityIter`
- `refineMs`
- `lastRender`
- `gpuStage`
Progress-only `lastEngine` assignments and their no-op `updateStats()` calls were removed. The user-visible status continues to derive from `drawState`, unresolved counts, temporal fill, render clock, and GPU error state.
### Renderer diagnostics
Removed retained diagnostics that were never consumed:
- `this.adapter`
- `this.adapterInfo`
- `this.compilation`
- `this.uncapturedErrors`
Shader compilation errors are still checked and thrown immediately. The latest uncaptured GPU error is still copied to `state.gpuError`. Device-loss stage tracking stays in renderer-local `activeGpuStage` / `lastCompletedGpuStage`.
### Fixed-point / WGSL dead definitions
- Removed production-only unused `fromDec()` and `decimalRequiredBits()` helpers. Test decimal conversion now lives only in the test harness.
- Removed unused WGSL `FIELD_INTERIOR_LIKELY`.
- All 19 surviving kernels are byte-identical to the pre-cleanup kernels after deleting that single unused constant line.
- Simplified stale reference-selector comments and redundant return logic.
### Export metadata
Both export paths now call one `buildExportMetadata()` function. Common fields now have one definition for:
- finite-budget completion
- membership certification
- precision policy
- iteration policy
- view coordinates
- palette description
- renderer/shader version
- pixel contract
Path-specific tile/ring fields are added only when applicable.
### Small dead-code cleanup
- Removed dead `dispatchWork` local after correction accounting was deleted.
- Removed dead `sampleCount`, `lastUi`, and final-frame `elapsed` locals.
- Renamed stale `reusePreview` to `reusePriorField` and the no-frame `PREVIEW` draw-state label to `PENDING`.
## Verification
`node tests/regression.mjs` passes and directly executes production JS. It covers routing, finite-budget semantics, staged operation-limit scoring, exact interior proof, integer-grid history reuse, nearby reference reuse, Worker lifecycle, correction pipeline count, export cancellation/generation behavior, common export metadata, pinned WGSL hashes, and CPU worker smoke tests.
The optional browser smoke script passes syntax validation. A real WebGPU browser run is not claimed in this environment.

View file

@ -0,0 +1,44 @@
{
"date": "2026-09-12",
"baseline": {
"artifact": "mandelbrot-cleaned-all/index.html",
"bytes": 212483,
"gzip9_bytes_shell": 53779
},
"final": {
"index_bytes": 208576,
"gzip9_bytes_shell": 52563,
"sha256": "8355da57e2f546c4e1cbeb2237e9dbfb65168b6d4c254929a2b02078c086a935",
"wgsl_kernels": 19,
"persistent_test_html": false,
"definition_only_locals_or_functions": 0
},
"change": {
"index_bytes": -3907,
"index_percent": -1.8387,
"gzip9_bytes_shell": -1216,
"gzip9_percent": -2.2616
},
"kernel_equivalence": {
"kernel_object_keys_including_version": 20,
"byte_identical_without_normalization": 9,
"byte_identical_after_removing_old_unused_FIELD_INTERIOR_LIKELY_line": 20,
"behavioral_kernel_changes": 0
},
"regression": {
"status": "PASS",
"runs_after_warmup": 12,
"median_ms": 54.8059,
"min_ms": 47.6742,
"max_ms": 71.0208
},
"browser_smoke": {
"script_syntax": "PASS",
"temporary_file_cleanup_on_connection_failure": "PASS",
"real_webgpu_run": "not available in this environment"
},
"fixed_bugs": [
"cancelRender production typo truestate.token++",
"render error/cancellation typo returnfinishRenderClock(...)"
]
}

View file

@ -0,0 +1,153 @@
# 5改善案の本体統合と再評価
## 対象
前回評価表の「Numeric tile cache」より上にあった次の5案を `index.html` に統合した。
1. 一般周期検出
2. perturbation series approximation
3. sparse active state
4. dynamic multi-reference
5. CPU Worker fallback
`Numeric tile cache` 以下は今回の対象外。基準は統合前の `index.html`(SHA-256 `8355da57...086a935`)。
## 実装
### 1. 周期検出
- direct f32 kernel に Brent 型 O(1) candidate search を追加。
- 単純な「近いから内部」判定は再評価で危険と判明したため、候補後に **2周期分の再一致**と **周期乗数 `|μ| < 0.95`** を確認してから `FIELD_INTERIOR_HEURISTIC=2` にする。
- `FIELD_INTERIOR_PROVEN=3` とは分離し、heuristic が1画素でもあれば `membershipCertified=false`。
- strict/export では無効。
- FAST perturbation 上の周期早期停止は撤回。forced-direct 診断で deep seahorse に false candidate が残ったため、deep path は series / continuation に任せる。
### 2. Series approximation
- reference worker で 1〜6次係数を更新。
- GPU に送るのは 1〜4次係数。5・6次は truncation error の安全側判定に使用。
- validity 条件を満たす最大 jump を reference ごとに計算し、FAST perturbation の初期反復を4次多項式評価へ置換。
- strict/export では series jump を無効化し、従来 recurrence を使う。
### 3. Sparse active state
旧 continuation は state/queues/mark をほぼ全画素サイズで持っていた。新構成は以下。
- `DeepState`: active slot ごと 32 B
- queue A/B: active slot ID
- `pixelMap`: active slot → pixel index
- membership: 1 bit/pixel bitset
- 初期 capacity: `active * 1.25 + 256`(全画素上限)
- correction により新しい `OPERATION_LIMIT` が生じた場合は、slot allocator を複雑化せず metadata から compact session を再構築する。
### 4. Dynamic multi-reference
- failure-guided reference recovery を最大3 passまで反復。
- 既に試した reference identity は再試行しない。
- 1 pass の改善が `max(8 pixels, 2%)` 未満なら打ち切る。
- 固定 2x2 / 3x3 配置ではなく、既存 failure sample + scorer を使って必要箇所だけ reference を追加する。
### 5. CPU Worker fallback
- WebGPU 非対応/初期化失敗時に最大4 Workerで direct Mandelbrot を描画。
- cardioid / period-2 bulb、有限 iteration、既存 palette の主要表示をサポート。
- JS `Number` で adjacent pixel を区別できない deep zoom では停止し、誤った deep image を出さない。
- CPU fallback の `OPERATION_LIMIT` と周期 heuristic は未確定扱い。WebGPU の high-precision path の代替ではなく互換経路。
## 再評価結果
### 周期検出
96×64、maxIter=8192、candidate は 65,536 iteration まで escape を追跡。
| view | production backend | 反復仕事量削減 | false candidate |
|---|---|---:|---:|
| overview | direct | 84.9% | 0 |
| period3-bulb | direct | 99.09% | 0 |
| period3-core | direct | 99.12% | 0 |
| seahorse-boundary | direct | 0% | 0 |
| exterior | direct | 80.0% | 0 |
| seahorse-deep | fast-extended | **周期停止を使用しない** | — |
forced-direct では seahorse-deep に false candidate が残るため、FAST 側を無効化した判断は維持する。
### Series approximation
production fast-reference worker の実出力を使用し、4次 series と直接 perturbation の delta を8点で比較。
| span | jump | 最大絶対誤差 |
|---:|---:|---:|
| 1e-5 | 53 | 7.11e-9 |
| 1e-7 | 446 | 7.74e-9 |
| 1e-9 | 930 | 7.46e-9 |
| 1e-11 | 1037 | 2.28e-9 |
reference build の一部として係数を作るため CPU 側の追加仕事はあるが、deep zoom ほど jump が大きい。実 GPU frame time はこの環境では測れていない。
### Sparse active state
standard 1,048,576 px の continuation workspace モデル。
| active ratio | 旧 | 新 | 削減 |
|---:|---:|---:|---:|
| 100% | 44.0 MiB | 44.13 MiB | -0.28% |
| 25% | 44.0 MiB | 13.89 MiB | 68.4% |
| 10% | 44.0 MiB | 5.64 MiB | 87.2% |
| 1% | 44.0 MiB | 0.686 MiB | 98.4% |
active がほぼ全画面なら pixel map + bitset の分だけわずかに悪化する。狙いは sparse frontier であり、その条件では効果が大きい。
### Dynamic multi-reference
production は failure-guided 最大3追加 reference。GPU end-to-end の `A_num` は実 GPU 不在で測れないため、前回と同じ f32 perturbation locality surrogate を再実行した。
seahorse span=1e-7:
- center 1 ref: class mismatch 2.578%
- center + 2x2 (5 refs): **1.445%**
- center + 3x3 (9 refs): 1.953%
reference を無制限に増やすのは逆効果になり得るため、「失敗箇所から選ぶ + 3 pass cap」は妥当。
### CPU fallback
production `cpuFallbackWorkerSource()` 自体を Node worker shim で実行。640×360、350 iter、7 run median。
- 1 Worker: 約 97.2 ms
- 4 Workers: 約 36.0 ms
- speedup: **2.70×**
- escaped / OP_LIMIT / heuristic / work count は 1 Worker と4 Workerで一致。
これはブラウザ 2D canvas compositing を含まない worker compute 値。
## 回帰・サイズ
- JS syntax: PASS
- production regression: **5/5 PASS**
- regression median: 約 0.08 s
- WGSL kernels: 19本、hash 更新済み
- `index.html`: 208,576 → 223,672 bytes(+7.24%)
- gzip -9: 52,563 → 57,198 bytes(+8.82%)
## 実 GPU 検証の限界
コンテナの Headless Chromium + SwiftShader は EGL 初期化に失敗し、WebGPU smoke を開始できなかった。そのため以下は未測定。
- stable frame p50/p95
- GPU series jump の実時間短縮
- sparse scatter/gather の実 GPU overhead
- dynamic multi-reference 導入後の production `A_num`
したがって **CPU/数値 surrogate を GPU frame-time として扱わない**。
## 結論
| 改善 | 統合判断 | 再評価 |
|---|---|---|
| 周期検出 | 採用(directのみ) | 高周期内部で非常に有効。FASTでは安全性不足のため無効 |
| Series | 採用(interactive) | deepほど jump が増え、有望 |
| Sparse active state | 採用 | active≪100%ならメモリ効果が明確 |
| Dynamic multi-reference | 採用(最大3 pass) | reference増加は非単調。failure-guided capが妥当 |
| CPU fallback | 採用 | 互換性改善。4 Workerで約2.7×、deep precision代替ではない |
次に実機で確認すべき優先順位は **series GPU frame time → sparse continuation latency → multi-reference の `A_num`**。

View file

@ -0,0 +1,6 @@
# Integrated improvements
- `IMPLEMENTATION.md`: 実装内容、設計判断、再評価、限界
- `results.json`: 再現用の生データ
- benchmark: `../../experiments/integrated_bench.mjs`
- regression: `../../tests/regression.mjs`

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,426 @@
{
"date": "2026-09-29",
"scope": [
"direct periodicity heuristic",
"perturbation series approximation",
"sparse active continuation state",
"dynamic failure-guided multi-reference",
"CPU Worker fallback"
],
"baseline": {
"path": "../mandelbrot_work/index.html",
"sha256": "8355da57e2f546c4e1cbeb2237e9dbfb65168b6d4c254929a2b02078c086a935",
"bytes": 208576,
"gzip9Bytes": 52702
},
"integrated": {
"sha256": "544820c5024cf6d56e012db6d1bec3ddfcef5d833691b890baa64b26c5c2ee5a",
"bytes": 223672,
"gzip9Bytes": 57375
},
"regression": {
"passes": 5,
"timesSeconds": [
0.08,
0.09,
0.07,
0.07,
0.09
],
"medianSeconds": 0.08,
"status": "PASS",
"kernelCount": 19
},
"webGpuBrowserSmoke": {
"status": "UNAVAILABLE_IN_CONTAINER",
"reason": "Headless Chromium SwiftShader/EGL initialization failed before WebGPU could run; no real GPU frame-latency claim is made."
},
"benchmarks": {
"date": "2026-09-29T08:28:46.101Z",
"periodicity": {
"w": 96,
"h": 64,
"maxIter": 8192,
"oracleIter": 65536,
"rows": [
{
"view": "overview",
"productionBackend": "direct",
"pixels": 6144,
"baseWork": 884420,
"heuristicWork": 133404,
"workReduction": 0.849162162773343,
"candidates": 96,
"falseCandidates": 0,
"baseLimit": 104,
"heuristicLimit": 8,
"diagnosticIfForcedDirect": null,
"ms": 68.786555
},
{
"view": "period3-bulb",
"productionBackend": "direct",
"pixels": 6144,
"baseWork": 50331648,
"heuristicWork": 460156,
"workReduction": 0.9908575216929117,
"candidates": 6144,
"falseCandidates": 0,
"baseLimit": 6144,
"heuristicLimit": 0,
"diagnosticIfForcedDirect": null,
"ms": 3092.6467549999998
},
{
"view": "period3-core",
"productionBackend": "direct",
"pixels": 6144,
"baseWork": 50331648,
"heuristicWork": 442395,
"workReduction": 0.991210401058197,
"candidates": 6144,
"falseCandidates": 0,
"baseLimit": 6144,
"heuristicLimit": 0,
"diagnosticIfForcedDirect": null,
"ms": 3061.9720069999994
},
{
"view": "seahorse-boundary",
"productionBackend": "direct",
"pixels": 6144,
"baseWork": 410111,
"heuristicWork": 410111,
"workReduction": 0,
"candidates": 0,
"falseCandidates": 0,
"baseLimit": 0,
"heuristicLimit": 0,
"diagnosticIfForcedDirect": null,
"ms": 17.153881000000183
},
{
"view": "seahorse-deep",
"productionBackend": "fast-extended",
"pixels": 6144,
"baseWork": 36579905,
"heuristicWork": 6167354,
"workReduction": null,
"candidates": 0,
"falseCandidates": 0,
"baseLimit": 4304,
"heuristicLimit": 4304,
"diagnosticIfForcedDirect": {
"workReduction": 0.8314004916087125,
"candidates": 4125,
"falseCandidates": 12
},
"ms": 2289.795112
},
{
"view": "exterior",
"productionBackend": "direct",
"pixels": 6144,
"baseWork": 686432,
"heuristicWork": 137063,
"workReduction": 0.8003254510279241,
"candidates": 72,
"falseCandidates": 0,
"baseLimit": 79,
"heuristicLimit": 7,
"diagnosticIfForcedDirect": null,
"ms": 37.95325000000048
}
],
"note": "Production DIRECT_F32 rule with two attracting-cycle confirmations. FAST/perturbation periodic early-stop is intentionally disabled."
},
"series": {
"rows": [
{
"span": 1e-05,
"jump": 53,
"errorLog2": -22.11596292311006,
"maxAbsError": 7.112067409017996e-09,
"maxRelativeError": 9.415623690713784e-05,
"refLen": 1200,
"buildMs": 32.31590200000028
},
{
"span": 1e-07,
"jump": 446,
"errorLog2": -22.302471602690176,
"maxAbsError": 7.736805567567409e-09,
"maxRelativeError": 0.0031991190750368034,
"refLen": 1200,
"buildMs": 33.50016399999913
},
{
"span": 1e-09,
"jump": 930,
"errorLog2": -22.038152635655024,
"maxAbsError": 7.459933177532973e-09,
"maxRelativeError": 1.015603159369818e-05,
"refLen": 1200,
"buildMs": 29.815043000000514
},
{
"span": 1e-11,
"jump": 1037,
"errorLog2": -23.96377927072575,
"maxAbsError": 2.2791360188558774e-09,
"maxRelativeError": 4.948137247650717e-06,
"refLen": 1200,
"buildMs": 37.88702799999919
}
],
"note": "Production fast-reference worker output. Error compares packed 4th-order series against direct perturbation over 8 screen-domain deltas."
},
"sparseActiveState": {
"rows": [
{
"preset": "fast",
"pixels": 524288,
"activeRatio": 1,
"capacity": 524288,
"oldBytes": 23068672,
"newBytes": 23134208,
"reduction": -0.0028409090909091717
},
{
"preset": "fast",
"pixels": 524288,
"activeRatio": 0.25,
"capacity": 164096,
"oldBytes": 23068672,
"newBytes": 7285760,
"reduction": 0.6841708096590908
},
{
"preset": "fast",
"pixels": 524288,
"activeRatio": 0.1,
"capacity": 65792,
"oldBytes": 23068672,
"newBytes": 2960384,
"reduction": 0.8716708096590909
},
{
"preset": "fast",
"pixels": 524288,
"activeRatio": 0.01,
"capacity": 6810,
"oldBytes": 23068672,
"newBytes": 365176,
"reduction": 0.9841700467196378
},
{
"preset": "standard",
"pixels": 1048576,
"activeRatio": 1,
"capacity": 1048576,
"oldBytes": 46137344,
"newBytes": 46268416,
"reduction": -0.0028409090909091717
},
{
"preset": "standard",
"pixels": 1048576,
"activeRatio": 0.25,
"capacity": 327936,
"oldBytes": 46137344,
"newBytes": 14560256,
"reduction": 0.6844149502840908
},
{
"preset": "standard",
"pixels": 1048576,
"activeRatio": 0.1,
"capacity": 131328,
"oldBytes": 46137344,
"newBytes": 5909504,
"reduction": 0.8719149502840909
},
{
"preset": "standard",
"pixels": 1048576,
"activeRatio": 0.01,
"capacity": 13364,
"oldBytes": 46137344,
"newBytes": 719088,
"reduction": 0.9844141873446378
},
{
"preset": "high",
"pixels": 4194304,
"activeRatio": 1,
"capacity": 4194304,
"oldBytes": 184549376,
"newBytes": 185073664,
"reduction": -0.0028409090909091717
},
{
"preset": "high",
"pixels": 4194304,
"activeRatio": 0.25,
"capacity": 1310976,
"oldBytes": 184549376,
"newBytes": 58207232,
"reduction": 0.6845980557528408
},
{
"preset": "high",
"pixels": 4194304,
"activeRatio": 0.1,
"capacity": 524544,
"oldBytes": 184549376,
"newBytes": 23604224,
"reduction": 0.8720980557528409
},
{
"preset": "high",
"pixels": 4194304,
"activeRatio": 0.01,
"capacity": 52685,
"oldBytes": 184549376,
"newBytes": 2842428,
"reduction": 0.9845980080691251
}
],
"note": "Production allocation: 32 B state + 2x4 B queues + 4 B pixel map per active slot, plus 1-bit membership bitmap. Capacity adds 25% + 256 headroom."
},
"multiReference": {
"maxGuidedPasses": 3,
"w": 64,
"h": 40,
"maxIter": 1200,
"rows": [
{
"view": "seahorse-1e-5",
"grid": 1,
"references": 1,
"total": 2560,
"refEnd": 0,
"mismatchClass": 3,
"mismatchIter": 155,
"mismatchRate": 0.001171875
},
{
"view": "seahorse-1e-5",
"grid": 2,
"references": 5,
"total": 2560,
"refEnd": 0,
"mismatchClass": 3,
"mismatchIter": 155,
"mismatchRate": 0.001171875
},
{
"view": "seahorse-1e-5",
"grid": 3,
"references": 9,
"total": 2560,
"refEnd": 0,
"mismatchClass": 3,
"mismatchIter": 155,
"mismatchRate": 0.001171875
},
{
"view": "seahorse-1e-7",
"grid": 1,
"references": 1,
"total": 2560,
"refEnd": 0,
"mismatchClass": 66,
"mismatchIter": 74,
"mismatchRate": 0.02578125
},
{
"view": "seahorse-1e-7",
"grid": 2,
"references": 5,
"total": 2560,
"refEnd": 0,
"mismatchClass": 37,
"mismatchIter": 41,
"mismatchRate": 0.014453125
},
{
"view": "seahorse-1e-7",
"grid": 3,
"references": 9,
"total": 2560,
"refEnd": 0,
"mismatchClass": 50,
"mismatchIter": 67,
"mismatchRate": 0.01953125
}
],
"note": "CPU f32 perturbation locality surrogate. Production selects up to three additional references from failure clusters instead of fixed grids."
},
"cpuFallback": {
"w": 640,
"h": 360,
"iter": 350,
"threads": 4,
"one": {
"medianMs": 97.22207600000002,
"times": [
115.15600200000154,
86.49102299999868,
97.22207600000002,
97.75022499999977,
95.21002200000112,
105.08147800000006,
81.41742200000044
],
"work": 2658084,
"escaped": 176648,
"operationLimit": 3394,
"heuristicInterior": 1654
},
"multi": {
"medianMs": 36.02884199999971,
"times": [
100.63864199999989,
61.91339699999844,
50.152170999999726,
34.4066870000006,
34.23579900000004,
36.02884199999971,
34.373131000000285
],
"work": 2658084,
"escaped": 176648,
"operationLimit": 3394,
"heuristicInterior": 1654
},
"speedup": 2.698451312978663,
"workMatch": true,
"escapedMatch": true,
"operationLimitMatch": true,
"heuristicMatch": true,
"note": "Production CPU fallback Worker source executed under node:worker_threads shim."
},
"sourceAudit": {
"heuristicClass": true,
"strictDisablesPeriodicity": true,
"seriesBinding": true,
"compactPixelMap": true,
"multiReferencePasses": true,
"cpuFallback": true
},
"environment": {
"node": "v22.16.0",
"cpus": 5,
"platform": "linux",
"arch": "x64"
}
},
"assessment": {
"periodicity": "ADOPT_DIRECT_ONLY",
"seriesApproximation": "ADOPT_EXPERIMENTAL_INTERACTIVE",
"sparseActiveState": "ADOPT",
"dynamicMultiReference": "ADOPT_CAPPED_FAILURE_GUIDED",
"cpuFallback": "ADOPT_COMPATIBILITY_PATH"
}
}

View file

@ -0,0 +1,137 @@
# Performance / Memory Cost Audit
## Purpose
Audit the integrated Mandelbrot viewer for processing that is low-value, redundant, or harmful from a speed / memory perspective. This audit intentionally **does not remove or alter any production behavior** in `index.html`.
Production source audited:
- `index.html`
- SHA-256: `544820c5024cf6d56e012db6d1bec3ddfcef5d833691b890baa64b26c5c2ee5a`
- Regression: PASS, 19 WGSL kernels
Measurements are CPU/static surrogates unless explicitly stated. Real WebGPU frame timing is not available in this container because the browser WebGPU backend cannot initialize successfully here.
## Conclusions
### A — High-confidence waste / harmful cost
| ID | Finding | Cost | Benefit observed | Confidence |
|---|---|---|---|---|
| A1 | Numeric history is recopied after every accepted recolor | 8 B/px copy per recolor | Numeric data did not change | Very high |
| A2 | `cpuFrameRgba` retains the assembled CPU fallback frame but is never read | 4 B/px JS heap retained | None | Very high |
| A3 | `certifyInterior()` runs a full-frame unknown-statistics pass and waits for GPU completion before primary compute | Full meta scan + queue synchronization | Partial proof/history-seed caller does not consume these stats | Very high |
| A4 | Strict direct rendering still updates Brent-cycle state even though periodicity classification is disabled | Per-iteration ALU/register state | None in strict mode | Very high |
| A5 | Export workspace survives export completion | Up to ~24 MiB GPU memory with 512 tiles / 3 slots | None while not exporting | Very high |
| A6 | Deep active/correction workspaces survive the hard frame that required them | active workspace up to ~44.1 MiB standard / ~176.5 MiB high; dense correction queue 4/16 MiB | None on later easy frames until reuse/resize | High |
### B — Strong optimization candidates, but keep until GPU A/B validation
| ID | Finding | Evidence | Assessment |
|---|---|---|---|
| B1 | Numeric history is always resident although exact-grid pan reuse is restrictive | 8 B/px resident. Under common budget-scaled resolutions, integer CSS pans 1–100 px produced 0/100 exact-grid reuse hits in the static alignment test | Likely over-allocated; make lazy/telemetry-driven rather than delete immediately |
| B2 | CPU exact cardioid/bulb tile proof runs for every numerical frame | 1024×768: ~4.8–6.1 ms median; 2048²: ~25–28 ms median. Four of six fixed corpus views certified 0 pixels | Add cheap spatial gate; direct backend especially needs A/B comparison |
| B3 | `fast-coverage-repair` runs a full-screen FAST pass after tiled coverage | Tile loops cover the full compute domain; mode 2 only repairs reason=0 untouched metadata | Likely redundant defensive pass; instrument reason=0 count before removal |
| B4 | `captureColorSource()` snapshots `meta+smooth` during rendering | 8 B/px copy per snapshot; provisional snapshots can occur every 125 ms. Snapshot allocation is 12 B/px | Useful only for live recoloring while render is in flight; gate more aggressively |
| B5 | Color-auto idle mode retains a color snapshot | standard ~12 MiB, high ~48 MiB at preset caps | Idle color-auto recolor path uses current field, not `recolorPublished()` | Snapshot should be render-only/on-demand |
| B6 | Adaptive initial-iteration probe uses high-precision scoring before every adaptive frame | Fixed corpus: ~1.3–12.5 ms and all six chose 350. Exploratory views can choose 512, so feature is not useless | Cache/gate/replace with cheaper trigger; do not delete blindly |
| B7 | Active continuation can replay already-performed iterations | initial OP_LIMIT session starts state at n=0; reference change resets progress to 0; recovered pixels can rebuild whole active session | Potentially large on the hardest views; add replay counters before redesign |
### C — Expensive but currently justified / not a deletion target
- General periodicity detection: CPU surrogate showed ~98% iteration reduction in period-3 interiors. It can add overhead on boundary-only views, but removal is not justified.
- Sparse numerical correction and reason bucketing: it adds full scans/queue construction, but isolates expensive DS/BigInt work. Needs GPU A/B before structural change.
- Stable texture reprojection: consumes one history texture but directly improves interaction latency.
- Reference cache / guided multi-reference: memory cost is bounded and prior evaluation showed reduced perturbation mismatch.
- Series approximation: small reference metadata cost with large deep-zoom iteration skip in prior evaluation.
## Key measurements
### Recolor numeric-history copy bandwidth
`commitHistory()` always calls `commitNumericHistory()`. Therefore a color-only update copies unchanged `meta` and `smooth` buffers (8 B/px). Color auto can request a pass every 50 ms while idle.
| preset cap | unchanged numeric copy / recolor | at 20 Hz |
|---|---:|---:|
| fast, 524,288 px | 4 MiB | 80 MiB/s |
| standard, 1,048,576 px | 8 MiB | 160 MiB/s |
| high, 4,194,304 px | 32 MiB | 640 MiB/s |
This is in addition to the actual color pass bandwidth.
### Color snapshot
`captureColorSource()` allocates/copies:
- meta: 4 B/px
- smooth: 4 B/px
- texture: 4 B/px
Resident cost = 12 B/px: ~6 MiB fast, 12 MiB standard, 48 MiB high at preset caps. A provisional snapshot copies 8 B/px and can be refreshed at up to 8 Hz, equivalent to ~32 / 64 / 256 MiB/s at the preset caps.
### Exact interior tile proof
Seven-run median on this host:
| view | 1024×768 | certified | 2048×2048 | certified |
|---|---:|---:|---:|---:|
| overview | ~6.1 ms | 7.3% | ~25.5 ms | 8.6% |
| period3 bulb | ~5.1 ms | 0% | ~26.5 ms | 0% |
| period3 core | ~4.8 ms | 0% | ~28.1 ms | 0% |
| seahorse boundary | ~6.0 ms | 0% | ~25.1 ms | 0% |
| seahorse deep | ~5.6 ms | 0% | ~26.1 ms | 0% |
The exact proof is still useful in views that overlap the main cardioid / period-2 bulb, especially on the perturbation path. The issue is unconditional invocation, not the proof mechanism itself.
### Exact-grid numeric-history reuse
The history seed requires identical span and an approximately integer render-pixel shift. When the resolution is pixel-budget limited, render pixels often do not align with CSS pixels.
Examples from the current resize rule, testing integer CSS horizontal pans of 1–100 px:
| screen / quality | render size | reuse hits |
|---|---:|---:|
| 1920×1080 / fast | 965×543 | 0/100 |
| 1920×1080 / standard | 1365×768 | 0/100 |
| 1920×1080 / high | 2731×1536 | 0/100 |
| 1024×768@1× / standard | 1024×768 | 100/100 |
This is a static alignment test, not real interaction telemetry. It supports making numeric history conditional rather than proving that it has no value.
### Persistent workspaces
- Sparse active workspace, worst full-active capacity: ~44.1 MiB standard, ~176.5 MiB high.
- At 10% active: ~5.64 MiB standard, ~22.51 MiB high.
- Dense deep correction queue: 4 MiB standard, 16 MiB high.
- Export workspace at 512 tile, depth 3: ~24 MiB retained after export.
- Non-AA export currently allocates four 512² sample textures, ~4 MiB per slot, although 1× rendering does not need the four AA sample textures.
## Static code locations
- `index.html:1299-1300`: pre-primary unknown stats + synchronization in `certifyInterior()`.
- `index.html:1383-1384`: full numeric-history copy.
- `index.html:1416`: `commitHistory()` always commits numeric history.
- `index.html:1822`: color-only recolor promotes history and therefore triggers numeric copy.
- `index.html:1718`: `state.cpuFrameRgba=rgba`; the state value has no reader.
- `index.html:162-163`: cycle state still updated in strict direct mode.
- `index.html:1349-1350`: tiled FAST coverage followed by full-screen coverage-repair pass.
- `index.html:1751-1756`: provisional color snapshots at 125 ms throttle.
- `index.html:1774`: old completed field snapshot at every numerical render start.
- `index.html:1866`: idle color-auto snapshot allocation.
- `index.html:1218`: active workspace persists unless another active allocation request causes a >4× shrink.
- `index.html:1217`: full-pixel dense correction queue persists once allocated.
- `index.html:1222-1226, 1898-1944`: export workspace lifetime extends beyond export job.
- `index.html:1306-1307, 1648, 1654`: active continuation reinitialization/rebuild can reset progress.
## Recommended instrumentation before deleting anything
1. Add counters for numeric-history `attempt / hit / reused pixels / bytes copied`.
2. Count reason-0 pixels immediately before `fast-coverage-repair`; if consistently zero, remove/disable that pass in an A/B branch.
3. Record `certifiedInteriorTiles` CPU ms and certified ratio per frame; gate when hit rate stays zero.
4. Record active-continuation `reinitCount` and `replayedPixelIterations`.
5. Record peak/resident bytes for active, correction, export, color snapshot and release-on-idle experiments.
6. Separate color-history promotion from numeric-history promotion and A/B color-auto frame time.
## Production integrity
No production deletion or behavioral modification was made by this audit. Regression was rerun after inspection and passed.

View file

@ -0,0 +1,234 @@
# Mandelbrot viewer performance-cost A/B test
Date: 2026-09-29
## Scope
This A/B test follows the performance-cost audit. Production `index.html` was **not changed**. A is the current production implementation; B variants live under `experiments/ab/variants/` and disable, defer, or gate one candidate operation at a time.
Production SHA-256 before/after testing:
`544820c5024cf6d56e012db6d1bec3ddfcef5d833691b890baa64b26c5c2ee5a`
The existing regression suite passes on production. The combined high-confidence B variant and the individual structural B variants used for gating/lazy allocation also pass the same regression suite; all retain 19 WGSL kernels.
## Measurement limitation
A usable WebGPU backend could not be started in the container (Chromium/SwiftShader/EGL). Therefore this report deliberately separates:
1. **CPU measured time/work**: exact-interior BigInt proof, adaptive-iteration probe, CPU surrogates for strict-cycle and continuation replay.
2. **Exact traffic/residency counts from the production buffer sizes and dispatch extents**: copies, full-frame scans, allocations.
3. **Structural/regression validation**: B variants parse and pass the production regression suite.
No result below is presented as measured GPU frame time unless explicitly stated. Real-device WebGPU p50/p95 remains a follow-up requirement.
## Summary
| Candidate | A/B result | Decision from this test |
|---|---|---|
| Recolor numeric-history copy | B removes 8 B/pixel copy with unchanged numeric field | **Strongly supported** |
| Unread `cpuFrameRgba` retention | B removes 4 B/pixel retained JS data; no reader exists | **Strongly supported** |
| Pre-primary unknown stats after exact interior | B removes one full-frame `fieldMeta` scan; primary path computes stats later | **Strongly supported** |
| Strict direct periodicity-state updates | CPU surrogate median B change **+2.82% slower** | **Do not prioritize for speed** |
| FAST full-screen coverage repair | 36 layout cases + 200 semantic randomized cases: **0 holes** | **Strongly supported**, real GPU A/B still desirable |
| Idle color-auto `colorSource` snapshot | B avoids 12 B/pixel idle snapshot; idle recolor does not consume it | **Strongly supported** |
| Export workspace lifetime | B releases ~24 MiB max workspace after export | **Memory win**, trades for reallocation on next export |
| Exact-interior spatial gate | Fixed corpus proof CPU time reduced ~40% at 1024×768 and ~34% at 2048²; 240 random views: 0 gate false-negatives | **Strongly supported** |
| Conditional numeric-history residency | Budget-scaled 1920×1080 cases had 0–0.6% exact-grid hit rate; native-grid cases 100% | **Supported conditionally**, not blanket removal |
| Lazy export AA samples | 1× export avoids **12 MiB** of four unused AA sample textures across a 3-slot ring | **Strongly supported** |
| Adaptive initial-iteration probe removal | 3/48 survey views selected >350; forcing 350 raised predicted work by **+7.2%, +25.3%, +33.1%** | **Do not remove**; cache/gate instead |
| Continuation restart from n=0 | Resume-state surrogate reduced work **7.6–9.8%**, identical classifications | **Real waste**, but requires state-preserving redesign |
| Deep workspace release after easy frame | Up to ~44.1 MiB standard / ~176.5 MiB high can remain resident | **Memory-lifetime candidate**; real GPU allocation cost unmeasured |
## Results in detail
### 1. Recolor numeric-history copy
Current recolor promotes color history and also copies unchanged `meta/smooth` numeric history. B separates the two responsibilities.
Exact avoided copy per accepted recolor:
- fast cap: 4 MiB
- standard cap: 8 MiB
- high cap: 32 MiB
At 20 Hz color cycling, that corresponds to 80 / 160 / 640 MiB/s of avoidable numeric-history copying at the respective caps. This is a traffic count, not a GPU-time measurement.
Variant: `b01-recolor-no-numeric-copy.html`
### 2. Unread CPU RGBA retention
`cpuFrameRgba` is assigned after CPU fallback rendering but has no subsequent reader in production. B drops the assignment.
Avoided retained JS memory at cap:
- fast: 2 MiB
- standard: 4 MiB
- high: 16 MiB
Variant: `b02-cpu-no-rgba-retention.html`
### 3. Exact-interior pre-primary unknown-stat pass
When exact tile certification only partially covers the frame, production immediately runs a full-frame unknown-stat scan and synchronization before the primary numerical path. The partial-path result is not consumed; the primary path computes the required statistics later.
B removes this pre-primary stats scan while preserving the fully-certified shortcut.
Minimum avoided `fieldMeta` read per pass:
- fast: 2 MiB
- standard: 4 MiB
- high: 16 MiB
This excludes atomics, dispatch overhead, and synchronization cost, so it is a lower bound on removed work.
Variant: `b03-certify-no-preprimary-stats.html`
### 4. Strict direct periodicity-state updates
The strict path does not accept heuristic periodicity, yet production still maintains Brent state. A CPU surrogate compared identical numerical output with and without those state updates.
Across six fixed views, B was not consistently faster: median elapsed change was **+2.82%** (slower), mean +5.47%. One deep case improved by 1.48%, but the result is not robust.
Conclusion: semantically redundant state exists, but this A/B test does **not** justify touching it for performance. GPU register pressure could behave differently; real WebGPU measurement would be needed.
Variant: `b04-strict-no-cycle-state.html`
### 5. FAST coverage repair
Production performs a second full-frame FAST pass intended to fill `reason=0` holes after tiled FAST rendering.
Coverage simulation tested 36 combinations of dimensions, iteration budgets and symmetry modes; all produced **0 holes**. A second semantic randomized test seeded mixed proven/unknown metadata over 200 cases and also found **0 cases with holes**.
Representative work avoided if the repair pass is unnecessary:
- 1365×768: 1,050,624 shader invocations; at least 4,193,280 bytes of metadata reads
- 2731×1536: 4,202,496 invocations; at least 16,779,264 bytes of metadata reads
Variant: `b05-fast-no-coverage-repair.html`
### 6. Idle color-auto snapshot
Starting color cycling while no render is in progress allocates/copies `colorSource`, but idle recolor reads the current numerical field and does not consume that snapshot. Render-time snapshot behavior is preserved in B.
Avoided idle snapshot residency:
- fast: 6 MiB
- standard: 12 MiB
- high: 48 MiB
Variant: `b06-idle-colorauto-no-snapshot.html`
### 7. Export workspace lifetime
The export ring remains allocated after export. With 512-sized slots and ring depth 3, the tested maximum workspace is approximately **24 MiB**. B destroys it in `finally`, reducing post-export residency to zero. The cost is allocation of three slots on a subsequent export.
This is a clear memory trade-off, not a proven speed win.
Variant: `b07-export-release-workspace.html`
### 8. Exact-interior spatial gate
The expensive BigInt tile proof only needs to run when the viewport can intersect the main cardioid or period-2 bulb. B uses a conservative bounding-box gate before tile proof.
The cardioid gate was corrected during the A/B test: the main cardioid reaches `x = 3/8`, so the conservative box is `x ∈ [-3/4, 3/8]`, `y ∈ [-2/3, 2/3]`. The period-2 bulb box is `x ∈ [-5/4, -3/4]`, `y ∈ [-1/4, 1/4]`.
Random validation: 240 views, 160 gate-negative, **0 false negatives** versus the current exact tile proof.
Fixed-corpus aggregate CPU proof time:
- 1024×768: A 41.64 ms → B 25.16 ms, about **39.6% reduction**
- 2048²: A 177.70 ms → B 117.08 ms, about **34.1% reduction**
The largest wins were period-3 views that cannot intersect either analytically certifiable component: about 7–10 ms saved at 1024×768 and 25–30 ms at 2048².
Variant: `b08-exact-interior-spatial-gate.html`
### 9. Conditional numeric-history residency
Exact-grid history reuse requires render-pixel translation to line up exactly with CSS/device motion. A deterministic horizontal-pan survey over offsets -500…500 showed:
| Configuration | Reuse hits | A residency | B policy |
|---|---:|---:|---:|
| 1920×1080 fast → 965×543 | 0.2% | ~4.0 MiB | 0 |
| 1920×1080 standard → 1365×768 | 0.6% | ~8.0 MiB | 0 |
| 1920×1080 high → 2731×1536 | 0% | ~32.0 MiB | 0 |
| 1024×768 native standard | 100% | 6 MiB | keep |
| 1024×768 DPR2 native ratio | 100% | 24 MiB | keep |
B keeps numeric history only for simple reduced render/CSS scale ratios (denominator ≤ 8). This preserves the clearly useful native-grid cases while avoiding permanent allocation where reuse is nearly impossible.
Variant: `b09-conditional-numeric-history.html`
### 10. Lazy export AA sample textures
Production allocates four 512² RGBA8 AA sample textures per export slot even for 1× export, although only 2× AA uses them. Four textures are 4 MiB per slot; with ring depth 3 this is **12 MiB** of unnecessary 1× export allocation.
B creates AA sample textures lazily only for 2× AA. Regression passes.
Variant: `b10-export-aa-samples-lazy.html`
### 11. Adaptive initial-iteration probe
The fixed six-view corpus always selected the base 350 iterations, at a measured probe cost of roughly 1.4–17.4 ms per view. That initially suggested deletion.
A broader deterministic survey of 48 views disproved the blanket-removal hypothesis. Three views (6.25%) selected more than 350. Forcing 350 increased the surrogate work metric by:
- +7.21%
- +33.14%
- +25.25%
Conclusion: **do not delete this probe globally**. Better candidates are caching by nearby view, a cheaper trigger before the full probe, or reducing sample count where confidence is high.
### 12. Continuation replay
Current continuation reconstructs active state from `n=0`; a state-resume surrogate instead kept the base-350 state and continued to 4096. The classifications matched in every tested view.
Work reduction by view: **7.63% to 9.79%**, median about **7.87%**.
This is genuine redundant work, but production perturbation/reference changes complicate state validity. It is a redesign candidate, not a safe line deletion.
## Recommended next actions
### High-confidence changes to consider integrating
1. Separate recolor history promotion from numeric-history copy.
2. Stop retaining unused CPU RGBA output.
3. Remove the partial exact-interior pre-primary stats pass.
4. Remove/disable FAST coverage repair after adding a diagnostic hole counter for one release cycle.
5. Avoid idle color-auto snapshots.
6. Release export workspace after export, subject to acceptable next-export allocation latency.
7. Add the conservative exact-interior spatial gate.
8. Make export AA sample textures lazy.
### Conditional changes
- Allocate numeric history only when exact-grid reuse has realistic geometry.
- Release large deep workspaces after returning to an easy frame or after a memory-pressure/idle policy; do not churn them every frame.
### Do not remove based on this test
- Adaptive initial-iteration probe.
- Strict periodicity state solely for speed; no measurable CPU benefit was established.
### Requires structural work
- Preserve valid continuation state so OPERATION_LIMIT pixels do not replay their first ~350 iterations.
## Reproduction
From project root:
```bash
node tests/regression.mjs
node experiments/ab/ab_test.mjs
node experiments/ab/supplement.mjs
node experiments/ab/variant_check.mjs
```
Raw data:
- `docs/performance-cost-audit/ab/results.json`
- `docs/performance-cost-audit/ab/supplement.json`
All B variants are experimental. Production was not modified by this A/B test.

View file

@ -0,0 +1 @@
544820c5024cf6d56e012db6d1bec3ddfcef5d833691b890baa64b26c5c2ee5a index.html

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,12 @@
{
"interiorGateRandom": {
"cases": 240,
"gateFalse": 160,
"falseNeg": 0,
"totalProofInFalseNeg": 0
},
"fastCoverageSemantic": {
"cases": 200,
"casesWithHoles": 0
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,148 @@
# Runtime cost removal pass
Date: 2026-09-29
## Purpose
This build applies every removal/reduction that the preceding A/B pass supported strongly enough to change without redesigning numerical state. The original integrated build remains separately available at `/mnt/data/mandelbrot_integrated`; this directory is the experimental production-equivalent removal build.
Production source of truth in this build: `index.html`.
## Applied changes
### 1. Recolor no longer copies numeric history
Color-only recolor now promotes only color history. `meta/smooth` numeric history is not recopied when the numerical field did not change.
A/B traffic avoided per accepted recolor at the preset caps:
- fast: 4 MiB
- standard: 8 MiB
- high: 32 MiB
At 20 Hz color cycling this removes 80 / 160 / 640 MiB/s of avoidable copy traffic respectively. These are byte counts, not measured GPU frame-time gains.
### 2. Removed unread CPU fallback RGBA retention
The CPU fallback still creates the RGBA array needed for `ImageData`, but no longer stores a second long-lived reference in `state.cpuFrameRgba`. The state property itself was removed.
Avoided retained JS memory at the pixel caps:
- fast: 2 MiB
- standard: 4 MiB
- high: 16 MiB
### 3. Removed partial exact-interior pre-primary unknown scan
`certifyInterior()` no longer runs a full-frame unknown-stat pass immediately before the primary numerical pass. The primary path computes the required statistics later. A fully certified frame now uses known-zero stats directly instead of a readback.
Minimum avoided `fieldMeta` read per partial certification:
- fast: 2 MiB
- standard: 4 MiB
- high: 16 MiB
### 4. Removed FAST coverage-repair pass and its dead remnants
The second full-screen FAST numerical pass that attempted to repair `reason=0` holes was removed. Validation before removal found zero holes in 36 layout cases plus 200 randomized semantic cases.
After integration, two dead remnants were also removed:
- the post-tile `numericParams` upload whose only purpose was coverage-repair mode 2;
- the coverage-hole mode-2 branch in `FAST_PERTURB_WGSL`.
Representative eliminated shader invocations:
- 1365×768: 1,050,624 invocations
- 2731×1536: 4,202,496 invocations
The FAST kernel hash changed only because this unreachable coverage-repair branch was deleted. `tests/kernel_hashes.json` was updated accordingly.
### 5. Removed idle color-auto snapshot allocation
Turning on automatic hue cycling while no render is running no longer captures `colorSource`. Idle recolor does not consume that snapshot. Render-time snapshot behavior remains because it is used to keep visual continuity while a new numerical frame is in flight.
Avoided idle snapshot residency at caps:
- fast: 6 MiB
- standard: 12 MiB
- high: 48 MiB
### 6. Export workspace is released after every export
`runExport()` now destroys the reusable export ring in `finally`. This removes roughly 24 MiB of post-export residency in the tested 512×512 / depth-3 configuration. The next export must allocate the ring again; real-device allocation latency was not measurable in this environment.
### 7. Exact-interior proof is spatially gated
Before the BigInt tile proof, the exact viewport is tested against conservative bounding boxes for the main cardioid and period-2 bulb. If neither can intersect the viewport, the proof is skipped.
A/B aggregate CPU proof time on the fixed corpus:
- 1024×768: 41.64 ms → 25.16 ms (~39.6% reduction)
- 2048²: 177.70 ms → 117.08 ms (~34.1% reduction)
Random validation: 240 views, 160 gate-negative, 0 false negatives versus the existing exact tile proof.
### 8. Numeric history is no longer permanently resident when exact-grid reuse is unrealistic
`historyMeta/historySmooth` are allocated only when the render-to-CSS scale ratio has a reduced denominator ≤ 8. Native-grid cases retain the feature; common pixel-budget-scaled cases do not keep 8 B/pixel of history that almost never matches integer-grid pan reuse.
Measured deterministic pan survey:
- 1920×1080 → 965×543: 0.2% reuse, history removed (~4 MiB)
- 1920×1080 → 1365×768: 0.6% reuse, history removed (~8 MiB)
- 1920×1080 → 2731×1536: 0% reuse, history removed (~32 MiB)
- 1024×768 native: 100% reuse, history retained
- 1024×768 DPR2/native ratio: 100% reuse, history retained
This is intentionally conditional rather than blanket deletion.
### 9. Export AA sample textures are lazy
The four RGBA8 sample textures used only by 2× AA export are no longer allocated by 1× export. At 512² and ring depth 3, 1× export avoids 12 MiB of peak sample-texture allocation.
## Deliberately not removed
The following were investigated but are not part of this deletion pass:
- **Adaptive initial-iteration probe**: 3/48 survey views selected more than 350 iterations; forcing 350 increased the surrogate work metric by +7.2%, +25.3%, and +33.1%.
- **Strict periodicity-state updates**: the CPU A/B did not establish a speed win; median B was slower.
- **Continuation restart from n=0**: confirmed redundant work, but fixing it requires carrying valid continuation state across primary/reference transitions rather than deleting code.
- **Deep/correction workspace lifetime**: potentially large memory saving, but unconditional release can cause GPU allocation churn; no real WebGPU allocation timing was available, so it remains unchanged in this pass.
## Validation
Commands:
```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
```
Results:
- regression: PASS
- JavaScript syntax: PASS
- WGSL kernels: 19 + bundle version
- exact-interior gate randomized test: 0 false negatives / 240 views
- FAST coverage semantic test: 0 hole cases / 200 randomized cases
- integrated CPU benchmark: completed; existing periodicity/series/sparse/multi-reference/CPU-fallback paths remain operational
Real WebGPU frame-time p50/p95 remains unmeasured because the container cannot start a usable WebGPU adapter.
## Source-size impact
Compared with the preceding integrated build:
- `index.html`: 223,672 → 224,559 bytes (+887 bytes)
- gzip-9: 57,375 → 57,744 bytes (+369 bytes)
The file is slightly larger because the spatial and allocation eligibility gates add code. The intended gains are runtime computation, transfer traffic, and memory residency rather than download size.
## Integrity
Removal-build `index.html` SHA-256:
`e69b91ead027927d977a065d27d9e27629110122df20aa470eab877e23f77ac7`

View file

@ -0,0 +1,163 @@
--- /mnt/data/mandelbrot_integrated/index.html 2026-09-29 09:15:05.708978122 +0000
+++ index.html 2026-09-29 09:26:37.218893530 +0000
@@ -268,9 +268,6 @@
if(p.unknownOnly!=0u){
let prior=fieldMeta[out];let cls=(prior>>28u)&3u;
if(cls!=FIELD_UNKNOWN){return;}
- let reason=(prior>>20u)&0xffu;
- // mode 2 repairs only untouched coverage holes (zero-filled metadata).
- if(p.unknownOnly==2u && reason!=0u){return;}
}
let gx=f32(p.tileX+gid.x)+p.sampleX;
let gy=f32(p.tileY+gid.y)+p.sampleY;
@@ -915,7 +912,7 @@
};
function normalizeQuality(value){return value==='fast'||value==='power'?'fast':value==='high'||value==='fine'||value==='validate'?'high':'standard'}
function applyQuality(value){state.quality=normalizeQuality(value);state.continuationBudget=QUALITY_PRESETS[state.quality].iterations}
-const state={bits:INITIAL_BITS,re:0n,im:0n,span:0n,baseIter:350,adaptive:true,continuationBudget:16384,quality:'standard',palette:0,cycle:.008,shift:.18,colorAuto:false,colorRevision:0,token:0,rendering:false,recoloring:false,recolorPending:false,dirty:true,renderClock:null,drawState:'REPROJECTED',frameView:null,fieldView:null,pointerActive:false,wheelActive:false,unresolved:0,unknownReasons:null,gpuError:'',gpuInitFailed:false,lastInteraction:performance.now(),uiHidden:false,temporalFill:false,deferNumericPublish:false,heuristicInterior:0,cpuFrameRgba:null};
+const state={bits:INITIAL_BITS,re:0n,im:0n,span:0n,baseIter:350,adaptive:true,continuationBudget:16384,quality:'standard',palette:0,cycle:.008,shift:.18,colorAuto:false,colorRevision:0,token:0,rendering:false,recoloring:false,recolorPending:false,dirty:true,renderClock:null,drawState:'REPROJECTED',frameView:null,fieldView:null,pointerActive:false,wheelActive:false,unresolved:0,unknownReasons:null,gpuError:'',gpuInitFailed:false,lastInteraction:performance.now(),uiHidden:false,temporalFill:false,deferNumericPublish:false,heuristicInterior:0};
let renderer=null,rendererInitPromise=null,raf=0,settleTimer=0,lastWrittenHash='',navigationHash='';
// ── exact fixed-point view state ─────────────────────────────────────────
@@ -1171,6 +1168,12 @@
function buf(device,size,usage,label){return device.createBuffer({label,size:Math.max(4,Math.ceil(size/4)*4),usage})}
function destroy(x){if(x&&x.destroy)try{x.destroy()}catch{}}
function writeU32F32(size,writer){const a=new ArrayBuffer(size),d=new DataView(a);writer(d);return a}
+
+function numericHistoryScaleEligible(w,h){
+ const cw=Math.max(1,Math.round(canvas.clientWidth||w)),ch=Math.max(1,Math.round(canvas.clientHeight||h));
+ const gcd=(a,b)=>{a=Math.abs(a);b=Math.abs(b);while(b){const t=a%b;a=b;b=t}return a||1};
+ return cw/gcd(w,cw)<=8&&ch/gcd(h,ch)<=8;
+}
class WebGpuRenderer{
constructor(adapter,device){
this.device=device;
@@ -1210,7 +1213,7 @@
frameDestroy(){this.releaseColorSource();if(!this.frame)return;this.clearBindGroupCache();for(const ring of Object.values(this.frame.readbackRings||{}))for(const b of ring.buffers)destroy(b);for(const slot of this.frame.precisionUploadRing?.slots||[])destroy(slot.buffer);for(const k of ['meta','smooth','historyMeta','historySmooth','unresolved','deepQueueStats','deepBucketState','deepQueue','deepIndirect','deepActiveState','deepActiveQueueA','deepActiveQueueB','deepActiveCountA','deepActiveCountB','deepActiveIndirect','deepActiveRead','deepActiveMark','deepActivePixel','numericParams','statsParams','symmetryParams','colorParams','presentParams'])destroy(this.frame[k]);for(const t of new Set([this.frame.front,this.frame.back,this.frame.history,this.frame.spare]))destroy(t);this.frame=null;this.historyView=null;this.historyColorKey='';this.frontColorKey='';this.historyReady=false;this.numericHistoryView=null;this.numericHistoryIter=0;this.numericHistoryReady=false}
ensureFrame(w,h){
const n=w*h;if(this.frame&&this.frame.w===w&&this.frame.h===h)return this.frame;this.frameDestroy();const d=this.device,B=GPUBufferUsage,T=GPUTextureUsage,colorUsage=T.STORAGE_BINDING|T.TEXTURE_BINDING|T.COPY_SRC,front=d.createTexture({size:[w,h],format:'rgba8unorm',usage:colorUsage,label:'front-color'}),back=d.createTexture({size:[w,h],format:'rgba8unorm',usage:colorUsage,label:'back-color'}),history=d.createTexture({size:[w,h],format:'rgba8unorm',usage:colorUsage,label:'stable-history'});
- this.frame={w,h,n,meta:buf(d,n*4,B.STORAGE|B.COPY_SRC|B.COPY_DST,'field-meta'),smooth:buf(d,n*4,B.STORAGE|B.COPY_SRC|B.COPY_DST,'field-smooth'),historyMeta:buf(d,n*4,B.COPY_SRC|B.COPY_DST,'stable-history-meta'),historySmooth:buf(d,n*4,B.COPY_SRC|B.COPY_DST,'stable-history-smooth'),unresolved:buf(d,UNRESOLVED_BYTES,B.STORAGE|B.COPY_SRC|B.COPY_DST,'unresolved-count'),deepQueueStats:null,deepBucketState:null,deepQueue:null,deepIndirect:null,deepActiveCapacity:0,deepActiveState:null,deepActiveQueueA:null,deepActiveQueueB:null,deepActiveCountA:null,deepActiveCountB:null,deepActiveIndirect:null,deepActiveRead:null,deepActiveMark:null,deepActivePixel:null,readbackRings:{},numericParams:buf(d,NUMERIC_PARAM_BYTES,B.UNIFORM|B.COPY_DST,'numeric-params'),statsParams:buf(d,16,B.UNIFORM|B.COPY_DST,'unknown-stats-params'),symmetryParams:buf(d,16,B.UNIFORM|B.COPY_DST,'symmetry-copy-params'),colorParams:buf(d,32,B.UNIFORM|B.COPY_DST,'color-params'),presentParams:buf(d,48,B.UNIFORM|B.COPY_DST,'present-params'),front,back,history,spare:history};return this.frame;
+ const keepNumericHistory=numericHistoryScaleEligible(w,h);this.frame={w,h,n,meta:buf(d,n*4,B.STORAGE|B.COPY_SRC|B.COPY_DST,'field-meta'),smooth:buf(d,n*4,B.STORAGE|B.COPY_SRC|B.COPY_DST,'field-smooth'),historyMeta:keepNumericHistory?buf(d,n*4,B.COPY_SRC|B.COPY_DST,'stable-history-meta'):null,historySmooth:keepNumericHistory?buf(d,n*4,B.COPY_SRC|B.COPY_DST,'stable-history-smooth'):null,unresolved:buf(d,UNRESOLVED_BYTES,B.STORAGE|B.COPY_SRC|B.COPY_DST,'unresolved-count'),deepQueueStats:null,deepBucketState:null,deepQueue:null,deepIndirect:null,deepActiveCapacity:0,deepActiveState:null,deepActiveQueueA:null,deepActiveQueueB:null,deepActiveCountA:null,deepActiveCountB:null,deepActiveIndirect:null,deepActiveRead:null,deepActiveMark:null,deepActivePixel:null,readbackRings:{},numericParams:buf(d,NUMERIC_PARAM_BYTES,B.UNIFORM|B.COPY_DST,'numeric-params'),statsParams:buf(d,16,B.UNIFORM|B.COPY_DST,'unknown-stats-params'),symmetryParams:buf(d,16,B.UNIFORM|B.COPY_DST,'symmetry-copy-params'),colorParams:buf(d,32,B.UNIFORM|B.COPY_DST,'color-params'),presentParams:buf(d,48,B.UNIFORM|B.COPY_DST,'present-params'),front,back,history,spare:history};return this.frame;
}
frameReadback(kind,size){const f=this.frame;if(!f)throw new Error('frame readback requested without a frame');let ring=f.readbackRings[kind];if(!ring){const B=GPUBufferUsage;ring=f.readbackRings[kind]={cursor:0,buffers:Array.from({length:3},(_,i)=>buf(this.device,size,B.COPY_DST|B.MAP_READ,kind+'-readback-'+i))}}for(let tries=0;tries<ring.buffers.length;tries++){const i=ring.cursor++%ring.buffers.length,b=ring.buffers[i];if(!b.mapState||b.mapState==='unmapped')return b}throw new Error(kind+' readback ring is saturated')}
ensureDeepBucketState(f=this.frame){if(!f)return null;if(!f.deepBucketState){const d=this.device,B=GPUBufferUsage;f.deepBucketState=buf(d,DEEP_BUCKET_STATE_BYTES,B.STORAGE|B.COPY_SRC|B.COPY_DST,'deep-unknown-bucket-state')}return f}
@@ -1221,8 +1224,9 @@
destroyExportWorkspace(ws){if(!ws)return;this.clearBindGroupCache();for(const k of ['meta','smooth','cbuf','tex','read'])destroy(ws[k]);for(const b of ws.unresolveds)destroy(b);for(const b of ws.pbufs)destroy(b);for(const t of ws.samples)destroy(t)}
ensureExportWorkspace(slot=0,S=this.exportTileSize()){
const old=this.exportWs[slot];if(old&&old.size===S)return old;if(old)this.destroyExportWorkspace(old);const d=this.device,B=GPUBufferUsage,T=GPUTextureUsage,n=S*S,bpr=S*4,pixelBytes=bpr*S,label='export-'+slot+'-';
- const ws={slot,size:S,meta:buf(d,n*4,B.STORAGE|B.COPY_DST,label+'meta'),smooth:buf(d,n*4,B.STORAGE|B.COPY_DST,label+'smooth'),unresolveds:Array.from({length:4},(_,i)=>buf(d,UNRESOLVED_BYTES,B.STORAGE|B.COPY_SRC|B.COPY_DST,label+'unresolved-'+i)),pbufs:Array.from({length:4},(_,i)=>buf(d,NUMERIC_PARAM_BYTES,B.UNIFORM|B.COPY_DST,label+'numeric-'+i)),cbuf:buf(d,32,B.UNIFORM|B.COPY_DST,label+'color'),samples:Array.from({length:4},(_,i)=>d.createTexture({label:label+'sample-'+i,size:[S,S],format:'rgba8unorm',usage:T.STORAGE_BINDING|T.TEXTURE_BINDING})),tex:d.createTexture({label:label+'resolve',size:[S,S],format:'rgba8unorm',usage:T.STORAGE_BINDING|T.COPY_SRC}),read:buf(d,pixelBytes+4*UNRESOLVED_BYTES,B.COPY_DST|B.MAP_READ,label+'readback')};this.exportWs[slot]=ws;return ws;
+ const ws={slot,size:S,meta:buf(d,n*4,B.STORAGE|B.COPY_DST,label+'meta'),smooth:buf(d,n*4,B.STORAGE|B.COPY_DST,label+'smooth'),unresolveds:Array.from({length:4},(_,i)=>buf(d,UNRESOLVED_BYTES,B.STORAGE|B.COPY_SRC|B.COPY_DST,label+'unresolved-'+i)),pbufs:Array.from({length:4},(_,i)=>buf(d,NUMERIC_PARAM_BYTES,B.UNIFORM|B.COPY_DST,label+'numeric-'+i)),cbuf:buf(d,32,B.UNIFORM|B.COPY_DST,label+'color'),samples:[],tex:d.createTexture({label:label+'resolve',size:[S,S],format:'rgba8unorm',usage:T.STORAGE_BINDING|T.COPY_SRC}),read:buf(d,pixelBytes+4*UNRESOLVED_BYTES,B.COPY_DST|B.MAP_READ,label+'readback')};this.exportWs[slot]=ws;return ws;
}
+ ensureExportAaSamples(ws){if(ws.samples.length===4)return ws.samples;const d=this.device,T=GPUTextureUsage;for(let i=0;i<4;i++)ws.samples.push(d.createTexture({label:'export-'+ws.slot+'-sample-'+i,size:[ws.size,ws.size],format:'rgba8unorm',usage:T.STORAGE_BINDING|T.TEXTURE_BINDING}));return ws.samples}
exportWorkspaceDestroy(){for(const ws of this.exportWs)this.destroyExportWorkspace(ws);this.exportWs=[]}
setDeepContext(ctx){
if(this.deepCtx?.key===ctx.key&&this.deepCtx.refLen>=ctx.refLen)return;
@@ -1296,7 +1300,6 @@
if(input)d.queue.writeBuffer(input,0,mask);const e=d.createCommandEncoder({label:'history-seed-and-exact-interior'});if(historySeed)this.encodeNumericHistorySeed(e,historySeed);else if(clear){e.clearBuffer(f.meta);e.clearBuffer(f.smooth)}
if(pixels){const bg=d.createBindGroup({layout:this.interiorMask.getBindGroupLayout(0),entries:[{binding:0,resource:{buffer:input}},{binding:1,resource:{buffer:f.meta}},{binding:2,resource:{buffer:f.smooth}}]});
const pass=e.beginComputePass();pass.setPipeline(this.interiorMask);pass.setBindGroup(0,bg);pass.dispatchWorkgroups(Math.ceil(f.w/8),Math.ceil(f.h/8));pass.end()}
- this.encodeUnknownStats(e,{meta:f.meta,unresolved:f.unresolved,n:f.n});
return await this.submitStage(e,historySeed?'numeric history + exposed interior':'exact interior before primary',token,'correction')===null?0:pixels;
}finally{destroy(input)}
}
@@ -1347,7 +1350,7 @@
const tileWidth=this.numericTileShape(f.w,iter).width;let rows=this.initialNumericRows(tileWidth,iter),burstCount=0,burstStart=performance.now(),lastStage='',predictedStripMs=22,burstLimit=this.numericBurstLimit(predictedStripMs),burstPredictedMs=0,lastCompletedRows=rows;
const flushBurst=async remaining=>{if(!burstCount)return true;const ms=await this.waitStageBurst(lastStage,token,burstStart,burstCount);if(ms===null)return false;rows=this.adaptNumericRows(lastCompletedRows,ms,remaining);predictedStripMs=.65*predictedStripMs+.35*ms;burstLimit=this.numericBurstLimit(predictedStripMs);burstCount=0;burstPredictedMs=0;burstStart=performance.now();return true};
for(let y=0;y<computeH;){if(token!==state.token)return false;const th=Math.min(rows,computeH-y);lastCompletedRows=th;for(let x=0;x<f.w;x+=tileWidth){if(token!==state.token)return false;const tw=Math.min(tileWidth,f.w-x),base=y*f.w+x,params=this.fastParams(tw,th,f.w,f.h,x,y,iter,snap,.5,.5,refX,refY,f.w,base,reuseResolved?1:0);d.queue.writeBuffer(f.numericParams,0,params);const e=d.createCommandEncoder({label:'fast-numeric-strip-'+y});if(x===0&&y===0){if(historySeed)this.encodeNumericHistorySeed(e,historySeed);else if(!reusePriorField&&!preserveInterior){e.clearBuffer(f.meta);e.clearBuffer(f.smooth)}}this.encodeFastPostStatsNumeric(e,{pbuf:f.numericParams,meta:f.meta,smooth:f.smooth,w:tw,h:th});lastStage='fast primary rows '+y+'..'+(y+th-1);this.submitStageDeferred(e,lastStage,'numeric');burstCount++;burstPredictedMs+=predictedStripMs;const budgetMs=this.numericInFlightBudgetMs();if(burstCount>=burstLimit||burstPredictedMs>=budgetMs||performance.now()-burstStart>=budgetMs){if(!await flushBurst(computeH-(y+th)))return false}}y+=th}
- if(!await flushBurst(0))return false;d.queue.writeBuffer(f.numericParams,0,this.fastParams(f.w,f.h,f.w,f.h,0,0,iter,snap,.5,.5,refX,refY,f.w,0,2));if(!deferColor)d.queue.writeBuffer(f.colorParams,0,this.colorParamsData(f.w,f.h,iter,colorStyle));const e=d.createCommandEncoder({label:deferColor?'fast-coverage-repair':'fast-coverage-repair-and-color'});if(mirror)this.encodeSymmetryCopy(e,{meta:f.meta,smooth:f.smooth,w:f.w,h:f.h});this.encodeFastNumeric(e,{pbuf:f.numericParams,meta:f.meta,smooth:f.smooth,unresolved:f.unresolved,w:f.w,h:f.h});this.encodeUnknownStats(e,{meta:f.meta,unresolved:f.unresolved,n:f.n});if(!deferColor){const cbg=this.cachedBindGroup(this.color,[{binding:0,resource:{buffer:f.colorParams}},{binding:1,resource:{buffer:f.meta}},{binding:2,resource:{buffer:f.smooth}},{binding:3,resource:this.cachedTextureView(this.colorTarget(f))}]),cp=e.beginComputePass();cp.setPipeline(this.color);cp.setBindGroup(0,cbg);cp.dispatchWorkgroups(Math.ceil(f.w/8),Math.ceil(f.h/8));cp.end()}const ms=await this.submitStage(e,deferColor?'fast coverage only':'fast coverage + color',token,'numeric');if(ms===null)return false;if(!deferColor){[f.front,f.back]=[f.back,f.front];this.frontColorKey=colorKey}return true
+ if(!await flushBurst(0))return false;if(!deferColor)d.queue.writeBuffer(f.colorParams,0,this.colorParamsData(f.w,f.h,iter,colorStyle));const e=d.createCommandEncoder({label:deferColor?'fast-post-stats':'fast-post-stats-and-color'});if(mirror)this.encodeSymmetryCopy(e,{meta:f.meta,smooth:f.smooth,w:f.w,h:f.h});this.encodeUnknownStats(e,{meta:f.meta,unresolved:f.unresolved,n:f.n});if(!deferColor){const cbg=this.cachedBindGroup(this.color,[{binding:0,resource:{buffer:f.colorParams}},{binding:1,resource:{buffer:f.meta}},{binding:2,resource:{buffer:f.smooth}},{binding:3,resource:this.cachedTextureView(this.colorTarget(f))}]),cp=e.beginComputePass();cp.setPipeline(this.color);cp.setBindGroup(0,cbg);cp.dispatchWorkgroups(Math.ceil(f.w/8),Math.ceil(f.h/8));cp.end()}const ms=await this.submitStage(e,deferColor?'fast post-stats only':'fast post-stats + color',token,'numeric');if(ms===null)return false;if(!deferColor){[f.front,f.back]=[f.back,f.front];this.frontColorKey=colorKey}return true
}
async computeFrame(snap,iter,token,referencePixel=null,fastExtended=false,fastContext=null,preserveInterior=false,preseededHistory=false,deferColor=false){
await this.ready;if(fastExtended)await this.ensureFastPipelines();if(token!==state.token)return false;const f=this.ensureFrame(canvas.width,canvas.height),d=this.device,colorStyle=colorStyleSnapshot(),colorKey=colorStyleKey(colorStyle);
@@ -1381,7 +1384,7 @@
return true;
}
commitNumericHistory(view=state.frameView,iter=state.fieldView?.iter??maxIter()){
- const f=this.frame;if(!f||!view||state.fieldView?.complete===false)return false;const e=this.device.createCommandEncoder({label:'stable-numeric-history'});e.copyBufferToBuffer(f.meta,0,f.historyMeta,0,f.n*4);e.copyBufferToBuffer(f.smooth,0,f.historySmooth,0,f.n*4);this.device.queue.submit([e.finish()]);this.numericHistoryView={...view,w:f.w,h:f.h};this.numericHistoryIter=iter;this.numericHistoryReady=true;return true;
+ const f=this.frame;if(!f||!f.historyMeta||!f.historySmooth||!view||state.fieldView?.complete===false){this.numericHistoryReady=false;return false}const e=this.device.createCommandEncoder({label:'stable-numeric-history'});e.copyBufferToBuffer(f.meta,0,f.historyMeta,0,f.n*4);e.copyBufferToBuffer(f.smooth,0,f.historySmooth,0,f.n*4);this.device.queue.submit([e.finish()]);this.numericHistoryView={...view,w:f.w,h:f.h};this.numericHistoryIter=iter;this.numericHistoryReady=true;return true;
}
captureColorSource(view=state.frameView,needed=state.colorAuto||state.rendering){
this.colorSourceVisible=false;
@@ -1413,7 +1416,7 @@
}
async recolor(token,iter=state.fieldView?.iter??maxIter()){await this.ready;if(token!==state.token||!this.frame)return false;const f=this.frame,d=this.device,colorStyle=colorStyleSnapshot(),colorKey=colorStyleKey(colorStyle);d.queue.writeBuffer(f.colorParams,0,this.colorParamsData(f.w,f.h,iter,colorStyle));const e=d.createCommandEncoder(),bg=this.cachedBindGroup(this.color,[{binding:0,resource:{buffer:f.colorParams}},{binding:1,resource:{buffer:f.meta}},{binding:2,resource:{buffer:f.smooth}},{binding:3,resource:this.cachedTextureView(this.colorTarget(f))}]),p=e.beginComputePass();p.setPipeline(this.color);p.setBindGroup(0,bg);p.dispatchWorkgroups(Math.ceil(f.w/8),Math.ceil(f.h/8));p.end();d.queue.submit([e.finish()]);await d.queue.onSubmittedWorkDone();if(token!==state.token)return false;[f.front,f.back]=[f.back,f.front];this.frontColorKey=colorKey;return true}
presentTransform(view=state.frameView){if(!this.frame||!view)return{scaleX:1,scaleY:1,offsetX:0,offsetY:0};const cur=snapshot(),b=Math.max(cur.bits,view.bits),cs=align(cur.span,cur.bits,b),ps=align(view.span,view.bits,b),dr=align(cur.re,cur.bits,b)-align(view.re,view.bits,b),di=align(cur.im,cur.bits,b)-align(view.im,view.bits,b),scale=fixedRatio(cs,ps);return{scaleX:scale,scaleY:scale,offsetX:fixedRatio(dr,ps),offsetY:-fixedRatio(di,ps)*this.frame.w/Math.max(1,this.frame.h)}}
- commitHistory(view=state.frameView){if(!this.frame||!view||state.fieldView?.complete===false)return false;const f=this.frame;if(f.history!==f.front){f.history=f.front;this.clearBindGroupCache()}this.historyView=view;this.historyColorKey=this.frontColorKey;this.historyReady=true;this.commitNumericHistory(view,state.fieldView?.iter??maxIter());return true}
+ commitHistory(view=state.frameView,commitNumeric=true){if(!this.frame||!view||state.fieldView?.complete===false)return false;const f=this.frame;if(f.history!==f.front){f.history=f.front;this.clearBindGroupCache()}this.historyView=view;this.historyColorKey=this.frontColorKey;this.historyReady=true;if(commitNumeric)this.commitNumericHistory(view,state.fieldView?.iter??maxIter());return true}
presentFrame(transform=this.presentTransform()){if(!this.frame)return;const colored=this.colorSourceVisible&&this.colorSource;if(colored)transform=this.presentTransform(colored.view);const f=this.frame,d=this.device,pb=f.presentParams,interactive=state.pointerActive||state.wheelActive,finite=Number.isFinite(transform?.scaleX)&&Number.isFinite(transform?.scaleY)&&Number.isFinite(transform?.offsetX)&&Number.isFinite(transform?.offsetY)&&transform.scaleX>0&&transform.scaleY>0,ft=finite?transform:{scaleX:1,scaleY:1,offsetX:0,offsetY:0},historyCandidate=!!(!colored&&state.temporalFill&&this.historyReady&&this.historyView&&this.historyColorKey===colorStyleKey()),rawHistory=historyCandidate?this.presentTransform(this.historyView):{scaleX:1,scaleY:1,offsetX:0,offsetY:0},useHistory=!!(historyCandidate&&reprojectionSafe(rawHistory,2.25,.45)),ht=useHistory?rawHistory:{scaleX:1,scaleY:1,offsetX:0,offsetY:0};d.queue.writeBuffer(pb,0,new Float32Array([ft.scaleX,ft.scaleY,ft.offsetX,ft.offsetY,ht.scaleX,ht.scaleY,ht.offsetX,ht.offsetY,useHistory?1:0,0,0,0]));const bg=this.cachedBindGroup(this.present,[{binding:0,resource:interactive?this.reprojectSampler:this.sampler},{binding:1,resource:this.cachedTextureView(colored?colored.texture:f.front)},{binding:2,resource:this.cachedTextureView(f.history)},{binding:3,resource:{buffer:pb}}]),e=d.createCommandEncoder(),pass=e.beginRenderPass({colorAttachments:[{view:this.context.getCurrentTexture().createView(),clearValue:{r:0,g:0,b:0,a:1},loadOp:'clear',storeOp:'store'}]});pass.setPipeline(this.present);pass.setBindGroup(0,bg);pass.draw(3);pass.end();d.queue.submit([e.finish()])}
async readMeta(indices){if(!this.frame||!indices.length)return new Uint32Array();const d=this.device,B=GPUBufferUsage,r=buf(d,indices.length*4,B.COPY_DST|B.MAP_READ),e=d.createCommandEncoder();for(let i=0;i<indices.length;i++)e.copyBufferToBuffer(this.frame.meta,indices[i]*4,r,i*4,4);d.queue.submit([e.finish()]);await r.mapAsync(GPUMapMode.READ);const out=new Uint32Array(r.getMappedRange().slice(0));r.unmap();destroy(r);return out}
async readNumericalIndices(token){
@@ -1504,7 +1507,7 @@
}
async renderTileRGBA2x({snap,iter,fastContext=null,fullW,fullH,tileX,tileY,w,h,strict=false,workspaceSlot=0,workspaceSize=this.exportTileSize()}){
if(w*h*Math.max(1,iter)>48000000){const shape=this.numericTileShape(w,iter),out=new Uint8ClampedArray(w*h*4),generation=state.token;let unresolved=0;for(let y=0;y<h;y+=shape.rows)for(let x=0;x<w;x+=shape.width){if(generation!==state.token||exportJob.cancelled&&exportJob.active)throw new Error('cancelled');const tw=Math.min(shape.width,w-x),th=Math.min(shape.rows,h-y),part=await this.renderTileRGBA2x({snap,iter,fastContext,fullW,fullH,strict,workspaceSlot,workspaceSize,tileX:tileX+x,tileY:tileY+y,w:tw,h:th});for(let row=0;row<th;row++)out.set(part.rgba.subarray(row*tw*4,(row+1)*tw*4),((y+row)*w+x)*4);unresolved+=part.unresolved}return{rgba:out,unresolved}}
- await this.ready;await this.ensureAaPipeline();const ws=this.ensureExportWorkspace(workspaceSlot,workspaceSize);if(w>ws.size||h>ws.size)throw new Error('export tile exceeds reusable workspace');const fastExtended=!!fastContext;if(fastExtended){await this.ensureFastPipelines();this.setFastContext(fastContext)}const d=this.device,meta=ws.meta,smooth=ws.smooth,encoder=d.createCommandEncoder({label:'export-aa2x'}),offsets=[[.25,.25],[.75,.25],[.25,.75],[.75,.75]],ca=this.colorParamsData(w,h,iter);new DataView(ca).setUint32(12,0,true);d.queue.writeBuffer(ws.cbuf,0,ca);for(let si=0;si<4;si++){const [sampleX,sampleY]=offsets[si],pbuf=ws.pbufs[si],unresolved=ws.unresolveds[si];d.queue.writeBuffer(unresolved,0,new Uint32Array(UNRESOLVED_BYTES/4));if(fastExtended){const ref=referencePixelForSource(fastContext?.source,snap,fullW,fullH);d.queue.writeBuffer(pbuf,0,this.fastParams(w,h,fullW,fullH,tileX,tileY,iter,snap,sampleX,sampleY,ref.x,ref.y,w,0,0,strict?1:0));this.encodeFastNumeric(encoder,{pbuf,meta,smooth,unresolved,w,h})}else{d.queue.writeBuffer(pbuf,0,this.directParams(w,h,fullW,fullH,tileX,tileY,iter,snap,sampleX,sampleY,strict?1:0));this.encodeDirectNumeric(encoder,{pbuf,meta,smooth,w,h})}const cbg=d.createBindGroup({layout:this.color.getBindGroupLayout(0),entries:[{binding:0,resource:{buffer:ws.cbuf}},{binding:1,resource:{buffer:meta}},{binding:2,resource:{buffer:smooth}},{binding:3,resource:ws.samples[si].createView()}]}),cp=encoder.beginComputePass();cp.setPipeline(this.color);cp.setBindGroup(0,cbg);cp.dispatchWorkgroups(Math.ceil(w/8),Math.ceil(h/8));cp.end()}const abg=d.createBindGroup({layout:this.aaResolve.getBindGroupLayout(0),entries:[{binding:0,resource:ws.samples[0].createView()},{binding:1,resource:ws.samples[1].createView()},{binding:2,resource:ws.samples[2].createView()},{binding:3,resource:ws.samples[3].createView()},{binding:4,resource:ws.tex.createView()}]}),ap=encoder.beginComputePass();ap.setPipeline(this.aaResolve);ap.setBindGroup(0,abg);ap.dispatchWorkgroups(Math.ceil(w/8),Math.ceil(h/8));ap.end();const bpr=Math.ceil(w*4/256)*256,pixelBytes=bpr*h,statsBytes=4*UNRESOLVED_BYTES;encoder.copyTextureToBuffer({texture:ws.tex},{buffer:ws.read,bytesPerRow:bpr,rowsPerImage:h},{width:w,height:h});for(let si=0;si<4;si++)encoder.copyBufferToBuffer(ws.unresolveds[si],0,ws.read,pixelBytes+si*UNRESOLVED_BYTES,UNRESOLVED_BYTES);d.queue.submit([encoder.finish()]);await ws.read.mapAsync(GPUMapMode.READ,0,pixelBytes+statsBytes);const raw=new Uint8Array(ws.read.getMappedRange(0,pixelBytes+statsBytes)),out=new Uint8ClampedArray(w*h*4);for(let y=0;y<h;y++)out.set(raw.subarray(y*bpr,y*bpr+w*4),y*w*4);let unresolved=0;for(let si=0;si<4;si++)unresolved+=new Uint32Array(raw.buffer,raw.byteOffset+pixelBytes+si*UNRESOLVED_BYTES,UNRESOLVED_BYTES/4)[0]||0;ws.read.unmap();return{rgba:out,unresolved}
+ await this.ready;await this.ensureAaPipeline();const ws=this.ensureExportWorkspace(workspaceSlot,workspaceSize);this.ensureExportAaSamples(ws);if(w>ws.size||h>ws.size)throw new Error('export tile exceeds reusable workspace');const fastExtended=!!fastContext;if(fastExtended){await this.ensureFastPipelines();this.setFastContext(fastContext)}const d=this.device,meta=ws.meta,smooth=ws.smooth,encoder=d.createCommandEncoder({label:'export-aa2x'}),offsets=[[.25,.25],[.75,.25],[.25,.75],[.75,.75]],ca=this.colorParamsData(w,h,iter);new DataView(ca).setUint32(12,0,true);d.queue.writeBuffer(ws.cbuf,0,ca);for(let si=0;si<4;si++){const [sampleX,sampleY]=offsets[si],pbuf=ws.pbufs[si],unresolved=ws.unresolveds[si];d.queue.writeBuffer(unresolved,0,new Uint32Array(UNRESOLVED_BYTES/4));if(fastExtended){const ref=referencePixelForSource(fastContext?.source,snap,fullW,fullH);d.queue.writeBuffer(pbuf,0,this.fastParams(w,h,fullW,fullH,tileX,tileY,iter,snap,sampleX,sampleY,ref.x,ref.y,w,0,0,strict?1:0));this.encodeFastNumeric(encoder,{pbuf,meta,smooth,unresolved,w,h})}else{d.queue.writeBuffer(pbuf,0,this.directParams(w,h,fullW,fullH,tileX,tileY,iter,snap,sampleX,sampleY,strict?1:0));this.encodeDirectNumeric(encoder,{pbuf,meta,smooth,w,h})}const cbg=d.createBindGroup({layout:this.color.getBindGroupLayout(0),entries:[{binding:0,resource:{buffer:ws.cbuf}},{binding:1,resource:{buffer:meta}},{binding:2,resource:{buffer:smooth}},{binding:3,resource:ws.samples[si].createView()}]}),cp=encoder.beginComputePass();cp.setPipeline(this.color);cp.setBindGroup(0,cbg);cp.dispatchWorkgroups(Math.ceil(w/8),Math.ceil(h/8));cp.end()}const abg=d.createBindGroup({layout:this.aaResolve.getBindGroupLayout(0),entries:[{binding:0,resource:ws.samples[0].createView()},{binding:1,resource:ws.samples[1].createView()},{binding:2,resource:ws.samples[2].createView()},{binding:3,resource:ws.samples[3].createView()},{binding:4,resource:ws.tex.createView()}]}),ap=encoder.beginComputePass();ap.setPipeline(this.aaResolve);ap.setBindGroup(0,abg);ap.dispatchWorkgroups(Math.ceil(w/8),Math.ceil(h/8));ap.end();const bpr=Math.ceil(w*4/256)*256,pixelBytes=bpr*h,statsBytes=4*UNRESOLVED_BYTES;encoder.copyTextureToBuffer({texture:ws.tex},{buffer:ws.read,bytesPerRow:bpr,rowsPerImage:h},{width:w,height:h});for(let si=0;si<4;si++)encoder.copyBufferToBuffer(ws.unresolveds[si],0,ws.read,pixelBytes+si*UNRESOLVED_BYTES,UNRESOLVED_BYTES);d.queue.submit([encoder.finish()]);await ws.read.mapAsync(GPUMapMode.READ,0,pixelBytes+statsBytes);const raw=new Uint8Array(ws.read.getMappedRange(0,pixelBytes+statsBytes)),out=new Uint8ClampedArray(w*h*4);for(let y=0;y<h;y++)out.set(raw.subarray(y*bpr,y*bpr+w*4),y*w*4);let unresolved=0;for(let si=0;si<4;si++)unresolved+=new Uint32Array(raw.buffer,raw.byteOffset+pixelBytes+si*UNRESOLVED_BYTES,UNRESOLVED_BYTES/4)[0]||0;ws.read.unmap();return{rgba:out,unresolved}
}
destroy(){this.frameDestroy();this.exportWorkspaceDestroy();this.destroyDeepContext();this.destroyFastContext()}
}
@@ -1514,6 +1517,16 @@
// sparse/moderate failures go directly to local references.
// Integer interval tests over exact rational pixel coordinates. No f32
// rounding, periodic-orbit guess, or agreement of two precisions is a proof.
+
+function knownInteriorViewportMayOverlap(snap,width,height){
+ const unit=1n<<BigInt(snap.bits),bw=BigInt(Math.max(1,width)),bh=BigInt(Math.max(1,height));
+ const xLo=2n*snap.re-snap.span,xHi=2n*snap.re+snap.span,xDen=2n*unit;
+ const yLo=2n*bw*snap.im-snap.span*bh,yHi=2n*bw*snap.im+snap.span*bh,yDen=2n*bw*unit;
+ const axis=(lo,hi,den,lp,lq,hp,hq)=>hi*BigInt(lq)>=BigInt(lp)*den&&lo*BigInt(hq)<=BigInt(hp)*den;
+ const cardioid=axis(xLo,xHi,xDen,-3,4,3,8)&&axis(yLo,yHi,yDen,-2,3,2,3);
+ const bulb=axis(xLo,xHi,xDen,-5,4,-3,4)&&axis(yLo,yHi,yDen,-1,4,1,4);
+ return cardioid||bulb;
+}
function certifiedInteriorTiles(snap,width,height,tile=32,historySeed=null){
const columns=Math.ceil(width/tile),rows=Math.ceil(height/tile),mask=new Uint32Array(4+columns*rows);
mask.set([width,height,tile,columns]);let pixels=0;
@@ -1715,7 +1728,7 @@
constructor(){this.workers=[];this.serial=0;this.pending=new Map();this.failed=false;this.ctx=null}
ensure(){if(this.workers.length)return true;if(this.failed||typeof Worker==='undefined'||typeof Blob==='undefined')return false;try{this.ctx=canvas.getContext('2d');if(!this.ctx)throw new Error('2D canvas unavailable');const count=Math.max(1,Math.min(4,(Number(navigator.hardwareConcurrency)||4)-1));for(let i=0;i<count;i++){const url=URL.createObjectURL(new Blob([cpuFallbackWorkerSource()],{type:'text/javascript'})),w=new Worker(url);w.__url=url;w.onmessage=e=>{const d=e.data,p=this.pending.get(d.id);if(!p)return;this.pending.delete(d.id);d.type==='error'?p.reject(new Error(d.error)):p.resolve(d)};w.onerror=e=>{this.failed=true;for(const p of this.pending.values())p.reject(new Error(e.message||'CPU fallback worker error'));this.pending.clear()};this.workers.push(w)}return true}catch(e){this.failed=true;return false}}
request(worker,payload){const id=++this.serial;return new Promise((resolve,reject)=>{this.pending.set(id,{resolve,reject});worker.postMessage({...payload,id})})}
- async render(snap,iter,token){if(!this.ensure())throw new Error('CPU fallback workerを作成できません');const w=canvas.width,h=canvas.height,re=fixedNum(snap.re,snap.bits),im=fixedNum(snap.im,snap.bits),span=fixedNum(snap.span,snap.bits),step=span/Math.max(1,w);if(!Number.isFinite(re)||!Number.isFinite(im)||!Number.isFinite(span)||span<=0||step===0||(re!==0&&re+step===re)||(im!==0&&im+step===im))throw new Error('CPU fallback の倍精度範囲を超えています');const style=colorStyleSnapshot(),jobs=[],rows=Math.ceil(h/this.workers.length);for(let i=0;i<this.workers.length;i++){const y0=i*rows,y1=Math.min(h,y0+rows);if(y0<y1)jobs.push(this.request(this.workers[i],{type:'render',w,h,y0,y1,maxIter:iter,re,im,span,style}))}const parts=await Promise.all(jobs);if(token!==state.token)throw new Error('cancelled');const rgba=new Uint8ClampedArray(w*h*4);let operationLimit=0,heuristicInterior=0,work=0,escaped=0;for(const part of parts){rgba.set(new Uint8ClampedArray(part.rgba),part.y0*w*4);operationLimit+=part.operationLimit||0;heuristicInterior+=part.heuristicInterior||0;work+=part.work||0;escaped+=part.escaped||0}this.ctx.putImageData(new ImageData(rgba,w,h),0,0);state.cpuFrameRgba=rgba;return{operationLimit,heuristicInterior,work,escaped,workers:this.workers.length}}
+ async render(snap,iter,token){if(!this.ensure())throw new Error('CPU fallback workerを作成できません');const w=canvas.width,h=canvas.height,re=fixedNum(snap.re,snap.bits),im=fixedNum(snap.im,snap.bits),span=fixedNum(snap.span,snap.bits),step=span/Math.max(1,w);if(!Number.isFinite(re)||!Number.isFinite(im)||!Number.isFinite(span)||span<=0||step===0||(re!==0&&re+step===re)||(im!==0&&im+step===im))throw new Error('CPU fallback の倍精度範囲を超えています');const style=colorStyleSnapshot(),jobs=[],rows=Math.ceil(h/this.workers.length);for(let i=0;i<this.workers.length;i++){const y0=i*rows,y1=Math.min(h,y0+rows);if(y0<y1)jobs.push(this.request(this.workers[i],{type:'render',w,h,y0,y1,maxIter:iter,re,im,span,style}))}const parts=await Promise.all(jobs);if(token!==state.token)throw new Error('cancelled');const rgba=new Uint8ClampedArray(w*h*4);let operationLimit=0,heuristicInterior=0,work=0,escaped=0;for(const part of parts){rgba.set(new Uint8ClampedArray(part.rgba),part.y0*w*4);operationLimit+=part.operationLimit||0;heuristicInterior+=part.heuristicInterior||0;work+=part.work||0;escaped+=part.escaped||0}this.ctx.putImageData(new ImageData(rgba,w,h),0,0);return{operationLimit,heuristicInterior,work,escaped,workers:this.workers.length}}
cancelPending(reason='cancelled'){for(const p of this.pending.values())p.reject(new Error(reason));this.pending.clear();this.destroyWorkers()}
destroyWorkers(){for(const w of this.workers){try{w.terminate()}catch{}try{URL.revokeObjectURL(w.__url)}catch{}}this.workers.length=0}
destroy(){this.cancelPending('destroyed');this.ctx=null}
@@ -1759,9 +1772,9 @@
}
async function renderFinalFrame(r,snap,iter,token){
let decision=chooseBackend(snap,canvas.width),fastExtended=decision.fastExtended;
- const historySeed=fastExtended?r.numericHistorySeed(snap,iter,false,true):null,proof=certifiedInteriorTiles(snap,canvas.width,canvas.height,32,historySeed);let certified=0,preserveInterior=false;
+ const historySeed=fastExtended?r.numericHistorySeed(snap,iter,false,true):null,proof=knownInteriorViewportMayOverlap(snap,canvas.width,canvas.height)?certifiedInteriorTiles(snap,canvas.width,canvas.height,32,historySeed):{mask:null,pixels:0};let certified=0,preserveInterior=false;
if(proof.pixels||historySeed){certified=await r.certifyInterior(snap,token,true,proof,historySeed);if(token!==state.token)return null;preserveInterior=certified>0||!!historySeed}
- if(certified===canvas.width*canvas.height){decision={backend:'exact-interior',fastExtended:false,reason:'exact-full-interior'};const stats=await r.readUnresolvedStats();if(token!==state.token)return null;state.unresolved=stats.total;state.unknownReasons=stats.reasons;state.heuristicInterior=0;const painted=await r.recolor(token,iter);if(!painted||token!==state.token)return null;const frontier={stats,iter,minimumIter:iter,rounds:0,processed:0,escaped:0,converged:true,finiteBudgetComplete:true,policy:'exact-interior-proof',membershipCertified:true,applied:true};state.frameView=snap;state.fieldView={...snap,iter,frontier,w:canvas.width,h:canvas.height,fastExtended:false,backend:'exact-interior',referenceKey:'',referenceReused:false,complete:true,membershipCertified:true};r.captureColorSource(snap,state.colorAuto||state.recolorPending);r.presentFrame({scaleX:1,scaleY:1,offsetX:0,offsetY:0});return{decision,referenceReused:false,referencePixel:null,certified}}
+ if(certified===canvas.width*canvas.height){decision={backend:'exact-interior',fastExtended:false,reason:'exact-full-interior'};const stats={total:0,heuristicInterior:0,reasons:{errorBound:0,escapeUncertain:0,referenceEnd:0,rebaseGap:0,range:0,operationLimit:0}};state.unresolved=0;state.unknownReasons=stats.reasons;state.heuristicInterior=0;const painted=await r.recolor(token,iter);if(!painted||token!==state.token)return null;const frontier={stats,iter,minimumIter:iter,rounds:0,processed:0,escaped:0,converged:true,finiteBudgetComplete:true,policy:'exact-interior-proof',membershipCertified:true,applied:true};state.frameView=snap;state.fieldView={...snap,iter,frontier,w:canvas.width,h:canvas.height,fastExtended:false,backend:'exact-interior',referenceKey:'',referenceReused:false,complete:true,membershipCertified:true};r.captureColorSource(snap,state.colorAuto||state.recolorPending);r.presentFrame({scaleX:1,scaleY:1,offsetX:0,offsetY:0});return{decision,referenceReused:false,referencePixel:null,certified}}
let fastCtx=null,referencePixel=null,referenceReused=false;if(fastExtended){const reuse=reusableFastReference(snap,iter,canvas.width,canvas.height);if(reuse){fastCtx=reuse.ctx;referencePixel=reuse.pixel;referenceReused=true}else{fastCtx=await fastRefs.request(snap,iter,canvas.width,canvas.height);if(token!==state.token)return null;referencePixel=referencePixelForSource(fastCtx.source,snap,canvas.width,canvas.height)}}
const deferColor=!!(r.historyReady&&canUseStableReprojection());state.deferNumericPublish=deferColor;const ok=await r.computeFrame(snap,iter,token,referencePixel,fastExtended,fastCtx,preserveInterior,!!historySeed,deferColor);if(!ok||token!==state.token){state.deferNumericPublish=false;return null}if(!deferColor&&!await presentProvisional(r,snap,iter,token,true)){state.deferNumericPublish=false;return null}
let stats=await r.readUnresolvedStats();if(token!==state.token)return null;if(fastExtended){const initialFailed=numericalFailureCount(stats);if(initialFailed>0){const ordered=await recoverNumericalCostOrdered(r,snap,iter,token,stats);if(!ordered||token!==state.token){state.deferNumericPublish=false;return null}stats=ordered.stats}state.unresolved=stats.total;state.unknownReasons=stats.reasons;state.heuristicInterior=stats.heuristicInterior||0;if(initialFailed>0&&!deferColor){const painted=await r.recolor(token,iter);if(!painted||token!==state.token){state.deferNumericPublish=false;return null}}}else{state.unresolved=stats.total;state.unknownReasons=stats.reasons}
@@ -1819,7 +1832,7 @@
// Only promote a colour frame to stable history if it still represents
// the newest slider value. Rapid dragging therefore collapses to the
// latest value instead of replaying stale colour states.
- if(revision===state.colorRevision&&numericFrameComplete()){renderer.commitHistory(state.frameView);state.temporalFill=false}
+ if(revision===state.colorRevision&&numericFrameComplete()){renderer.commitHistory(state.frameView,false);state.temporalFill=false}
updateStats();
// Yield ownership after one pass: a stream of hue changes must not keep
// recoloring=true forever and prevent a pending pan from computing.
@@ -1863,7 +1876,7 @@
function syncColorAutoButton(){const b=$('colorAuto');b.textContent=state.colorAuto?'色相変化停止':'色相変化';b.classList.toggle('on',state.colorAuto);b.setAttribute('aria-pressed',state.colorAuto?'true':'false')}
function stopColorAuto(repaint=true){state.colorAuto=false;colorAutoLast=0;if(colorAutoRaf){cancelAnimationFrame(colorAutoRaf);colorAutoRaf=0}syncColorAutoButton();if(repaint)requestRecolor()}
function colorAutoStep(now){if(!state.colorAuto){colorAutoRaf=0;return}if(!colorAutoLast)colorAutoLast=now;const dt=Math.min(.1,Math.max(0,(now-colorAutoLast)/1000));colorAutoLast=now;/* Hue animation advances shift directly. No cycle or phase compensation is applied. */state.shift=fract01(state.shift+.09*dt);$('shift').value=String(state.shift);$('shiftO').textContent=state.shift.toFixed(2);if(!state.pointerActive&&!state.wheelActive&&now-colorAutoPaint>=(state.rendering?100:50)){colorAutoPaint=now;requestRecolor()}colorAutoRaf=requestAnimationFrame(colorAutoStep)}
-$('colorAuto').onclick=()=>{state.colorAuto=!state.colorAuto;syncColorAutoButton();if(state.colorAuto&&!colorAutoRaf){if(!state.rendering)renderer?.captureColorSource();colorAutoRaf=requestAnimationFrame(colorAutoStep);}else if(!state.colorAuto)stopColorAuto()};
+$('colorAuto').onclick=()=>{state.colorAuto=!state.colorAuto;syncColorAutoButton();if(state.colorAuto&&!colorAutoRaf){colorAutoRaf=requestAnimationFrame(colorAutoStep);}else if(!state.colorAuto)stopColorAuto()};
$('palette').addEventListener('change',e=>{const pal=Math.max(0,Math.min(PALETTE_MAX,Number(e.target.value)|0));state.palette=VALID_PALETTE_IDS.has(pal)?pal:0;requestRecolor();saveHash()});
function applyCycleControl(e){state.cycle=sliderToCycle(e.target.value);$('cycleO').textContent=state.cycle.toFixed(4);requestRecolor()}
function applyShiftControl(e){if(state.colorAuto)stopColorAuto();state.shift=Math.max(0,Math.min(1,Number(e.target.value)||0));$('shiftO').textContent=state.shift.toFixed(2);requestRecolor()}
@@ -1940,7 +1953,7 @@
downloadBlob(blob,base+'.png');downloadBlob(new Blob([JSON.stringify(meta,null,2)],{type:'application/json'}),base+'.json');
$('exportStatus').textContent=unresolvedSamples?'保存しました · 未確定sample '+unresolvedSamples+'(sidecar参照)':'PNGと座標メタデータを保存しました。';
}catch(e){await png.abort(e);$('exportStatus').textContent=String(e.message)==='cancelled'?'出力を中止しました。':'出力失敗: '+String(e&&e.message||e)}
- finally{exportJob.active=false;$('exportStart').disabled=false}
+ finally{r.exportWorkspaceDestroy();exportJob.active=false;$('exportStart').disabled=false}
}
$('png').onclick=()=>{const d=$('exportDialog');$('exportWidth').value=String(canvas.width);$('exportScale').value='1';$('exportProgress').hidden=true;$('exportStatus').textContent='';d.showModal?d.showModal():d.setAttribute('open','')};$('exportScale').onchange=e=>{const s=Number(e.target.value);if(s)$('exportWidth').value=String(exportDimensions().w)};$('exportStart').onclick=runExport;$('exportCancel').onclick=()=>{if(exportJob.active){exportJob.cancelled=true;$('exportStatus').textContent='中止しています…'}else $('exportDialog').close()};$('exportQuick').onclick=()=>canvas.toBlob(blob=>{if(blob)downloadBlob(blob,'mandelbrot-'+Date.now()+(state.fieldView?.complete===false?'-preview':'')+'.png')},'image/png');

View file

@ -0,0 +1,381 @@
{
"date": "2026-09-29T09:27:34.132Z",
"periodicity": {
"w": 96,
"h": 64,
"maxIter": 8192,
"oracleIter": 65536,
"rows": [
{
"view": "overview",
"productionBackend": "direct",
"pixels": 6144,
"baseWork": 884420,
"heuristicWork": 133404,
"workReduction": 0.849162162773343,
"candidates": 96,
"falseCandidates": 0,
"baseLimit": 104,
"heuristicLimit": 8,
"diagnosticIfForcedDirect": null,
"ms": 107.84037399999998
},
{
"view": "period3-bulb",
"productionBackend": "direct",
"pixels": 6144,
"baseWork": 50331648,
"heuristicWork": 460156,
"workReduction": 0.9908575216929117,
"candidates": 6144,
"falseCandidates": 0,
"baseLimit": 6144,
"heuristicLimit": 0,
"diagnosticIfForcedDirect": null,
"ms": 4596.538675
},
{
"view": "period3-core",
"productionBackend": "direct",
"pixels": 6144,
"baseWork": 50331648,
"heuristicWork": 442395,
"workReduction": 0.991210401058197,
"candidates": 6144,
"falseCandidates": 0,
"baseLimit": 6144,
"heuristicLimit": 0,
"diagnosticIfForcedDirect": null,
"ms": 3849.7766140000003
},
{
"view": "seahorse-boundary",
"productionBackend": "direct",
"pixels": 6144,
"baseWork": 410111,
"heuristicWork": 410111,
"workReduction": 0,
"candidates": 0,
"falseCandidates": 0,
"baseLimit": 0,
"heuristicLimit": 0,
"diagnosticIfForcedDirect": null,
"ms": 28.11920800000007
},
{
"view": "seahorse-deep",
"productionBackend": "fast-extended",
"pixels": 6144,
"baseWork": 36579905,
"heuristicWork": 6167354,
"workReduction": null,
"candidates": 0,
"falseCandidates": 0,
"baseLimit": 4304,
"heuristicLimit": 4304,
"diagnosticIfForcedDirect": {
"workReduction": 0.8314004916087125,
"candidates": 4125,
"falseCandidates": 12
},
"ms": 2697.9715419999993
},
{
"view": "exterior",
"productionBackend": "direct",
"pixels": 6144,
"baseWork": 686432,
"heuristicWork": 137063,
"workReduction": 0.8003254510279241,
"candidates": 72,
"falseCandidates": 0,
"baseLimit": 79,
"heuristicLimit": 7,
"diagnosticIfForcedDirect": null,
"ms": 44.91841799999929
}
],
"note": "Production DIRECT_F32 rule with two attracting-cycle confirmations. FAST/perturbation periodic early-stop is intentionally disabled."
},
"series": {
"rows": [
{
"span": 0.00001,
"jump": 53,
"errorLog2": -22.11596292311006,
"maxAbsError": 7.112067409017996e-9,
"maxRelativeError": 0.00009415623690713784,
"refLen": 1200,
"buildMs": 38.390750999999
},
{
"span": 1e-7,
"jump": 446,
"errorLog2": -22.302471602690176,
"maxAbsError": 7.736805567567409e-9,
"maxRelativeError": 0.0031991190750368034,
"refLen": 1200,
"buildMs": 35.28093799999988
},
{
"span": 1e-9,
"jump": 930,
"errorLog2": -22.038152635655024,
"maxAbsError": 7.459933177532973e-9,
"maxRelativeError": 0.00001015603159369818,
"refLen": 1200,
"buildMs": 40.962877999998454
},
{
"span": 1e-11,
"jump": 1037,
"errorLog2": -23.96377927072575,
"maxAbsError": 2.2791360188558774e-9,
"maxRelativeError": 0.000004948137247650717,
"refLen": 1200,
"buildMs": 42.88623700000062
}
],
"note": "Production fast-reference worker output. Error compares packed 4th-order series against direct perturbation over 8 screen-domain deltas."
},
"sparseActiveState": {
"rows": [
{
"preset": "fast",
"pixels": 524288,
"activeRatio": 1,
"capacity": 524288,
"oldBytes": 23068672,
"newBytes": 23134208,
"reduction": -0.0028409090909091717
},
{
"preset": "fast",
"pixels": 524288,
"activeRatio": 0.25,
"capacity": 164096,
"oldBytes": 23068672,
"newBytes": 7285760,
"reduction": 0.6841708096590908
},
{
"preset": "fast",
"pixels": 524288,
"activeRatio": 0.1,
"capacity": 65792,
"oldBytes": 23068672,
"newBytes": 2960384,
"reduction": 0.8716708096590909
},
{
"preset": "fast",
"pixels": 524288,
"activeRatio": 0.01,
"capacity": 6810,
"oldBytes": 23068672,
"newBytes": 365176,
"reduction": 0.9841700467196378
},
{
"preset": "standard",
"pixels": 1048576,
"activeRatio": 1,
"capacity": 1048576,
"oldBytes": 46137344,
"newBytes": 46268416,
"reduction": -0.0028409090909091717
},
{
"preset": "standard",
"pixels": 1048576,
"activeRatio": 0.25,
"capacity": 327936,
"oldBytes": 46137344,
"newBytes": 14560256,
"reduction": 0.6844149502840908
},
{
"preset": "standard",
"pixels": 1048576,
"activeRatio": 0.1,
"capacity": 131328,
"oldBytes": 46137344,
"newBytes": 5909504,
"reduction": 0.8719149502840909
},
{
"preset": "standard",
"pixels": 1048576,
"activeRatio": 0.01,
"capacity": 13364,
"oldBytes": 46137344,
"newBytes": 719088,
"reduction": 0.9844141873446378
},
{
"preset": "high",
"pixels": 4194304,
"activeRatio": 1,
"capacity": 4194304,
"oldBytes": 184549376,
"newBytes": 185073664,
"reduction": -0.0028409090909091717
},
{
"preset": "high",
"pixels": 4194304,
"activeRatio": 0.25,
"capacity": 1310976,
"oldBytes": 184549376,
"newBytes": 58207232,
"reduction": 0.6845980557528408
},
{
"preset": "high",
"pixels": 4194304,
"activeRatio": 0.1,
"capacity": 524544,
"oldBytes": 184549376,
"newBytes": 23604224,
"reduction": 0.8720980557528409
},
{
"preset": "high",
"pixels": 4194304,
"activeRatio": 0.01,
"capacity": 52685,
"oldBytes": 184549376,
"newBytes": 2842428,
"reduction": 0.9845980080691251
}
],
"note": "Production allocation: 32 B state + 2x4 B queues + 4 B pixel map per active slot, plus 1-bit membership bitmap. Capacity adds 25% + 256 headroom."
},
"multiReference": {
"maxGuidedPasses": 3,
"w": 64,
"h": 40,
"maxIter": 1200,
"rows": [
{
"view": "seahorse-1e-5",
"grid": 1,
"references": 1,
"total": 2560,
"refEnd": 0,
"mismatchClass": 3,
"mismatchIter": 155,
"mismatchRate": 0.001171875
},
{
"view": "seahorse-1e-5",
"grid": 2,
"references": 5,
"total": 2560,
"refEnd": 0,
"mismatchClass": 3,
"mismatchIter": 155,
"mismatchRate": 0.001171875
},
{
"view": "seahorse-1e-5",
"grid": 3,
"references": 9,
"total": 2560,
"refEnd": 0,
"mismatchClass": 3,
"mismatchIter": 155,
"mismatchRate": 0.001171875
},
{
"view": "seahorse-1e-7",
"grid": 1,
"references": 1,
"total": 2560,
"refEnd": 0,
"mismatchClass": 66,
"mismatchIter": 74,
"mismatchRate": 0.02578125
},
{
"view": "seahorse-1e-7",
"grid": 2,
"references": 5,
"total": 2560,
"refEnd": 0,
"mismatchClass": 37,
"mismatchIter": 41,
"mismatchRate": 0.014453125
},
{
"view": "seahorse-1e-7",
"grid": 3,
"references": 9,
"total": 2560,
"refEnd": 0,
"mismatchClass": 50,
"mismatchIter": 67,
"mismatchRate": 0.01953125
}
],
"note": "CPU f32 perturbation locality surrogate. Production selects up to three additional references from failure clusters instead of fixed grids."
},
"cpuFallback": {
"w": 640,
"h": 360,
"iter": 350,
"threads": 4,
"one": {
"medianMs": 142.60004300000037,
"times": [
149.66868299999987,
152.4433539999991,
129.93585699999858,
119.70592199999919,
129.10428200000024,
142.60004300000037,
150.36682700000165
],
"work": 2658084,
"escaped": 176648,
"operationLimit": 3394,
"heuristicInterior": 1654
},
"multi": {
"medianMs": 58.8322790000002,
"times": [
132.8346849999998,
62.70222600000125,
52.98496299999897,
54.89045199999964,
60.89404900000045,
58.8322790000002,
43.25380600000062
],
"work": 2658084,
"escaped": 176648,
"operationLimit": 3394,
"heuristicInterior": 1654
},
"speedup": 2.423840201736871,
"workMatch": true,
"escapedMatch": true,
"operationLimitMatch": true,
"heuristicMatch": true,
"note": "Production CPU fallback Worker source executed under node:worker_threads shim."
},
"sourceAudit": {
"heuristicClass": true,
"strictDisablesPeriodicity": true,
"seriesBinding": true,
"compactPixelMap": true,
"multiReferencePasses": true,
"cpuFallback": true
},
"environment": {
"node": "v22.16.0",
"cpus": 5,
"platform": "linux",
"arch": "x64"
}
}

View file

@ -0,0 +1,574 @@
{
"date": "2026-09-29",
"base": {
"sha256": "544820c5024cf6d56e012db6d1bec3ddfcef5d833691b890baa64b26c5c2ee5a",
"bytes": 223672,
"gzip9": 57375
},
"pruned": {
"sha256": "e69b91ead027927d977a065d27d9e27629110122df20aa470eab877e23f77ac7",
"bytes": 224559,
"gzip9": 57744
},
"validation": {
"regression": "PASS",
"kernelCount": 19,
"variantSyntax": "PASS",
"interiorGateRandom": {
"cases": 240,
"gateFalse": 160,
"falseNeg": 0,
"totalProofInFalseNeg": 0
},
"fastCoverageSemantic": {
"cases": 200,
"casesWithHoles": 0
}
},
"structural": {
"cpuFrameRgbaRemoved": true,
"fastCoverageRepairRemoved": true,
"idleColorAutoSnapshotRemoved": true,
"exactInteriorSpatialGate": true,
"conditionalNumericHistory": true,
"lazyExportAaSamples": true,
"exportWorkspaceReleased": true,
"recolorNumericHistorySeparated": true,
"prePrimaryUnknownStatsRemoved": true,
"deadFastMode2ParamWriteRemoved": true
},
"priorABEvidence": {
"fastCoverageCases": [
{
"w": 965,
"h": 543,
"iter": 350,
"mirror": false,
"tileW": 965,
"initialRows": 142,
"holes": 0,
"repairThreads": 526592,
"minimumMetaReadBytes": 2095980
},
{
"w": 965,
"h": 543,
"iter": 350,
"mirror": true,
"tileW": 965,
"initialRows": 142,
"holes": 0,
"repairThreads": 526592,
"minimumMetaReadBytes": 2095980
},
{
"w": 965,
"h": 543,
"iter": 4096,
"mirror": false,
"tileW": 965,
"initialRows": 12,
"holes": 0,
"repairThreads": 526592,
"minimumMetaReadBytes": 2095980
},
{
"w": 965,
"h": 543,
"iter": 4096,
"mirror": true,
"tileW": 965,
"initialRows": 12,
"holes": 0,
"repairThreads": 526592,
"minimumMetaReadBytes": 2095980
},
{
"w": 965,
"h": 543,
"iter": 16384,
"mirror": false,
"tileW": 965,
"initialRows": 3,
"holes": 0,
"repairThreads": 526592,
"minimumMetaReadBytes": 2095980
},
{
"w": 965,
"h": 543,
"iter": 16384,
"mirror": true,
"tileW": 965,
"initialRows": 3,
"holes": 0,
"repairThreads": 526592,
"minimumMetaReadBytes": 2095980
},
{
"w": 1365,
"h": 768,
"iter": 350,
"mirror": false,
"tileW": 1365,
"initialRows": 100,
"holes": 0,
"repairThreads": 1050624,
"minimumMetaReadBytes": 4193280
},
{
"w": 1365,
"h": 768,
"iter": 350,
"mirror": true,
"tileW": 1365,
"initialRows": 100,
"holes": 0,
"repairThreads": 1050624,
"minimumMetaReadBytes": 4193280
},
{
"w": 1365,
"h": 768,
"iter": 4096,
"mirror": false,
"tileW": 1365,
"initialRows": 8,
"holes": 0,
"repairThreads": 1050624,
"minimumMetaReadBytes": 4193280
},
{
"w": 1365,
"h": 768,
"iter": 4096,
"mirror": true,
"tileW": 1365,
"initialRows": 8,
"holes": 0,
"repairThreads": 1050624,
"minimumMetaReadBytes": 4193280
},
{
"w": 1365,
"h": 768,
"iter": 16384,
"mirror": false,
"tileW": 1365,
"initialRows": 2,
"holes": 0,
"repairThreads": 1050624,
"minimumMetaReadBytes": 4193280
},
{
"w": 1365,
"h": 768,
"iter": 16384,
"mirror": true,
"tileW": 1365,
"initialRows": 2,
"holes": 0,
"repairThreads": 1050624,
"minimumMetaReadBytes": 4193280
},
{
"w": 1024,
"h": 768,
"iter": 350,
"mirror": false,
"tileW": 1024,
"initialRows": 133,
"holes": 0,
"repairThreads": 786432,
"minimumMetaReadBytes": 3145728
},
{
"w": 1024,
"h": 768,
"iter": 350,
"mirror": true,
"tileW": 1024,
"initialRows": 133,
"holes": 0,
"repairThreads": 786432,
"minimumMetaReadBytes": 3145728
},
{
"w": 1024,
"h": 768,
"iter": 4096,
"mirror": false,
"tileW": 1024,
"initialRows": 11,
"holes": 0,
"repairThreads": 786432,
"minimumMetaReadBytes": 3145728
},
{
"w": 1024,
"h": 768,
"iter": 4096,
"mirror": true,
"tileW": 1024,
"initialRows": 11,
"holes": 0,
"repairThreads": 786432,
"minimumMetaReadBytes": 3145728
},
{
"w": 1024,
"h": 768,
"iter": 16384,
"mirror": false,
"tileW": 1024,
"initialRows": 2,
"holes": 0,
"repairThreads": 786432,
"minimumMetaReadBytes": 3145728
},
{
"w": 1024,
"h": 768,
"iter": 16384,
"mirror": true,
"tileW": 1024,
"initialRows": 2,
"holes": 0,
"repairThreads": 786432,
"minimumMetaReadBytes": 3145728
},
{
"w": 2731,
"h": 1536,
"iter": 350,
"mirror": false,
"tileW": 2731,
"initialRows": 50,
"holes": 0,
"repairThreads": 4202496,
"minimumMetaReadBytes": 16779264
},
{
"w": 2731,
"h": 1536,
"iter": 350,
"mirror": true,
"tileW": 2731,
"initialRows": 50,
"holes": 0,
"repairThreads": 4202496,
"minimumMetaReadBytes": 16779264
},
{
"w": 2731,
"h": 1536,
"iter": 4096,
"mirror": false,
"tileW": 2731,
"initialRows": 4,
"holes": 0,
"repairThreads": 4202496,
"minimumMetaReadBytes": 16779264
},
{
"w": 2731,
"h": 1536,
"iter": 4096,
"mirror": true,
"tileW": 2731,
"initialRows": 4,
"holes": 0,
"repairThreads": 4202496,
"minimumMetaReadBytes": 16779264
},
{
"w": 2731,
"h": 1536,
"iter": 16384,
"mirror": false,
"tileW": 2731,
"initialRows": 1,
"holes": 0,
"repairThreads": 4202496,
"minimumMetaReadBytes": 16779264
},
{
"w": 2731,
"h": 1536,
"iter": 16384,
"mirror": true,
"tileW": 2731,
"initialRows": 1,
"holes": 0,
"repairThreads": 4202496,
"minimumMetaReadBytes": 16779264
},
{
"w": 2048,
"h": 2048,
"iter": 350,
"mirror": false,
"tileW": 2048,
"initialRows": 66,
"holes": 0,
"repairThreads": 4194304,
"minimumMetaReadBytes": 16777216
},
{
"w": 2048,
"h": 2048,
"iter": 350,
"mirror": true,
"tileW": 2048,
"initialRows": 66,
"holes": 0,
"repairThreads": 4194304,
"minimumMetaReadBytes": 16777216
},
{
"w": 2048,
"h": 2048,
"iter": 4096,
"mirror": false,
"tileW": 2048,
"initialRows": 5,
"holes": 0,
"repairThreads": 4194304,
"minimumMetaReadBytes": 16777216
},
{
"w": 2048,
"h": 2048,
"iter": 4096,
"mirror": true,
"tileW": 2048,
"initialRows": 5,
"holes": 0,
"repairThreads": 4194304,
"minimumMetaReadBytes": 16777216
},
{
"w": 2048,
"h": 2048,
"iter": 16384,
"mirror": false,
"tileW": 2048,
"initialRows": 1,
"holes": 0,
"repairThreads": 4194304,
"minimumMetaReadBytes": 16777216
},
{
"w": 2048,
"h": 2048,
"iter": 16384,
"mirror": true,
"tileW": 2048,
"initialRows": 1,
"holes": 0,
"repairThreads": 4194304,
"minimumMetaReadBytes": 16777216
},
{
"w": 513,
"h": 511,
"iter": 350,
"mirror": false,
"tileW": 513,
"initialRows": 267,
"holes": 0,
"repairThreads": 266240,
"minimumMetaReadBytes": 1048572
},
{
"w": 513,
"h": 511,
"iter": 350,
"mirror": true,
"tileW": 513,
"initialRows": 267,
"holes": 0,
"repairThreads": 266240,
"minimumMetaReadBytes": 1048572
},
{
"w": 513,
"h": 511,
"iter": 4096,
"mirror": false,
"tileW": 513,
"initialRows": 22,
"holes": 0,
"repairThreads": 266240,
"minimumMetaReadBytes": 1048572
},
{
"w": 513,
"h": 511,
"iter": 4096,
"mirror": true,
"tileW": 513,
"initialRows": 22,
"holes": 0,
"repairThreads": 266240,
"minimumMetaReadBytes": 1048572
},
{
"w": 513,
"h": 511,
"iter": 16384,
"mirror": false,
"tileW": 513,
"initialRows": 5,
"holes": 0,
"repairThreads": 266240,
"minimumMetaReadBytes": 1048572
},
{
"w": 513,
"h": 511,
"iter": 16384,
"mirror": true,
"tileW": 513,
"initialRows": 5,
"holes": 0,
"repairThreads": 266240,
"minimumMetaReadBytes": 1048572
}
],
"numericHistory": [
{
"name": "1920x1080-fast",
"cssW": 1920,
"cssH": 1080,
"w": 965,
"h": 543,
"hits": 2,
"total": 1000,
"hitRate": 0.002,
"meanReuseRatio": 0.8,
"reducedScaleDenominator": 384,
"policyEligible": false,
"aResidentBytes": 4191960,
"bResidentBytes": 0
},
{
"name": "1920x1080-standard",
"cssW": 1920,
"cssH": 1080,
"w": 1365,
"h": 768,
"hits": 6,
"total": 1000,
"hitRate": 0.006,
"meanReuseRatio": 0.8666666666666667,
"reducedScaleDenominator": 128,
"policyEligible": false,
"aResidentBytes": 8386560,
"bResidentBytes": 0
},
{
"name": "1920x1080-high",
"cssW": 1920,
"cssH": 1080,
"w": 2731,
"h": 1536,
"hits": 0,
"total": 1000,
"hitRate": 0,
"meanReuseRatio": 0,
"reducedScaleDenominator": 1920,
"policyEligible": false,
"aResidentBytes": 33558528,
"bResidentBytes": 0
},
{
"name": "1024x768-standard",
"cssW": 1024,
"cssH": 768,
"w": 1024,
"h": 768,
"hits": 1000,
"total": 1000,
"hitRate": 1,
"meanReuseRatio": 0.75537109375,
"reducedScaleDenominator": 1,
"policyEligible": true,
"aResidentBytes": 6291456,
"bResidentBytes": 6291456
},
{
"name": "1024x768-dpr2",
"cssW": 1024,
"cssH": 768,
"w": 2048,
"h": 1536,
"hits": 1000,
"total": 1000,
"hitRate": 1,
"meanReuseRatio": 0.75537109375,
"reducedScaleDenominator": 1,
"policyEligible": true,
"aResidentBytes": 25165824,
"bResidentBytes": 25165824
}
],
"memoryTraffic": {
"rows": [
{
"preset": "fast",
"pixels": 524288,
"recolorNumericCopyA": 4194304,
"recolorNumericCopyB": 0,
"cpuRgbaRetainedA": 2097152,
"cpuRgbaRetainedB": 0,
"idleColorSnapshotA": 6291456,
"idleColorSnapshotB": 0,
"unknownPrepassMetaReadA": 2097152,
"unknownPrepassMetaReadB": 0,
"fastRepairMetaReadA": 2097152,
"fastRepairMetaReadB": 0
},
{
"preset": "standard",
"pixels": 1048576,
"recolorNumericCopyA": 8388608,
"recolorNumericCopyB": 0,
"cpuRgbaRetainedA": 4194304,
"cpuRgbaRetainedB": 0,
"idleColorSnapshotA": 12582912,
"idleColorSnapshotB": 0,
"unknownPrepassMetaReadA": 4194304,
"unknownPrepassMetaReadB": 0,
"fastRepairMetaReadA": 4194304,
"fastRepairMetaReadB": 0
},
{
"preset": "high",
"pixels": 4194304,
"recolorNumericCopyA": 33554432,
"recolorNumericCopyB": 0,
"cpuRgbaRetainedA": 16777216,
"cpuRgbaRetainedB": 0,
"idleColorSnapshotA": 50331648,
"idleColorSnapshotB": 0,
"unknownPrepassMetaReadA": 16777216,
"unknownPrepassMetaReadB": 0,
"fastRepairMetaReadA": 16777216,
"fastRepairMetaReadB": 0
}
],
"export": {
"retainedA": 25165824,
"retainedB": 0,
"reallocationSlotsB": 3
}
}
},
"notes": [
"Real WebGPU frame-time p50/p95 remains unavailable in this container.",
"Adaptive initial-iteration probe was retained because blanket removal regressed 3/48 survey views.",
"Strict periodicity-state updates were retained because CPU A/B did not establish a speed win.",
"Continuation replay and deep-workspace lifetime were not changed because they require redesign or real-GPU allocation measurements rather than safe deletion."
]
}

View file

@ -0,0 +1,123 @@
# Reported deep-view artifact investigation and fix
Date: 2026-09-29
## Purpose
Investigate the reported URL state that displayed large yellow/black geometric regions and sometimes an almost entirely black frame. The production source before this pass is preserved as `index.before.html`. The fix is intentionally narrow: do not restore previously removed expensive work unless evidence points to it.
Reported state:
- bits: 273
- re: `-15958919693715511752069626580195447581643016686947107473855747766272418724092005269`
- im: `3884297345146583343999140717464554120497735725078805729405733239132420430333375719`
- span: `35679807704506851074208413824041445358585910697267232113437882789120793`
- palette: 0
- cycle: 0.008
- shift: 0.18
- base iteration: 350, adaptive enabled, quality high
At 1108×1582, the adaptive initial target for this region is approximately 1536 iterations and the renderer takes the `fast-extended` path.
## Root cause 1: unsafe series jump
The integrated fourth-order series approximation was enabled for the reported view. The fast-reference worker selected:
- reference pixel ≈ `(69.5, 922.5)`
- reference length: 1285
- series jump: 773
- fourth-order coefficient components up to approximately `2.35e38`, close to the maximum finite f32 magnitude
The series validity test checked truncation terms in unscaled orbit space, but did not bound the *intermediate scaled f32 polynomial products* used by WGSL. At screen-domain deltas away from the selected reference, `a4 * d^4` can overflow before the later `ldexp` rescales the term.
The reproducible probe in `experiments/reported-view/analyze.mjs` evaluates the exact worker output from the preserved pre-fix source. On a 32×32 viewport sample grid:
- pre-fix: 290 / 1024 series starts produce a non-finite fourth-order intermediate
- fixed: 0 / 1024, because series acceleration is disabled
This is sufficient to generate `RANGE` failures and black unknown pixels. Separate CPU surrogate comparison also showed large escape-iteration deviations in this state, so merely adding an overflow clamp would not make the current jump trustworthy enough for publication.
### Fix
`SERIES_APPROXIMATION_ENABLED=false` now disables production series construction. The code remains present for future work, but the fast-reference worker emits `jump=0` and zero coefficients.
Re-enabling series should require at minimum:
1. viewport/reference-relative bounds on all scaled polynomial intermediates,
2. a propagated truncation/rounding error bound carried into the perturbation path,
3. a corpus test that checks false escapes, not only absolute approximation error,
4. strict handling when the selected reference is off-center.
## Root cause 2: publishing numerical failures before repair
The FAST primary frame was colored and presented immediately after the primary perturbation pass, before `recoverNumericalCostOrdered()` repaired `REFERENCE_END`, `RANGE`, and other numerical failures.
`COLOR_WGSL` writes alpha zero for `FIELD_UNKNOWN`. Therefore an early provisional frame can appear:
- black when there is no usable history,
- mixed with reprojected old colors when stable history exists,
- as large geometric regions reflecting reference validity / numerical-failure domains rather than Mandelbrot geometry.
### Fix
For `fast-extended`:
- primary colorization is deferred (`deferPrimaryColor = deferColor || fastExtended`),
- unresolved statistics are read,
- numerical failures are repaired,
- only then is the first provisional recolor/presentation allowed.
Direct rendering keeps its existing provisional behavior.
## What was *not* reverted
The previous FAST coverage-repair dispatch was not restored. The existing full-frame unknown reduction and final invariant still detect untouched reason-0 pixels, and prior A/B coverage tests found no holes. The reported state produced a concrete series failure mechanism, so restoring an O(pixels) repair pass would treat the symptom without addressing the cause.
Other removal-pass optimizations are unchanged.
## Verification
### Regression
`node tests/regression.mjs`
Result: PASS, 19 WGSL kernels.
Additional regression pins were added for:
- `SERIES_APPROXIMATION_ENABLED=false`
- FAST primary-color deferral via `deferPrimaryColor=deferColor||fastExtended`
### Reported-view worker probe
`node experiments/reported-view/analyze.mjs`
See `results.json`.
Key result:
| Metric | Before | After |
|---|---:|---:|
| reference length | 1285 | 1285 |
| series jump | 773 | 0 |
| non-finite series-start samples | 290 / 1024 | 0 / 1024 |
### Existing integrated CPU benchmark
`node experiments/integrated_bench.mjs`
Completed successfully. The series section now reports jump 0 at all tested spans, while periodicity, sparse active state, multi-reference, and CPU fallback tests continue to run.
### Browser/WebGPU smoke
`tests/browser_smoke.mjs` could not run because its expected external Chromium remote-debug endpoint at `127.0.0.1:9333` was not available in this environment. This is an environment limitation, not a test assertion failure. Actual WebGPU visual confirmation of the supplied URL remains the most important local-device check.
## Build summary
See `build-summary.json`.
- before SHA-256: `e69b91ead027927d977a065d27d9e27629110122df20aa470eab877e23f77ac7`
- fixed SHA-256: `c53c0dc4cb9fa2d2046aaa12ef03cc79c61a710b0c8db36dea1a87be2d9aedd8`
- HTML size delta: +396 bytes (comments/guard and publication-order logic)
The WGSL kernels themselves were not changed in this pass, so kernel hashes remain pinned to the prior 19-kernel set.

View file

@ -0,0 +1,8 @@
# Reported-view bugfix artifacts
- `IMPLEMENTATION.md` — diagnosis, changes, verification, and limitations.
- `results.json` — reproducible before/after fast-reference worker analysis for the reported view.
- `integrated-bench.json` — existing CPU-side integrated benchmark after the fix.
- `build-summary.json` — file sizes and SHA-256 hashes.
- `index.diff` — exact production source diff.
- `index.before.html` — preserved pre-fix production source.

View file

@ -0,0 +1,9 @@
{
"beforeBytes": 224559,
"afterBytes": 224955,
"deltaBytes": 396,
"beforeGzip": 57744,
"afterGzip": 57958,
"beforeSha256": "e69b91ead027927d977a065d27d9e27629110122df20aa470eab877e23f77ac7",
"afterSha256": "c53c0dc4cb9fa2d2046aaa12ef03cc79c61a710b0c8db36dea1a87be2d9aedd8"
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,28 @@
--- /mnt/data/mandelbrot_bugfix_reported_view/docs/reported-view-bugfix/index.before.html 2026-09-29 10:14:53.060514133 +0000
+++ /mnt/data/mandelbrot_bugfix_reported_view/index.html 2026-09-29 10:15:44.235560513 +0000
@@ -973,8 +973,13 @@
function cmuln(a,b){return[a[0]*b[0]-a[1]*b[1],a[0]*b[1]+a[1]*b[0]]}
function cscale(a,k){return[a[0]*k,a[1]*k]}
function clog2(a){const m=Math.hypot(a[0],a[1]);return m>0?Math.log2(m):-Infinity}
+const SERIES_APPROXIMATION_ENABLED=false;
function seriesForOrbit(rr,ri,refLen,span,bits,w,h){
- if(!span||refLen<16)return{jump:0,coeffs:Array(8).fill(0),errorLog2:0};
+ // Disabled after a reported deep-view failure: the current 4th-order jump
+ // has no per-pixel propagated error bound and can overflow scaled f32
+ // polynomial intermediates near viewport edges. Keep the implementation
+ // available for future guarded reintroduction, but never publish it now.
+ if(!SERIES_APPROXIMATION_ENABLED||!span||refLen<16)return{jump:0,coeffs:Array(8).fill(0),errorLog2:0};
const aspect=Math.max(1e-9,h/Math.max(1,w)),logDelta=fixedLog2Abs(BigInt(span),bits)+Math.log2(Math.hypot(1,aspect));
let a=Array.from({length:7},()=>[0,0]),best={jump:0,coeffs:Array(8).fill(0),errorLog2:0},limit=Math.min(refLen,4096);
for(let n=0;n<limit;n++){
@@ -1776,8 +1781,8 @@
if(proof.pixels||historySeed){certified=await r.certifyInterior(snap,token,true,proof,historySeed);if(token!==state.token)return null;preserveInterior=certified>0||!!historySeed}
if(certified===canvas.width*canvas.height){decision={backend:'exact-interior',fastExtended:false,reason:'exact-full-interior'};const stats={total:0,heuristicInterior:0,reasons:{errorBound:0,escapeUncertain:0,referenceEnd:0,rebaseGap:0,range:0,operationLimit:0}};state.unresolved=0;state.unknownReasons=stats.reasons;state.heuristicInterior=0;const painted=await r.recolor(token,iter);if(!painted||token!==state.token)return null;const frontier={stats,iter,minimumIter:iter,rounds:0,processed:0,escaped:0,converged:true,finiteBudgetComplete:true,policy:'exact-interior-proof',membershipCertified:true,applied:true};state.frameView=snap;state.fieldView={...snap,iter,frontier,w:canvas.width,h:canvas.height,fastExtended:false,backend:'exact-interior',referenceKey:'',referenceReused:false,complete:true,membershipCertified:true};r.captureColorSource(snap,state.colorAuto||state.recolorPending);r.presentFrame({scaleX:1,scaleY:1,offsetX:0,offsetY:0});return{decision,referenceReused:false,referencePixel:null,certified}}
let fastCtx=null,referencePixel=null,referenceReused=false;if(fastExtended){const reuse=reusableFastReference(snap,iter,canvas.width,canvas.height);if(reuse){fastCtx=reuse.ctx;referencePixel=reuse.pixel;referenceReused=true}else{fastCtx=await fastRefs.request(snap,iter,canvas.width,canvas.height);if(token!==state.token)return null;referencePixel=referencePixelForSource(fastCtx.source,snap,canvas.width,canvas.height)}}
- const deferColor=!!(r.historyReady&&canUseStableReprojection());state.deferNumericPublish=deferColor;const ok=await r.computeFrame(snap,iter,token,referencePixel,fastExtended,fastCtx,preserveInterior,!!historySeed,deferColor);if(!ok||token!==state.token){state.deferNumericPublish=false;return null}if(!deferColor&&!await presentProvisional(r,snap,iter,token,true)){state.deferNumericPublish=false;return null}
- let stats=await r.readUnresolvedStats();if(token!==state.token)return null;if(fastExtended){const initialFailed=numericalFailureCount(stats);if(initialFailed>0){const ordered=await recoverNumericalCostOrdered(r,snap,iter,token,stats);if(!ordered||token!==state.token){state.deferNumericPublish=false;return null}stats=ordered.stats}state.unresolved=stats.total;state.unknownReasons=stats.reasons;state.heuristicInterior=stats.heuristicInterior||0;if(initialFailed>0&&!deferColor){const painted=await r.recolor(token,iter);if(!painted||token!==state.token){state.deferNumericPublish=false;return null}}}else{state.unresolved=stats.total;state.unknownReasons=stats.reasons}
+ const deferColor=!!(r.historyReady&&canUseStableReprojection()),deferPrimaryColor=deferColor||fastExtended;state.deferNumericPublish=deferColor;const ok=await r.computeFrame(snap,iter,token,referencePixel,fastExtended,fastCtx,preserveInterior,!!historySeed,deferPrimaryColor);if(!ok||token!==state.token){state.deferNumericPublish=false;return null}if(!fastExtended&&!deferColor&&!await presentProvisional(r,snap,iter,token,true)){state.deferNumericPublish=false;return null}
+ let stats=await r.readUnresolvedStats();if(token!==state.token)return null;if(fastExtended){const initialFailed=numericalFailureCount(stats);if(initialFailed>0){const ordered=await recoverNumericalCostOrdered(r,snap,iter,token,stats);if(!ordered||token!==state.token){state.deferNumericPublish=false;return null}stats=ordered.stats}state.unresolved=stats.total;state.unknownReasons=stats.reasons;state.heuristicInterior=stats.heuristicInterior||0;if(!deferColor&&!await presentProvisional(r,snap,iter,token,true)){state.deferNumericPublish=false;return null}}else{state.unresolved=stats.total;state.unknownReasons=stats.reasons}
const frontier=await refinePixelFrontier(r,snap,iter,token,{total:state.unresolved,reasons:{...(state.unknownReasons||{})}},certified);if(!frontier||token!==state.token){state.deferNumericPublish=false;return null}const effectiveIter=frontier.iter;if(!frontier.converged)throw new Error('全画素反復が安全上限 '+PIXEL_FRONTIER_MAX_ITER+' までに収束しなかったためframeを公開しません');if(frontier.applied&&!deferColor){const painted=await r.recolor(token,effectiveIter);if(!painted||token!==state.token){state.deferNumericPublish=false;return null}}if(deferColor){const painted=await r.recolor(token,effectiveIter);if(!painted||token!==state.token){state.deferNumericPublish=false;return null}}
const remaining=numericalFailureCount({reasons:state.unknownReasons||{}}),unprocessed=Math.max(0,state.unresolved-remaining-(state.unknownReasons?.operationLimit||0));if(unprocessed)throw new Error('未計算画素が '+unprocessed+' px 残っています');if(remaining>0)throw new Error('画素精度補修後も数値未確定が '+remaining+' px 残ったためframeを公開しません');state.frameView=snap;state.fieldView={...snap,iter:effectiveIter,frontier,w:canvas.width,h:canvas.height,fastExtended,backend:frontier.applied?'pixel-frontier-'+decision.backend:decision.backend,referenceKey:fastCtx?.key||'',referenceReused,complete:true,membershipCertified:frontier.membershipCertified};r.captureColorSource(snap,state.colorAuto||state.recolorPending);r.presentFrame({scaleX:1,scaleY:1,offsetX:0,offsetY:0});state.deferNumericPublish=false;return{decision,referenceReused,referencePixel,certified}
}

View file

@ -0,0 +1,381 @@
{
"date": "2026-09-29T10:16:09.784Z",
"periodicity": {
"w": 96,
"h": 64,
"maxIter": 8192,
"oracleIter": 65536,
"rows": [
{
"view": "overview",
"productionBackend": "direct",
"pixels": 6144,
"baseWork": 884420,
"heuristicWork": 133404,
"workReduction": 0.849162162773343,
"candidates": 96,
"falseCandidates": 0,
"baseLimit": 104,
"heuristicLimit": 8,
"diagnosticIfForcedDirect": null,
"ms": 73.54993999999999
},
{
"view": "period3-bulb",
"productionBackend": "direct",
"pixels": 6144,
"baseWork": 50331648,
"heuristicWork": 460156,
"workReduction": 0.9908575216929117,
"candidates": 6144,
"falseCandidates": 0,
"baseLimit": 6144,
"heuristicLimit": 0,
"diagnosticIfForcedDirect": null,
"ms": 2914.8620730000002
},
{
"view": "period3-core",
"productionBackend": "direct",
"pixels": 6144,
"baseWork": 50331648,
"heuristicWork": 442395,
"workReduction": 0.991210401058197,
"candidates": 6144,
"falseCandidates": 0,
"baseLimit": 6144,
"heuristicLimit": 0,
"diagnosticIfForcedDirect": null,
"ms": 2906.390313
},
{
"view": "seahorse-boundary",
"productionBackend": "direct",
"pixels": 6144,
"baseWork": 410111,
"heuristicWork": 410111,
"workReduction": 0,
"candidates": 0,
"falseCandidates": 0,
"baseLimit": 0,
"heuristicLimit": 0,
"diagnosticIfForcedDirect": null,
"ms": 18.368617999999515
},
{
"view": "seahorse-deep",
"productionBackend": "fast-extended",
"pixels": 6144,
"baseWork": 36579905,
"heuristicWork": 6167354,
"workReduction": null,
"candidates": 0,
"falseCandidates": 0,
"baseLimit": 4304,
"heuristicLimit": 4304,
"diagnosticIfForcedDirect": {
"workReduction": 0.8314004916087125,
"candidates": 4125,
"falseCandidates": 12
},
"ms": 2205.721136
},
{
"view": "exterior",
"productionBackend": "direct",
"pixels": 6144,
"baseWork": 686432,
"heuristicWork": 137063,
"workReduction": 0.8003254510279241,
"candidates": 72,
"falseCandidates": 0,
"baseLimit": 79,
"heuristicLimit": 7,
"diagnosticIfForcedDirect": null,
"ms": 38.21316800000022
}
],
"note": "Production DIRECT_F32 rule with two attracting-cycle confirmations. FAST/perturbation periodic early-stop is intentionally disabled."
},
"series": {
"rows": [
{
"span": 0.00001,
"jump": 0,
"errorLog2": 0,
"maxAbsError": 0,
"maxRelativeError": 0,
"refLen": 1200,
"buildMs": 9.239160999999513
},
{
"span": 1e-7,
"jump": 0,
"errorLog2": 0,
"maxAbsError": 0,
"maxRelativeError": 0,
"refLen": 1200,
"buildMs": 6.685975000000326
},
{
"span": 1e-9,
"jump": 0,
"errorLog2": 0,
"maxAbsError": 0,
"maxRelativeError": 0,
"refLen": 1200,
"buildMs": 7.990567000000738
},
{
"span": 1e-11,
"jump": 0,
"errorLog2": 0,
"maxAbsError": 0,
"maxRelativeError": 0,
"refLen": 1200,
"buildMs": 7.234717999999702
}
],
"note": "Production fast-reference worker output. Error compares packed 4th-order series against direct perturbation over 8 screen-domain deltas."
},
"sparseActiveState": {
"rows": [
{
"preset": "fast",
"pixels": 524288,
"activeRatio": 1,
"capacity": 524288,
"oldBytes": 23068672,
"newBytes": 23134208,
"reduction": -0.0028409090909091717
},
{
"preset": "fast",
"pixels": 524288,
"activeRatio": 0.25,
"capacity": 164096,
"oldBytes": 23068672,
"newBytes": 7285760,
"reduction": 0.6841708096590908
},
{
"preset": "fast",
"pixels": 524288,
"activeRatio": 0.1,
"capacity": 65792,
"oldBytes": 23068672,
"newBytes": 2960384,
"reduction": 0.8716708096590909
},
{
"preset": "fast",
"pixels": 524288,
"activeRatio": 0.01,
"capacity": 6810,
"oldBytes": 23068672,
"newBytes": 365176,
"reduction": 0.9841700467196378
},
{
"preset": "standard",
"pixels": 1048576,
"activeRatio": 1,
"capacity": 1048576,
"oldBytes": 46137344,
"newBytes": 46268416,
"reduction": -0.0028409090909091717
},
{
"preset": "standard",
"pixels": 1048576,
"activeRatio": 0.25,
"capacity": 327936,
"oldBytes": 46137344,
"newBytes": 14560256,
"reduction": 0.6844149502840908
},
{
"preset": "standard",
"pixels": 1048576,
"activeRatio": 0.1,
"capacity": 131328,
"oldBytes": 46137344,
"newBytes": 5909504,
"reduction": 0.8719149502840909
},
{
"preset": "standard",
"pixels": 1048576,
"activeRatio": 0.01,
"capacity": 13364,
"oldBytes": 46137344,
"newBytes": 719088,
"reduction": 0.9844141873446378
},
{
"preset": "high",
"pixels": 4194304,
"activeRatio": 1,
"capacity": 4194304,
"oldBytes": 184549376,
"newBytes": 185073664,
"reduction": -0.0028409090909091717
},
{
"preset": "high",
"pixels": 4194304,
"activeRatio": 0.25,
"capacity": 1310976,
"oldBytes": 184549376,
"newBytes": 58207232,
"reduction": 0.6845980557528408
},
{
"preset": "high",
"pixels": 4194304,
"activeRatio": 0.1,
"capacity": 524544,
"oldBytes": 184549376,
"newBytes": 23604224,
"reduction": 0.8720980557528409
},
{
"preset": "high",
"pixels": 4194304,
"activeRatio": 0.01,
"capacity": 52685,
"oldBytes": 184549376,
"newBytes": 2842428,
"reduction": 0.9845980080691251
}
],
"note": "Production allocation: 32 B state + 2x4 B queues + 4 B pixel map per active slot, plus 1-bit membership bitmap. Capacity adds 25% + 256 headroom."
},
"multiReference": {
"maxGuidedPasses": 3,
"w": 64,
"h": 40,
"maxIter": 1200,
"rows": [
{
"view": "seahorse-1e-5",
"grid": 1,
"references": 1,
"total": 2560,
"refEnd": 0,
"mismatchClass": 3,
"mismatchIter": 155,
"mismatchRate": 0.001171875
},
{
"view": "seahorse-1e-5",
"grid": 2,
"references": 5,
"total": 2560,
"refEnd": 0,
"mismatchClass": 3,
"mismatchIter": 155,
"mismatchRate": 0.001171875
},
{
"view": "seahorse-1e-5",
"grid": 3,
"references": 9,
"total": 2560,
"refEnd": 0,
"mismatchClass": 3,
"mismatchIter": 155,
"mismatchRate": 0.001171875
},
{
"view": "seahorse-1e-7",
"grid": 1,
"references": 1,
"total": 2560,
"refEnd": 0,
"mismatchClass": 66,
"mismatchIter": 74,
"mismatchRate": 0.02578125
},
{
"view": "seahorse-1e-7",
"grid": 2,
"references": 5,
"total": 2560,
"refEnd": 0,
"mismatchClass": 37,
"mismatchIter": 41,
"mismatchRate": 0.014453125
},
{
"view": "seahorse-1e-7",
"grid": 3,
"references": 9,
"total": 2560,
"refEnd": 0,
"mismatchClass": 50,
"mismatchIter": 67,
"mismatchRate": 0.01953125
}
],
"note": "CPU f32 perturbation locality surrogate. Production selects up to three additional references from failure clusters instead of fixed grids."
},
"cpuFallback": {
"w": 640,
"h": 360,
"iter": 350,
"threads": 4,
"one": {
"medianMs": 92.0384610000001,
"times": [
92.0384610000001,
92.44781700000021,
93.7905269999992,
88.7301699999989,
99.79122599999937,
82.51707699999861,
82.31785099999979
],
"work": 2658084,
"escaped": 176648,
"operationLimit": 3394,
"heuristicInterior": 1654
},
"multi": {
"medianMs": 35.13209400000051,
"times": [
90.21825200000058,
47.29191700000047,
35.13209400000051,
34.57788200000141,
32.72723099999894,
35.70634799999971,
32.441507999999885
],
"work": 2658084,
"escaped": 176648,
"operationLimit": 3394,
"heuristicInterior": 1654
},
"speedup": 2.6197829540134663,
"workMatch": true,
"escapedMatch": true,
"operationLimitMatch": true,
"heuristicMatch": true,
"note": "Production CPU fallback Worker source executed under node:worker_threads shim."
},
"sourceAudit": {
"heuristicClass": true,
"strictDisablesPeriodicity": true,
"seriesBinding": true,
"compactPixelMap": true,
"multiReferencePasses": true,
"cpuFallback": true
},
"environment": {
"node": "v22.16.0",
"cpus": 5,
"platform": "linux",
"arch": "x64"
}
}

View file

@ -0,0 +1,68 @@
{
"view": {
"bits": 273,
"re": "-15958919693715511752069626580195447581643016686947107473855747766272418724092005269",
"im": "3884297345146583343999140717464554120497735725078805729405733239132420430333375719",
"span": "35679807704506851074208413824041445358585910697267232113437882789120793",
"width": 1108,
"height": 1582,
"iter": 1536
},
"before": {
"sha256": "e69b91ead027927d977a065d27d9e27629110122df20aa470eab877e23f77ac7",
"targetBits": 96,
"refLen": 1285,
"selectionEscape": 1285,
"referencePixel": {
"x": 69.5,
"y": 922.4999999999999
},
"series": {
"jump": 773,
"coeffs": [
40452732,
196035184,
-3191889952015122400,
2277982484077478000,
-7.321407650201077e+27,
3.7598990810493897e+28,
-2.345861669341488e+38,
2.300172499347822e+38
],
"errorLog2": -27.69705218182358
},
"nonFiniteSeriesStartSamples": 290,
"seriesSampleCount": 1024
},
"after": {
"sha256": "c53c0dc4cb9fa2d2046aaa12ef03cc79c61a710b0c8db36dea1a87be2d9aedd8",
"targetBits": 96,
"refLen": 1285,
"selectionEscape": 1285,
"referencePixel": {
"x": 69.5,
"y": 922.4999999999999
},
"series": {
"jump": 0,
"coeffs": [
0,
0,
0,
0,
0,
0,
0,
0
],
"errorLog2": 0
},
"nonFiniteSeriesStartSamples": 0,
"seriesSampleCount": 1024
},
"checks": {
"baselineSeriesActive": true,
"baselineHasNonFiniteSeriesStarts": true,
"patchedSeriesDisabled": true
}
}

View file

@ -1,4 +1,4 @@
import {clamp} from './units.js'; import {clamp} from './units.js?v=45';
export class ClimateProvider{ export class ClimateProvider{
constructor({meanTemperatureC=15,amplitudeC=10,phaseDay=205}={}){this.meanTemperatureC=meanTemperatureC;this.amplitudeC=amplitudeC;this.phaseDay=phaseDay;this.series=new Map} constructor({meanTemperatureC=15,amplitudeC=10,phaseDay=205}={}){this.meanTemperatureC=meanTemperatureC;this.amplitudeC=amplitudeC;this.phaseDay=phaseDay;this.series=new Map}

View file

@ -1,4 +1,4 @@
import {annualSurvivalToDailyHazard,rateToProbability,clamp} from './units.js'; import {annualSurvivalToDailyHazard,rateToProbability,clamp} from './units.js?v=45';
// Life-history fallbacks are simulator calibration values, not universal biological constants. // Life-history fallbacks are simulator calibration values, not universal biological constants.
export const ROLE_DEMOGRAPHY = Object.freeze({ export const ROLE_DEMOGRAPHY = Object.freeze({
@ -10,8 +10,8 @@ export const ROLE_DEMOGRAPHY = Object.freeze({
export function backgroundHazardPerDay(role){return annualSurvivalToDailyHazard((ROLE_DEMOGRAPHY[role]||ROLE_DEMOGRAPHY['primary-consumer']).annualSurvival)} export function backgroundHazardPerDay(role){return annualSurvivalToDailyHazard((ROLE_DEMOGRAPHY[role]||ROLE_DEMOGRAPHY['primary-consumer']).annualSurvival)}
export function mortalityOccurs(hazardPerDay,dtDays,random){return random()<rateToProbability(hazardPerDay,dtDays)} export function mortalityOccurs(hazardPerDay,dtDays,random){return random()<rateToProbability(hazardPerDay,dtDays)}
export function reproductionConfig(role){return ROLE_DEMOGRAPHY[role]||ROLE_DEMOGRAPHY['primary-consumer']} export function reproductionConfig(role){return ROLE_DEMOGRAPHY[role]||ROLE_DEMOGRAPHY['primary-consumer']}
export function inBreedingSeason(day,role){ export function inBreedingSeason(day,role,phaseDays=0){
const doy=((day%365)+365)%365; const doy=(((day-phaseDays)%365)+365)%365;
if(role==='tertiary-consumer')return doy>=60&&doy<=180; if(role==='tertiary-consumer')return doy>=60&&doy<=180;
if(role==='secondary-consumer')return (doy>=60&&doy<=165)||(doy>=245&&doy<=300); if(role==='secondary-consumer')return (doy>=60&&doy<=165)||(doy>=245&&doy<=300);
if(role==='detritivore')return (doy>=35&&doy<=175)||(doy>=220&&doy<=315); if(role==='detritivore')return (doy>=35&&doy<=175)||(doy>=220&&doy<=315);

View file

@ -1,4 +1,4 @@
import {clamp,rateToProbability} from './units.js'; import {clamp,rateToProbability} from './units.js?v=45';
export const TROPHIC_ROLES = Object.freeze(['primary-consumer','secondary-consumer','tertiary-consumer','detritivore']); export const TROPHIC_ROLES = Object.freeze(['primary-consumer','secondary-consumer','tertiary-consumer','detritivore']);
export const RESOURCE_ROLES = Object.freeze(['producer','zooplankton','primary-consumer','secondary-consumer','tertiary-consumer','detritus']); export const RESOURCE_ROLES = Object.freeze(['producer','zooplankton','primary-consumer','secondary-consumer','tertiary-consumer','detritus']);
@ -62,12 +62,19 @@ export function functionalResponseRatePerDay(params,resourceDensity,denominatorT
return numerator/Math.max(1e-12,denominator); return numerator/Math.max(1e-12,denominator);
} }
export function producerIntakeKgPerDay(massKg,producerKgPerM2,edge,temperatureC=20,profile={feeding:{preferredPredatorPreyMassRatio:35,massRatioSigma:1.05}}){ export function fieldResourceIntakeKgPerDay(massKg,resourceDensityKgPerM2,edge,resourceRole='producer'){
if(!edge)return 0; if(!edge)return 0;
const resourceMassEquivalent=Math.max(1e-5,producerKgPerM2*.01),params=rallFeedingParameters(edge,massKg,resourceMassEquivalent,temperatureC,profile),q=edge.functionalResponse===3?2:1; const mass=Math.max(massKg,.001),density=Math.max(0,resourceDensityKgPerM2),q=edge.functionalResponse===3?2:1;
const raw=functionalResponseRatePerDay(params,producerKgPerM2,[{...params,resourceDensity:producerKgPerM2,q}],q); // A biomass field has no single prey body mass. Do not feed field density into
const maxIntake=Math.max(.002,.16*Math.pow(Math.max(massKg,.001),.78))*edge.preferenceWeight; // predator:prey body-mass scaling. Use a density-based Holling saturation instead.
return Math.min(maxIntake,raw*Math.max(.03,Math.pow(massKg,.58))); const halfSaturation=resourceRole==='zooplankton'?.012:.08;
const x=Math.pow(density,q),half=Math.pow(halfSaturation,q),saturation=x/Math.max(1e-12,half+x);
const coefficient=resourceRole==='zooplankton'?.12:.16;
const maxIntakeKgPerDay=Math.max(.002,coefficient*Math.pow(mass,.78))*edge.preferenceWeight;
return maxIntakeKgPerDay*saturation;
}
export function producerIntakeKgPerDay(massKg,producerKgPerM2,edge){
return fieldResourceIntakeKgPerDay(massKg,producerKgPerM2,edge,'producer');
} }
export function attackProbabilityPerStep(ratePerDay,dtDays){return rateToProbability(ratePerDay,dtDays)} export function attackProbabilityPerStep(ratePerDay,dtDays){return rateToProbability(ratePerDay,dtDays)}

View file

@ -1,4 +1,4 @@
import {KELVIN_OFFSET, clamp} from './units.js'; import {KELVIN_OFFSET, clamp} from './units.js?v=45';
const B0 = Object.freeze({ectotherm: 42, endotherm: 310}); // kJ d^-1 at 1 kg, simulator calibration const B0 = Object.freeze({ectotherm: 42, endotherm: 310}); // kJ d^-1 at 1 kg, simulator calibration
const ACTIVATION_EV = 0.65; const ACTIVATION_EV = 0.65;

View file

@ -3,9 +3,9 @@ const MODE = Object.freeze({
// v = a * M^b * (1 - exp(-h * M^i)), v in km/h, M in kg. // v = a * M^b * (1 - exp(-h * M^i)), v in km/h, M in kg.
// Flying, running and swimming coefficients are the published Supplementary // Flying, running and swimming coefficients are the published Supplementary
// Table 4 fits for the time-dependent maximum-speed model. // Table 4 fits for the time-dependent maximum-speed model.
running:{a:25.5,b:.26,h:22,i:-.60,routineFraction:.34,homeRangeFactor:1,calibration:'Hirt-2017'}, running:{a:25.5,b:.26,h:22,i:-.60,routineFraction:.34,calibration:'Hirt-2017'},
swimming:{a:11.2,b:.36,h:19.5,i:-.56,routineFraction:.30,homeRangeFactor:1.25,calibration:'Hirt-2017'}, swimming:{a:11.2,b:.36,h:19.5,i:-.56,routineFraction:.30,calibration:'Hirt-2017'},
flying:{a:142.8,b:.24,h:2.4,i:-.72,routineFraction:.42,homeRangeFactor:1.8,calibration:'Hirt-2017'}, flying:{a:142.8,b:.24,h:2.4,i:-.72,routineFraction:.42,calibration:'Hirt-2017'},
}); });
export const locomotionModes=Object.freeze(Object.keys(MODE)); export const locomotionModes=Object.freeze(Object.keys(MODE));
export const normalizeLocomotionMode=mode=>MODE[mode]?mode:'running'; export const normalizeLocomotionMode=mode=>MODE[mode]?mode:'running';
@ -25,13 +25,17 @@ export function foragingSpeedMPerDay(massKg,dailyMovementBudgetM,mode='running')
export function escapeSpeedMPerDay(massKg,dailyMovementBudgetM,mode='running'){ export function escapeSpeedMPerDay(massKg,dailyMovementBudgetM,mode='running'){
return Math.min(maximumSpeedMPerDay(massKg,mode),routineTravelSpeedMPerDay(massKg,dailyMovementBudgetM,mode)*2.4); return Math.min(maximumSpeedMPerDay(massKg,mode),routineTravelSpeedMPerDay(massKg,dailyMovementBudgetM,mode)*2.4);
} }
export function dailyMovementBudgetM(massKg,geneBudgetM,mode='running'){ export function dailyMovementBudgetM(massKg,geneBudgetM){
const c=MODE[normalizeLocomotionMode(mode)]; // Daily travel capacity is an independent movement trait.
return Math.max(25,geneBudgetM)*(0.75+0.25*Math.pow(Math.max(massKg,.01)/3,.08))*(.9+.1*c.homeRangeFactor); return Math.max(25,geneBudgetM)*(0.75+0.25*Math.pow(Math.max(massKg,.01)/3,.08));
} }
export function homeRangeRadiusM(massKg,mode='running'){ export function sampleRoamingDistanceM(random,dailyBudgetM){
const c=MODE[normalizeLocomotionMode(mode)];return Math.min(1500,Math.max(90,170*Math.pow(Math.max(massKg,.01),.22)*c.homeRangeFactor)); // Persistent free-roaming waypoint: typically ~0.35-1.5 days of travel,
// capped to avoid asking a local navigator to cross the entire world at once.
const budget=Math.max(80,dailyBudgetM),scale=Math.max(120,budget*.72),draw=-Math.log(Math.max(1e-9,1-random()))*scale;
return Math.min(Math.max(90,draw),Math.min(1800,budget*1.8));
} }
export function sampleDispersalDistanceM(random,massKg,mode='running'){ export function limitSpeedForArrival(speedMPerDay,distanceM,arrivalRadiusM,dtDays){
const scale=homeRangeRadiusM(massKg,mode)*.55;return Math.min(scale*3,-Math.log(Math.max(1e-9,1-random()))*scale); const available=Math.max(0,Math.max(0,distanceM)-Math.max(0,arrivalRadiusM));
return Math.min(Math.max(0,speedMPerDay),dtDays>0?available/dtDays:0);
} }

View file

@ -1,11 +1,11 @@
export const TERRESTRIAL = Object.freeze({ export const TERRESTRIAL = Object.freeze({
id:'terrestrial',type:'terrestrial',producer:{rMaxPerDay:.03,carryingCapacityKgPerM2:.5},feeding:{preferredPredatorPreyMassRatio:35,massRatioSigma:1.05},environment:{light:.82,nutrientN:.78,nutrientP:.78,dissolvedOxygen:1},movement:{homeRangeMultiplier:1} id:'terrestrial',type:'terrestrial',producer:{rMaxPerDay:.03,carryingCapacityKgPerM2:.5},feeding:{preferredPredatorPreyMassRatio:35,massRatioSigma:1.05},environment:{light:.82,nutrientN:.78,nutrientP:.78,dissolvedOxygen:1}
}); });
export const FRESHWATER = Object.freeze({ export const FRESHWATER = Object.freeze({
id:'freshwater',type:'freshwater',producer:{carryingCapacityKgPerM2:.18},feeding:{preferredPredatorPreyMassRatio:80,massRatioSigma:1.12},environment:{light:.72,nutrientN:.58,nutrientP:.52,dissolvedOxygen:.82},movement:{homeRangeMultiplier:1.15} id:'freshwater',type:'freshwater',producer:{carryingCapacityKgPerM2:.18},feeding:{preferredPredatorPreyMassRatio:80,massRatioSigma:1.12},environment:{light:.72,nutrientN:.58,nutrientP:.52,dissolvedOxygen:.82}
}); });
export const MARINE = Object.freeze({ export const MARINE = Object.freeze({
id:'marine',type:'marine',producer:{carryingCapacityKgPerM2:.14},feeding:{preferredPredatorPreyMassRatio:55,massRatioSigma:1.08},environment:{light:.76,nutrientN:.54,nutrientP:.48,dissolvedOxygen:.9},movement:{homeRangeMultiplier:1.35} id:'marine',type:'marine',producer:{carryingCapacityKgPerM2:.14},feeding:{preferredPredatorPreyMassRatio:55,massRatioSigma:1.08},environment:{light:.76,nutrientN:.54,nutrientP:.48,dissolvedOxygen:.9}
}); });
export const PROFILES=Object.freeze({terrestrial:TERRESTRIAL,freshwater:FRESHWATER,marine:MARINE}); export const PROFILES=Object.freeze({terrestrial:TERRESTRIAL,freshwater:FRESHWATER,marine:MARINE});
export function getProfile(id){return PROFILES[id]||null} export function getProfile(id){return PROFILES[id]||null}

View file

@ -1,4 +1,4 @@
import {clamp} from './units.js'; import {clamp} from './units.js?v=45';
export class ResourceField{ export class ResourceField{
constructor({id,role,cellCount,energyDensityKJPerKg,representation='field'}){this.id=id;this.role=role;this.energyDensityKJPerKg=energyDensityKJPerKg;this.representation=representation;this.biomass=new Float32Array(cellCount)} constructor({id,role,cellCount,energyDensityKJPerKg,representation='field'}){this.id=id;this.role=role;this.energyDensityKJPerKg=energyDensityKJPerKg;this.representation=representation;this.biomass=new Float32Array(cellCount)}

View file

@ -1,3 +1,3 @@
import {PROFILES} from './profiles.js'; import {PROFILES} from './profiles.js?v=45';
export function validateProfileId(id){if(!PROFILES[id])throw new RangeError(`unknown environment profile: ${id}`);return id} export function validateProfileId(id){if(!PROFILES[id])throw new RangeError(`unknown environment profile: ${id}`);return id}

103
engine.js
View file

@ -1,18 +1,18 @@
import {RockField} from './obstacles.js?v=30'; import {RockField} from './obstacles.js?v=45';
import {waterDistance as shorelineDistance,inWater,habitat} from './water.js?v=30'; import {waterDistance as shorelineDistance,inWater,habitat} from './water.js?v=45';
import {W,H,GW,GH,CELL} from './world-size.js'; import {W,H,GW,GH,CELL} from './world-size.js?v=45';
import {sizeSpeedBoost,staminaLimit,recoveryRate,locomotionCost,sprintCost,thermalFitness,metabolismKJPerDay,decayRate} from './model.js'; import {sizeSpeedBoost,staminaLimit,staminaRecoveryFractionPerDay,sprintStaminaFractionPerDay,locomotionCost,thermalFitness,metabolismKJPerDay,decayRate} from './model.js?v=45';
import {ClimateProvider} from './ecology/climate.js'; import {ClimateProvider} from './ecology/climate.js?v=45';
import {TERRESTRIAL,getProfile} from './ecology/profiles.js'; import {TERRESTRIAL,getProfile} from './ecology/profiles.js?v=45';
import {ResourceField,producerCapacity,producerGrowthPerDay,updateProducerBiomass,updateZooplanktonCohort} from './ecology/resources.js'; import {ResourceField,producerCapacity,producerGrowthPerDay,updateProducerBiomass,updateZooplanktonCohort} from './ecology/resources.js?v=45';
import {interactionEdge,bodyMassPreference,producerIntakeKgPerDay,rallFeedingParameters,functionalResponseRatePerDay,attackProbabilityPerStep,roleCode} from './ecology/feeding.js'; import {interactionEdge,bodyMassPreference,producerIntakeKgPerDay,fieldResourceIntakeKgPerDay,rallFeedingParameters,functionalResponseRatePerDay,attackProbabilityPerStep,roleCode} from './ecology/feeding.js?v=45';
import {backgroundHazardPerDay,reproductionConfig,inBreedingSeason,offspringEnergyShare,reproductionThreshold,energyFraction,initialStructuralMassKg,energyLimitedGrowth} from './ecology/demography.js'; import {backgroundHazardPerDay,reproductionConfig,inBreedingSeason,offspringEnergyShare,reproductionThreshold,energyFraction,initialStructuralMassKg,energyLimitedGrowth} from './ecology/demography.js?v=45';
import {starvationHazardPerDay,thermalHazardPerDay} from './ecology/metabolism.js'; import {starvationHazardPerDay,thermalHazardPerDay} from './ecology/metabolism.js?v=45';
import {rateToProbability} from './ecology/units.js'; import {rateToProbability} from './ecology/units.js?v=45';
import {routineTravelSpeedMPerDay,foragingSpeedMPerDay,escapeSpeedMPerDay,maximumSpeedMPerDay,dailyMovementBudgetM,homeRangeRadiusM,sampleDispersalDistanceM} from './ecology/movement.js'; import {routineTravelSpeedMPerDay,foragingSpeedMPerDay,escapeSpeedMPerDay,maximumSpeedMPerDay,dailyMovementBudgetM,sampleRoamingDistanceM,limitSpeedForArrival} from './ecology/movement.js?v=45';
import {validateProfileId} from './ecology/schema.js'; import {validateProfileId} from './ecology/schema.js?v=45';
export {sizeSpeedBoost} from './model.js'; export {sizeSpeedBoost} from './model.js?v=45';
export {W,H,GW,GH,CELL} from './world-size.js'; export {W,H,GW,GH,CELL} from './world-size.js?v=45';
export const DT=1/24; export const DT=1/24;
export const CANOPY_BLOCK_MASS=8; export const CANOPY_BLOCK_MASS=8;
export const traits={bodySize:[.02,100],moveSpeed:[50,5000],staminaCapacity:[5,100],staminaRecovery:[.1,7],visionRange:[10,500],perceptionAbility:[.2,2.5],preferredTemperature:[-15,45],temperatureTolerance:[3,30],waterAffinity:[0,1],offspringSize:[.05,.5],maturityAge:[15,365],lifespan:[120,3650],sociability:[0,1],fear:[.05,2],aggression:[0,1]}; export const traits={bodySize:[.02,100],moveSpeed:[50,5000],staminaCapacity:[5,100],staminaRecovery:[.1,7],visionRange:[10,500],perceptionAbility:[.2,2.5],preferredTemperature:[-15,45],temperatureTolerance:[3,30],waterAffinity:[0,1],offspringSize:[.05,.5],maturityAge:[15,365],lifespan:[120,3650],sociability:[0,1],fear:[.05,2],aggression:[0,1]};
@ -47,7 +47,7 @@ export class World{
setAquaticProfile(id){this.aquaticProfile=getProfile(validateProfileId(id));this.rebuildEnvironment()} setAquaticProfile(id){this.aquaticProfile=getProfile(validateProfileId(id));this.rebuildEnvironment()}
edge(consumerRole,resourceRole){return interactionEdge(consumerRole,resourceRole)} edge(consumerRole,resourceRole){return interactionEdge(consumerRole,resourceRole)}
log(text){this.events.unshift({time:this.time,text});if(this.events.length>80)this.events.pop()} log(text){this.events.unshift({time:this.time,text});if(this.events.length>80)this.events.pop()}
addSpecies(name,g,color,parent=null,meta={}){const id=this.nextSpeciesId++,ancestor=this.species[parent];name='種 '+String(id+1).padStart(3,'0');this.species[id]={id,name,g:Object.freeze({...g}),color:ancestor?descendantColor(ancestor.color,id):color,morph:ancestor?ancestor.morph:id%4,parent,origin:this.time,pending:0,trophicRole:meta.trophicRole||ancestor?.trophicRole||'primary-consumer',physiology:meta.physiology||ancestor?.physiology||'ectotherm',locomotionMode:meta.locomotionMode||ancestor?.locomotionMode||(g.waterAffinity>.55?'swimming':'running')};return id} addSpecies(name,g,color,parent=null,meta={}){const id=this.nextSpeciesId++,ancestor=this.species[parent];name='種 '+String(id+1).padStart(3,'0');this.species[id]={id,name,g:Object.freeze({...g}),color:ancestor?descendantColor(ancestor.color,id):color,morph:ancestor?ancestor.morph:id%4,parent,origin:this.time,pending:0,trophicRole:meta.trophicRole||ancestor?.trophicRole||'primary-consumer',physiology:meta.physiology||ancestor?.physiology||'ectotherm',locomotionMode:meta.locomotionMode||ancestor?.locomotionMode||(g.waterAffinity>.55?'swimming':'running'),breedingPhaseDays:meta.breedingPhaseDays??ancestor?.breedingPhaseDays??((id*137.508)%365)};return id}
// Separate terrain RNG keeps terrain generation reproducible without consuming animal RNG. // Separate terrain RNG keeps terrain generation reproducible without consuming animal RNG.
generateWater(seed){ generateWater(seed){
// Keep generation cheap: radii are scaled by sqrt(2), which gives roughly twice the // Keep generation cheap: radii are scaled by sqrt(2), which gives roughly twice the
@ -131,9 +131,13 @@ export class World{
detectionProbability(a,b,d=Math.hypot(b.x-a.x,b.y-a.y)){const visible=clamp(Math.sqrt(this.biomass(b))*a.g.perceptionAbility*35/(d+15)*(1+Math.hypot(b.vx,b.vy)/80),.015,1);return visible*(1-this.concealmentAt(b.x,b.y))} detectionProbability(a,b,d=Math.hypot(b.x-a.x,b.y-a.y)){const visible=clamp(Math.sqrt(this.biomass(b))*a.g.perceptionAbility*35/(d+15)*(1+Math.hypot(b.vx,b.vy)/80),.015,1);return visible*(1-this.concealmentAt(b.x,b.y))}
staminaCapacity(a){return staminaLimit(this.biomass(a),a.g.staminaCapacity)} staminaCapacity(a){return staminaLimit(this.biomass(a),a.g.staminaCapacity)}
waterDistance(x,y){return shorelineDistance(this.water,x,y)} waterDistance(x,y){return shorelineDistance(this.water,x,y)}
waterAllowed(a,x,y){const h=a.habitat||habitat(a.g.waterAffinity);return h==='両棲'||(h==='水棲')===inWater(this.water,x,y)} habitatClear(a,x,y,clearance=0){const h=a.habitat||habitat(a.g.waterAffinity);if(h==='両棲')return true;if(clearance<=0)return(h==='水棲')===inWater(this.water,x,y);const d=this.waterDistance(x,y);return h==='水棲'?d<=-clearance:d>=clearance}
findHabitat(a,x,y){if(this.waterAllowed(a,x,y)&&this.fieldFor(a).free(x,y,this.radius(a)))return {x,y};for(let radius=10;radius<=540;radius+=12)for(let j=0;j<16;j++){const t=j*Math.PI/8+radius*.037,px=clamp(x+Math.cos(t)*radius,5,W-5),py=clamp(y+Math.sin(t)*radius,5,H-5);if(this.waterAllowed(a,px,py)&&this.fieldFor(a).free(px,py,this.radius(a)))return {x:px,y:py}}if((a.habitat||habitat(a.g.waterAffinity))==='水棲')for(const w of this.water){if(this.fieldFor(a).free(w.x,w.y,this.radius(a)))return {x:w.x,y:w.y}}return null} waterAllowed(a,x,y){return this.habitatClear(a,x,y,0)}
moveHabitat(a,x,y,tx,ty,out=null){if(!this.waterAllowed(a,x,y)){const p=this.findHabitat(a,x,y);return outputPosition(out,p?p.x:x,p?p.y:y)}let allowed=this.waterAllowed(a,tx,ty),firstBlocked=1;if(allowed&&(tx-x)**2+(ty-y)**2>100)for(let j=1;j<8;j++){const q=j/8;if(!this.waterAllowed(a,x+(tx-x)*q,y+(ty-y)*q)){allowed=false;firstBlocked=q;break}}if(allowed)return outputPosition(out,tx,ty);let lo=0,hi=firstBlocked;for(let i=0;i<9;i++){const mid=(lo+hi)/2;if(this.waterAllowed(a,x+(tx-x)*mid,y+(ty-y)*mid))lo=mid;else hi=mid}return outputPosition(out,x+(tx-x)*Math.max(0,lo-.006),y+(ty-y)*Math.max(0,lo-.006))} habitatPathAllowed(a,x,y,tx,ty,clearance=0){const h=a.habitat||habitat(a.g.waterAffinity);if(h==='両棲')return true;const distance=Math.hypot(tx-x,ty-y),steps=Math.max(2,Math.min(10,Math.ceil(distance/24)));for(let j=1;j<=steps;j++){const q=j/steps;if(!this.habitatClear(a,x+(tx-x)*q,y+(ty-y)*q,clearance))return false}return true}
findHabitat(a,x,y,clearance=0){const field=this.fieldFor(a),r=this.radius(a),ok=(px,py)=>this.habitatClear(a,px,py,clearance)&&field.free(px,py,r);if(ok(x,y))return {x,y};for(let radius=10;radius<=720;radius+=12)for(let j=0;j<20;j++){const t=j*Math.PI/10+radius*.037,px=clamp(x+Math.cos(t)*radius,5,W-5),py=clamp(y+Math.sin(t)*radius,5,H-5);if(ok(px,py))return {x:px,y:py}}if((a.habitat||habitat(a.g.waterAffinity))==='水棲')for(const w of this.water){if(ok(w.x,w.y))return {x:w.x,y:w.y}}return null}
steerHabitat(a,x,y,dx,dy,lookahead,out=null){const h=a.habitat||habitat(a.g.waterAffinity);if(h==='両棲')return outputPosition(out,dx,dy);const n=Math.hypot(dx,dy)||1,ux=dx/n,uy=dy/n,probe=Math.max(24,Math.min(90,lookahead||48)),clearance=this.radius(a)+4,sign=h==='水棲'?-1:1,current=sign*this.waterDistance(x,y);if(current>clearance+probe+8)return outputPosition(out,dx,dy);const tx=clamp(x+ux*probe,3,W-3),ty=clamp(y+uy*probe,3,H-3);if(this.habitatPathAllowed(a,x,y,tx,ty,clearance))return outputPosition(out,dx,dy);const eps=8,score=(px,py)=>sign*this.waterDistance(px,py),gx=(score(clamp(x+eps,0,W),y)-score(clamp(x-eps,0,W),y))/(2*eps),gy=(score(x,clamp(y+eps,0,H))-score(x,clamp(y-eps,0,H)))/(2*eps),gn=Math.hypot(gx,gy);if(gn<1e-6)return outputPosition(out,-ux,-uy);const nx=gx/gn,ny=gy/gn,dot=ux*nx+uy*ny,outward=Math.min(0,dot),inwardBoost=current<clearance+18?Math.min(.55,(clearance+18-current)/36):.08,vx=ux-outward*nx+nx*inwardBoost,vy=uy-outward*ny+ny*inwardBoost,vn=Math.hypot(vx,vy)||1;return outputPosition(out,vx/vn,vy/vn)}
moveHabitat(a,x,y,tx,ty,out=null){if(!this.waterAllowed(a,x,y)){const p=this.findHabitat(a,x,y,this.radius(a)+6);return outputPosition(out,p?p.x:x,p?p.y:y)}let allowed=this.habitatPathAllowed(a,x,y,tx,ty,0),firstBlocked=1;if(!allowed){for(let j=1;j<=12;j++){const q=j/12;if(!this.waterAllowed(a,x+(tx-x)*q,y+(ty-y)*q)){firstBlocked=q;break}}}if(allowed)return outputPosition(out,tx,ty);let lo=0,hi=firstBlocked;for(let i=0;i<10;i++){const mid=(lo+hi)/2;if(this.waterAllowed(a,x+(tx-x)*mid,y+(ty-y)*mid))lo=mid;else hi=mid}return outputPosition(out,x+(tx-x)*Math.max(0,lo-.01),y+(ty-y)*Math.max(0,lo-.01))}
chooseRoamTarget(a){const mode=this.species[a.sid]?.locomotionMode||'running',budget=dailyMovementBudgetM(this.biomass(a),a.g.moveSpeed,mode),baseDistance=sampleRoamingDistanceM(()=>this.rand(),budget),baseAngle=this.rand()*Math.PI*2,field=this.fieldFor(a),r=this.radius(a);for(const factor of [1,.72,.5,.34]){const distance=baseDistance*factor;for(let j=0;j<12;j++){const sign=j%2?-(j+1)/2:(j/2),angle=baseAngle+sign*Math.PI/6,x=clamp(a.x+Math.cos(angle)*distance,5,W-5),y=clamp(a.y+Math.sin(angle)*distance,5,H-5);if(!this.habitatClear(a,x,y,r+6)||!field.free(x,y,r)||!this.habitatPathAllowed(a,a.x,a.y,x,y,r+6))continue;return {x,y}}}return null}
rebuildEnvironment(){ rebuildEnvironment(){
this.updateShade();for(let j=0;j<GH;j++)for(let i=0;i<GW;i++){const n=j*GW+i,d=this.waterDistance((i+.5)*CELL,(j+.5)*CELL),wet=d<0,profile=wet?this.aquaticProfile:TERRESTRIAL;this.wet[n]=wet?1:0;this.moisture[n]=wet?1:.12+.88*Math.exp(-Math.max(0,d)/210);this.light[n]=Math.max(.08,profile.environment.light*(1-.55*this.shade[n]));this.nutrientN[n]=profile.environment.nutrientN;this.nutrientP[n]=profile.environment.nutrientP;this.dissolvedOxygen[n]=profile.environment.dissolvedOxygen;this.rockMask[n]=this.rockField.freePoint((i+.5)*CELL,(j+.5)*CELL)?0:1;if(this.rockMask[n]){this.plants[n]=0;this.zooplankton[n]=0}else if(!wet)this.zooplankton[n]=0} this.updateShade();for(let j=0;j<GH;j++)for(let i=0;i<GW;i++){const n=j*GW+i,d=this.waterDistance((i+.5)*CELL,(j+.5)*CELL),wet=d<0,profile=wet?this.aquaticProfile:TERRESTRIAL;this.wet[n]=wet?1:0;this.moisture[n]=wet?1:.12+.88*Math.exp(-Math.max(0,d)/210);this.light[n]=Math.max(.08,profile.environment.light*(1-.55*this.shade[n]));this.nutrientN[n]=profile.environment.nutrientN;this.nutrientP[n]=profile.environment.nutrientP;this.dissolvedOxygen[n]=profile.environment.dissolvedOxygen;this.rockMask[n]=this.rockField.freePoint((i+.5)*CELL,(j+.5)*CELL)?0:1;if(this.rockMask[n]){this.plants[n]=0;this.zooplankton[n]=0}else if(!wet)this.zooplankton[n]=0}
} }
@ -143,8 +147,8 @@ export class World{
maxEnergy(a){return this.biomass(a)*1200} maxEnergy(a){return this.biomass(a)*1200}
radius(a){return 2.3*Math.sqrt(this.biomass(a))} radius(a){return 2.3*Math.sqrt(this.biomass(a))}
make(g,sid,x,y,age=0,energy=null,parents=[]){ make(g,sid,x,y,age=0,energy=null,parents=[]){
const a={id:this.nextId++,sid,g:this.internGenome(g),x:clamp(x,5,W-5),y:clamp(y,5,H-5),vx:0,vy:0,age,structuralMassKg:initialStructuralMassKg(g.bodySize,g.offspringSize,age,g.maturityAge),energy:0,stamina:0,action:'徘徊',heading:this.rand()*6.28,cooldown:5+this.rand()*20,target:null,dead:false,parents,generation:0,homeX:clamp(x,5,W-5),homeY:clamp(y,5,H-5),movedToday:0,movementDay:Math.floor(this.time),dispersalTarget:null}; const a={id:this.nextId++,sid,g:this.internGenome(g),x:clamp(x,5,W-5),y:clamp(y,5,H-5),vx:0,vy:0,age,structuralMassKg:initialStructuralMassKg(g.bodySize,g.offspringSize,age,g.maturityAge),energy:0,stamina:0,action:'徘徊',heading:this.rand()*6.28,cooldown:5+this.rand()*20,target:null,dead:false,parents,generation:0,movedToday:0,movementDay:Math.floor(this.time),roamTarget:null,turnBias:this.rand()<.5?-1:1};
if(this.rockField.circles.length){const p=this.fieldFor(a).findFree(a.x,a.y,this.radius(a));if(!p)return null;a.x=p.x;a.y=p.y}const h=this.findHabitat(a,a.x,a.y);if(!h)return null;a.x=h.x;a.y=h.y;a.homeX=h.x;a.homeY=h.y;a.habitat=habitat(a.g.waterAffinity);a.ability=this.genomeRecords.get(a.g).ability;a.adultSpeedBoost=sizeSpeedBoost(a.g.bodySize);a.adultMassPower=Math.pow(a.g.bodySize,.75);a.energy=energy??this.maxEnergy(a)*.62;a.stamina=this.staminaCapacity(a);a.lastBirth=-Infinity;return a if(this.rockField.circles.length){const p=this.fieldFor(a).findFree(a.x,a.y,this.radius(a));if(!p)return null;a.x=p.x;a.y=p.y}const h=this.findHabitat(a,a.x,a.y,Math.max(24,this.radius(a)+12));if(!h)return null;a.x=h.x;a.y=h.y;a.habitat=habitat(a.g.waterAffinity);a.ability=this.genomeRecords.get(a.g).ability;a.adultSpeedBoost=sizeSpeedBoost(a.g.bodySize);a.adultMassPower=Math.pow(a.g.bodySize,.75);a.energy=energy??this.maxEnergy(a)*.62;a.stamina=this.staminaCapacity(a);a.lastBirth=-Infinity;return a
} }
setWater(water){ setWater(water){
if(!Array.isArray(water)||!water.length||water.length>80||water.some(w=>![w.x,w.y,w.r].every(Number.isFinite)||w.r<20||w.r>800||w.x<0||w.x>W||w.y<0||w.y>H))return false; if(!Array.isArray(water)||!water.length||water.length>80||water.some(w=>![w.x,w.y,w.r].every(Number.isFinite)||w.r<20||w.r>800||w.x<0||w.x>W||w.y<0||w.y>H))return false;
@ -168,31 +172,50 @@ export class World{
} }
rawGeneticDistance(a,b){let sum=0;for(const k of traitKeys){const [lo,hi]=traits[k],d=(a[k]-b[k])/(hi-lo);sum+=d*d}return Math.sqrt(sum/traitKeys.length)} rawGeneticDistance(a,b){let sum=0;for(const k of traitKeys){const [lo,hi]=traits[k],d=(a[k]-b[k])/(hi-lo);sum+=d*d}return Math.sqrt(sum/traitKeys.length)}
geneticSimilarity(a,b){return Math.exp(-Math.pow(this.geneticDistance(a,b)/.065,2)*3)} geneticSimilarity(a,b){return Math.exp(-Math.pow(this.geneticDistance(a,b)/.065,2)*3)}
shouldPauseAtFood(a,t){if(!t||t.dead||a.forageUntil>this.time)return false;const distance=Math.hypot(t.x-a.x,t.y-a.y);if(t.resourceRole==='zooplankton'||t.plant)return distance<this.radius(a)+5;if(t.maxLeaves)return distance<(this.biomass(a)>=CANOPY_BLOCK_MASS?this.canopyCollisionRadius(t):t.trunk)+this.radius(a)+2;if(t.id<0)return distance<this.radius(a)+5;return distance<this.radius(a)+this.radius(t)+3&&this.clearPath(a.x,a.y,t.x,t.y,a)} isDiffuseResourceTarget(t){return !!t&&(t.plant||t.resourceRole==='zooplankton')}
arrivalRadiusForTarget(a,t){if(!t)return 0;if(this.isDiffuseResourceTarget(t))return this.radius(a)+6;if(t.maxLeaves)return (this.biomass(a)>=CANOPY_BLOCK_MASS?this.canopyCollisionRadius(t):t.trunk)+this.radius(a)+2;if(t.id<0)return this.radius(a)+2.3*Math.sqrt(Math.max(0,t.remainingBiomass))+3;return this.radius(a)+this.radius(t)+3}
shouldPauseAtFood(a,t){if(!t||t.dead||a.forageUntil>this.time||this.isDiffuseResourceTarget(t))return false;const distance=Math.hypot(t.x-a.x,t.y-a.y);return distance<=this.arrivalRadiusForTarget(a,t)&&this.clearPath(a.x,a.y,t.x,t.y,a)}
roleOf(a){return this.species[a.sid]?.trophicRole||'primary-consumer'} roleOf(a){return this.species[a.sid]?.trophicRole||'primary-consumer'}
physiologyOf(a){return this.species[a.sid]?.physiology||'ectotherm'} physiologyOf(a){return this.species[a.sid]?.physiology||'ectotherm'}
profileAt(x,y){return inWater(this.water,x,y)?this.aquaticProfile:TERRESTRIAL} profileAt(x,y){return inWater(this.water,x,y)?this.aquaticProfile:TERRESTRIAL}
preyEdge(a,b){return this.edge(this.roleOf(a),this.roleOf(b))} preyEdge(a,b){return this.edge(this.roleOf(a),this.roleOf(b))}
canHunt(a,b){if(a===b||a.sid===b.sid)return false;const edge=this.preyEdge(a,b);if(!edge)return false;const pref=bodyMassPreference(this.biomass(a),this.biomass(b),this.profileAt(a.x,a.y),edge);return pref>.035&&((a.habitat||habitat(a.g.waterAffinity))==='両棲'||b.habitat&&a.habitat===b.habitat||this.waterAllowed(a,b.x,b.y))} canHunt(a,b){if(a===b||a.sid===b.sid)return false;const edge=this.preyEdge(a,b);if(!edge)return false;const pref=bodyMassPreference(this.biomass(a),this.biomass(b),this.profileAt(a.x,a.y),edge),sameHabitat=(a.habitat||habitat(a.g.waterAffinity))==='両棲'||b.habitat&&a.habitat===b.habitat||this.waterAllowed(a,b.x,b.y);return pref>.035&&sameHabitat&&this.habitatPathAllowed(a,a.x,a.y,b.x,b.y,0)}
kill(a,cause){if(a.dead)return;a.dead=true;this.carcasses.push({id:-a.id,x:a.x,y:a.y,remainingBiomass:this.biomass(a),originalBodySize:this.biomass(a),decay:.015,cause,resourceRole:'detritus'}); } kill(a,cause){if(a.dead)return;a.dead=true;this.carcasses.push({id:-a.id,x:a.x,y:a.y,remainingBiomass:this.biomass(a),originalBodySize:this.biomass(a),decay:.015,cause,resourceRole:'detritus'}); }
decide(a){ decide(a){
const g=a.g,near=this.hash.near(a.x,a.y,g.visionRange,this.nearAnimals),hunger=clamp(1-a.energy/this.maxEnergy(a),0,1),role=this.roleOf(a);let danger=null,food=null,dangerScore=0,bestFood=0; const g=a.g,near=this.hash.near(a.x,a.y,g.visionRange,this.nearAnimals),hunger=clamp(1-a.energy/this.maxEnergy(a),0,1),role=this.roleOf(a);let danger=null,food=null,dangerScore=0,bestFood=0;
for(const b of near){if(b===a||b.dead)continue;const d=Math.hypot(b.x-a.x,b.y-a.y),visible=clamp(Math.sqrt(this.biomass(b))*g.perceptionAbility*35/(d+15)*(1+Math.hypot(b.vx,b.vy)/1200),.015,1),roll=this.rand();if(roll>visible*(1-this.concealmentAt(b.x,b.y)))continue;if(this.canHunt(b,a)){const sc=(1-d/g.visionRange)*(this.species[b.sid]?.g.aggression??.3);if(sc>dangerScore){dangerScore=sc;danger=b}}if(this.canHunt(a,b)){const edge=this.preyEdge(a,b),pref=bodyMassPreference(this.biomass(a),this.biomass(b),this.profileAt(a.x,a.y),edge),sc=edge.preferenceWeight*pref*(1-d/g.visionRange);if(sc>bestFood){food=b;bestFood=sc}}} for(const b of near){if(b===a||b.dead)continue;const d=Math.hypot(b.x-a.x,b.y-a.y),visible=clamp(Math.sqrt(this.biomass(b))*g.perceptionAbility*35/(d+15)*(1+Math.hypot(b.vx,b.vy)/1200),.015,1),roll=this.rand();if(roll>visible*(1-this.concealmentAt(b.x,b.y)))continue;if(this.canHunt(b,a)){const sc=(1-d/g.visionRange)*(this.species[b.sid]?.g.aggression??.3);if(sc>dangerScore){dangerScore=sc;danger=b}}if(this.canHunt(a,b)){const edge=this.preyEdge(a,b),pref=bodyMassPreference(this.biomass(a),this.biomass(b),this.profileAt(a.x,a.y),edge),sc=edge.preferenceWeight*pref*(1-d/g.visionRange);if(sc>bestFood){food=b;bestFood=sc}}}
const detritusEdge=this.edge(role,'detritus');if(detritusEdge)for(const c of this.cHash.near(a.x,a.y,g.visionRange,this.nearCarcasses)){if(c.remainingBiomass<=0)continue;const sc=detritusEdge.preferenceWeight*(1-Math.hypot(c.x-a.x,c.y-a.y)/g.visionRange);if(sc>bestFood&&this.waterAllowed(a,c.x,c.y)){food=c;bestFood=sc}} const detritusEdge=this.edge(role,'detritus');if(detritusEdge)for(const c of this.cHash.near(a.x,a.y,g.visionRange,this.nearCarcasses)){if(c.remainingBiomass<=0)continue;const sc=detritusEdge.preferenceWeight*(1-Math.hypot(c.x-a.x,c.y-a.y)/g.visionRange);if(sc>bestFood&&this.waterAllowed(a,c.x,c.y)&&this.habitatPathAllowed(a,a.x,a.y,c.x,c.y,0)){food=c;bestFood=sc}}
const producerEdge=this.edge(role,'producer');if(producerEdge){for(const tree of this.treeField.candidates(a.x,a.y,a.x,a.y,g.visionRange)){if(tree.dead||tree.leaves<=0)continue;const d=Math.hypot(tree.x-a.x,tree.y-a.y),r=this.canopyRadius(tree),sc=producerEdge.preferenceWeight*.55*(tree.leaves/tree.maxLeaves)*(1-Math.max(0,d-r)/g.visionRange*.7);if(sc>bestFood&&this.waterAllowed(a,tree.x,tree.y)){food=tree;bestFood=sc}}for(let i=0;i<9;i++){const angle=this.rand()*6.28,r=i?this.rand()*g.visionRange:0,x=clamp(a.x+Math.cos(angle)*r,0,W-1),y=clamp(a.y+Math.sin(angle)*r,0,H-1),idx=this.index(x,y),cap=Math.max(.001,this.capacity(idx)),sc=producerEdge.preferenceWeight*(this.plants[idx]/cap)*(1-r/g.visionRange*.6);if(sc>.06&&sc>bestFood&&this.waterAllowed(a,x,y)){food={x,y,plant:true};bestFood=sc}}} const producerEdge=this.edge(role,'producer');if(producerEdge){for(const tree of this.treeField.candidates(a.x,a.y,a.x,a.y,g.visionRange)){if(tree.dead||tree.leaves<=0)continue;const d=Math.hypot(tree.x-a.x,tree.y-a.y),r=this.canopyRadius(tree),sc=producerEdge.preferenceWeight*.55*(tree.leaves/tree.maxLeaves)*(1-Math.max(0,d-r)/g.visionRange*.7);if(sc>bestFood&&this.waterAllowed(a,tree.x,tree.y)&&this.habitatPathAllowed(a,a.x,a.y,tree.x,tree.y,0)){food=tree;bestFood=sc}}for(let i=0;i<9;i++){const angle=this.rand()*6.28,r=i?this.rand()*g.visionRange:0,x=clamp(a.x+Math.cos(angle)*r,0,W-1),y=clamp(a.y+Math.sin(angle)*r,0,H-1),idx=this.index(x,y),cap=Math.max(.001,this.capacity(idx)),sc=producerEdge.preferenceWeight*(this.plants[idx]/cap)*(1-r/g.visionRange*.6);if(sc>.06&&sc>bestFood&&this.waterAllowed(a,x,y)&&this.habitatPathAllowed(a,a.x,a.y,x,y,0)){food={x,y,plant:true};bestFood=sc}}}
const zooplanktonEdge=this.edge(role,'zooplankton');if(zooplanktonEdge&&inWater(this.water,a.x,a.y)){for(let i=0;i<7;i++){const angle=this.rand()*6.28,r=i?this.rand()*g.visionRange:0,x=clamp(a.x+Math.cos(angle)*r,0,W-1),y=clamp(a.y+Math.sin(angle)*r,0,H-1),idx=this.index(x,y),sc=zooplanktonEdge.preferenceWeight*Math.min(1,this.zooplankton[idx]/.02)*(1-r/g.visionRange*.6);if(sc>.05&&sc>bestFood&&this.waterAllowed(a,x,y)){food={x,y,resourceRole:'zooplankton'};bestFood=sc}}} const zooplanktonEdge=this.edge(role,'zooplankton');if(zooplanktonEdge&&inWater(this.water,a.x,a.y)){for(let i=0;i<7;i++){const angle=this.rand()*6.28,r=i?this.rand()*g.visionRange:0,x=clamp(a.x+Math.cos(angle)*r,0,W-1),y=clamp(a.y+Math.sin(angle)*r,0,H-1),idx=this.index(x,y),sc=zooplanktonEdge.preferenceWeight*Math.min(1,this.zooplankton[idx]/.02)*(1-r/g.visionRange*.6);if(sc>.05&&sc>bestFood&&this.waterAllowed(a,x,y)&&this.habitatPathAllowed(a,a.x,a.y,x,y,0)){food={x,y,resourceRole:'zooplankton'};bestFood=sc}}}
let winning=dangerScore*g.fear*2.5;a.action='逃走';a.target=danger;const feeding=hunger*(.35+bestFood)*1.7;if(food&&feeding>winning){winning=feeding;a.action='摂餌';a.target=food}const resting=Math.pow(1-a.stamina/this.staminaCapacity(a),2)*1.1*(food&&hunger>.16?.35:1);if(resting>winning){winning=resting;a.action='休息';a.target=null}if(.13>winning){a.action='徘徊';a.target=null} let winning=dangerScore*g.fear*2.5;a.action='逃走';a.target=danger;const feeding=hunger*(.35+bestFood)*1.7;if(food&&feeding>winning){winning=feeding;a.action='摂餌';a.target=food}const resting=Math.pow(1-a.stamina/this.staminaCapacity(a),2)*1.1*(food&&hunger>.16?.35:1);if(resting>winning){winning=resting;a.action='休息';a.target=null}if(.13>winning){a.action='徘徊';a.target=null}
if(a.action==='徘徊'&&hunger>.5&&bestFood<.04&&!a.dispersalTarget&&this.rand()<.025){const mode=this.species[a.sid]?.locomotionMode||'running',distance=sampleDispersalDistanceM(()=>this.rand(),this.biomass(a),mode),angle=this.rand()*Math.PI*2;a.dispersalTarget={x:clamp(a.x+Math.cos(angle)*distance,5,W-5),y:clamp(a.y+Math.sin(angle)*distance,5,H-5)}} if(a.action==='徘徊'&&!a.roamTarget)a.roamTarget=this.chooseRoamTarget(a)
a.flockX=0;a.flockY=0;let count=0;for(const b of near){if(b===a||b.dead)continue;const dx=b.x-a.x,dy=b.y-a.y,d2=dx*dx+dy*dy,space=this.radius(a)+this.radius(b);if(d2<(space*2)**2){const d=Math.hypot(dx,dy);a.flockX-=dx/(d*d+1)*space*5;a.flockY-=dy/(d*d+1)*space*5}else if(b.sid===a.sid&&d2<85**2){a.flockX+=(dx*.007+b.vx*.012)*g.sociability;a.flockY+=(dy*.007+b.vy*.012)*g.sociability;count++}}if(count){a.flockX/=Math.sqrt(count);a.flockY/=Math.sqrt(count)} this.updateFlocking(a,near);
} }
updateFlocking(a,near){
const sociability=clamp(a.g.sociability,0,1);let separationX=0,separationY=0,separationCount=0,sameCount=0,sumX=0,sumY=0;
for(const b of near){
if(b===a||b.dead)continue;
const dx=b.x-a.x,dy=b.y-a.y,d2=dx*dx+dy*dy,space=this.radius(a)+this.radius(b),limit=space*2;
if(d2<limit*limit){const d=Math.sqrt(Math.max(d2,1e-9)),strength=clamp((limit-d)/Math.max(limit,1),0,1);separationX-=dx/d*strength;separationY-=dy/d*strength;separationCount++}
if(b.sid===a.sid&&d2<85**2){sameCount++;sumX+=b.x;sumY+=b.y}
}
if(separationCount){const n=Math.hypot(separationX,separationY)||1,mag=Math.min(1.1,Math.hypot(separationX,separationY)/Math.sqrt(separationCount));separationX=separationX/n*mag;separationY=separationY/n*mag}
// Social steering is deliberately position-based. Velocity alignment can create a
// self-sustaining tangential feedback loop (milling) around shared targets.
// Separation + weak centroid attraction gives grouping while keeping social steering position-based.
let flockX=separationX,flockY=separationY;
if(sameCount&&sociability>0){const cx=sumX/sameCount,cy=sumY/sameCount,rx=cx-a.x,ry=cy-a.y,rd=Math.hypot(rx,ry);if(rd>18){const cohesionStrength=.42*sociability*Math.min(1,(rd-18)/45);flockX+=rx/rd*cohesionStrength;flockY+=ry/rd*cohesionStrength}}
const magnitude=Math.hypot(flockX,flockY);if(magnitude>1.15){flockX=flockX/magnitude*1.15;flockY=flockY/magnitude*1.15}a.flockX=flockX;a.flockY=flockY;
}
grow(a){ grow(a){
const result=energyLimitedGrowth({structuralMassKg:this.biomass(a),adultMassKg:a.g.bodySize,energyKJ:a.energy,ageDays:a.age,maturityAgeDays:a.g.maturityAge,dtDays:DT}); const result=energyLimitedGrowth({structuralMassKg:this.biomass(a),adultMassKg:a.g.bodySize,energyKJ:a.energy,ageDays:a.age,maturityAgeDays:a.g.maturityAge,dtDays:DT});
a.structuralMassKg=result.structuralMassKg;a.energy=result.energyKJ;return result.gainKg; a.structuralMassKg=result.structuralMassKg;a.energy=result.energyKJ;return result.gainKg;
} }
reproduce(a){ reproduce(a){
const role=this.roleOf(a),cfg=reproductionConfig(role);if(a.age<a.g.maturityAge||this.time-a.lastBirth<cfg.reproductionIntervalDays||!inBreedingSeason(this.time,role)||energyFraction(a.energy,this.maxEnergy(a))<reproductionThreshold(role))return; const role=this.roleOf(a),cfg=reproductionConfig(role);if(a.age<a.g.maturityAge||this.time-a.lastBirth<cfg.reproductionIntervalDays||!inBreedingSeason(this.time,role,this.species[a.sid]?.breedingPhaseDays||0)||energyFraction(a.energy,this.maxEnergy(a))<reproductionThreshold(role))return;
const children=[],share=offspringEnergyShare(role),perChild=Math.min(this.maxEnergy(a)*share,a.energy*share),mode=this.species[a.sid]?.locomotionMode||'running'; const children=[],share=offspringEnergyShare(role),perChild=Math.min(this.maxEnergy(a)*share,a.energy*share),mode=this.species[a.sid]?.locomotionMode||'running';
for(let j=0;j<cfg.offspringPerEvent;j++){const angle=this.rand()*Math.PI*2,d=Math.min(homeRangeRadiusM(this.biomass(a),mode)*.12,sampleDispersalDistanceM(()=>this.rand(),this.biomass(a),mode)*.15),child=this.make(a.g,a.sid,a.x+Math.cos(angle)*d,a.y+Math.sin(angle)*d,0,perChild,[a.id]);if(!child)continue;child.generation=a.generation+1;children.push(child)} for(let j=0;j<cfg.offspringPerEvent;j++){const angle=this.rand()*Math.PI*2,d=Math.min(32,Math.max(5,this.radius(a)*2.5+this.rand()*12)),child=this.make(a.g,a.sid,a.x+Math.cos(angle)*d,a.y+Math.sin(angle)*d,0,perChild,[a.id]);if(!child)continue;child.generation=a.generation+1;children.push(child)}
if(!children.length)return;a.energy=Math.max(0,a.energy-perChild*children.length);a.lastBirth=this.time;this.animals.push(...children); if(!children.length)return;a.energy=Math.max(0,a.energy-perChild*children.length);a.lastBirth=this.time;this.animals.push(...children);
} }
updateResources(){ updateResources(){
@ -219,7 +242,7 @@ export class World{
if(t?.maxLeaves&&!t.dead&&Math.hypot(a.x-t.x,a.y-t.y)<this.canopyRadius(t)+this.radius(a)+3){const amount=Math.min(t.leaves,producerIntakeKgPerDay(m,Math.min(1,t.leaves/Math.max(1,t.maxLeaves)),producerEdge,temp,profile)*DT);t.leaves-=amount;const treeGain=amount*18000*producerEdge.assimilationEfficiency;a.energy+=treeGain;if(t.leaves<=0){t.leaves=0;t.dead=true}} if(t?.maxLeaves&&!t.dead&&Math.hypot(a.x-t.x,a.y-t.y)<this.canopyRadius(t)+this.radius(a)+3){const amount=Math.min(t.leaves,producerIntakeKgPerDay(m,Math.min(1,t.leaves/Math.max(1,t.maxLeaves)),producerEdge,temp,profile)*DT);t.leaves-=amount;const treeGain=amount*18000*producerEdge.assimilationEfficiency;a.energy+=treeGain;if(t.leaves<=0){t.leaves=0;t.dead=true}}
} }
if(t?.resourceRole==='zooplankton'){ if(t?.resourceRole==='zooplankton'){
const edge=this.edge(role,'zooplankton');if(edge){const idx=this.index(a.x,a.y),density=this.zooplankton[idx],params=rallFeedingParameters(edge,m,Math.max(1e-5,density*.002),temp,profile),q=edge.functionalResponse===3?2:1,rate=functionalResponseRatePerDay(params,density,[{...params,resourceDensity:density,q}],q),amount=this.resourceFields[1].consume(idx,Math.min(.12*Math.pow(Math.max(m,.001),.78),rate)*DT,CELL*CELL),gain=amount*7000*edge.assimilationEfficiency;a.energy+=gain}return; const edge=this.edge(role,'zooplankton');if(edge){const idx=this.index(a.x,a.y),density=this.zooplankton[idx],intake=fieldResourceIntakeKgPerDay(m,density,edge,'zooplankton')*DT,amount=this.resourceFields[1].consume(idx,intake,CELL*CELL),gain=amount*7000*edge.assimilationEfficiency;a.energy+=gain}return;
} }
if(!t||t.plant||t.maxLeaves||t.dead)return; if(!t||t.plant||t.maxLeaves||t.dead)return;
const targetRadius=t.id<0?2.3*Math.sqrt(Math.max(0,t.remainingBiomass)):this.radius(t);if(Math.hypot(a.x-t.x,a.y-t.y)>=this.radius(a)+targetRadius+3||!this.clearPath(a.x,a.y,t.x,t.y,a))return; const targetRadius=t.id<0?2.3*Math.sqrt(Math.max(0,t.remainingBiomass)):this.radius(t);if(Math.hypot(a.x-t.x,a.y-t.y)>=this.radius(a)+targetRadius+3||!this.clearPath(a.x,a.y,t.x,t.y,a))return;
@ -230,15 +253,15 @@ export class World{
step(){ step(){
this.time+=DT;this.tick++;this.hash.rebuild(this.animals);if(this.tick%6===1)this.cHash.rebuild(this.carcasses.filter(c=>c.remainingBiomass>0));if(this.tick%24===0)this.updateResources();const temp=this.temp(),length=this.animals.length; this.time+=DT;this.tick++;this.hash.rebuild(this.animals);if(this.tick%6===1)this.cHash.rebuild(this.carcasses.filter(c=>c.remainingBiomass>0));if(this.tick%24===0)this.updateResources();const temp=this.temp(),length=this.animals.length;
for(let i=0;i<length;i++){ for(let i=0;i<length;i++){
const a=this.animals[i];if(a.dead)continue;const g=a.g,m=this.biomass(a),species=this.species[a.sid],mode=species?.locomotionMode||'running',localTemp=this.climate.temperatureC(this.time,{aquatic:inWater(this.water,a.x,a.y)}),stress=Math.max(0,Math.abs(localTemp-g.preferredTemperature)-g.temperatureTolerance*.7)/Math.max(1,g.temperatureTolerance);a.age+=DT;if(a.movementDay!==Math.floor(this.time)){a.movementDay=Math.floor(this.time);a.movedToday=0}const staminaMax=this.staminaCapacity(a),recover=recoveryRate(m,g.staminaRecovery),fitness=thermalFitness(localTemp,g.preferredTemperature,g.temperatureTolerance);a.cooldown-=DT;if((this.tick+a.id)%4===0)this.decide(a); const a=this.animals[i];if(a.dead)continue;const g=a.g,m=this.biomass(a),species=this.species[a.sid],mode=species?.locomotionMode||'running',localTemp=this.climate.temperatureC(this.time,{aquatic:inWater(this.water,a.x,a.y)}),stress=Math.max(0,Math.abs(localTemp-g.preferredTemperature)-g.temperatureTolerance*.7)/Math.max(1,g.temperatureTolerance);a.age+=DT;if(a.movementDay!==Math.floor(this.time)){a.movementDay=Math.floor(this.time);a.movedToday=0}const staminaMax=this.staminaCapacity(a),recoverFraction=staminaRecoveryFractionPerDay(m,g.staminaRecovery),fitness=thermalFitness(localTemp,g.preferredTemperature,g.temperatureTolerance);a.cooldown-=DT;if((this.tick+a.id)%4===0)this.decide(a);
let dx=0,dy=0,speed=0;const t=a.target,field=this.fieldFor(a);if(a.action!=='休息'){ let dx=0,dy=0,speed=0,t=a.target,arrival=null,grazing=false;const field=this.fieldFor(a);if(a.action!=='休息'){
if(t&&!t.dead){dx=t.x-a.x;dy=t.y-a.y;if(a.action==='逃走'){dx=-dx;dy=-dy}} const roamVector=()=>{if(!a.roamTarget)a.roamTarget=this.chooseRoamTarget(a);if(a.roamTarget){let rx=a.roamTarget.x-a.x,ry=a.roamTarget.y-a.y,rd=Math.hypot(rx,ry);if(rd<18||!this.habitatPathAllowed(a,a.x,a.y,a.roamTarget.x,a.roamTarget.y,0)){a.roamTarget=this.chooseRoamTarget(a);if(a.roamTarget){rx=a.roamTarget.x-a.x;ry=a.roamTarget.y-a.y;rd=Math.hypot(rx,ry)}}if(a.roamTarget){dx=rx;dy=ry;arrival={distance:rd,radius:12};return}}a.heading+=(this.rand()-.5)*.08;dx=Math.cos(a.heading);dy=Math.sin(a.heading)};
else if(a.dispersalTarget){dx=a.dispersalTarget.x-a.x;dy=a.dispersalTarget.y-a.y;if(Math.hypot(dx,dy)<20){a.homeX=a.dispersalTarget.x;a.homeY=a.dispersalTarget.y;a.dispersalTarget=null}} if(t&&!t.dead){const distance=Math.hypot(t.x-a.x,t.y-a.y),arrivalRadius=this.arrivalRadiusForTarget(a,t);if(this.isDiffuseResourceTarget(t)&&distance<=arrivalRadius+4){grazing=true;a.target=null;t=null;roamVector()}else{dx=t.x-a.x;dy=t.y-a.y;if(a.action==='逃走'){dx=-dx;dy=-dy}else arrival={distance,radius:arrivalRadius}}}
else{const homeRange=homeRangeRadiusM(m,mode)*(this.profileAt(a.x,a.y).movement?.homeRangeMultiplier||1),homeDx=a.homeX-a.x,homeDy=a.homeY-a.y;if(Math.hypot(homeDx,homeDy)>homeRange){dx=homeDx;dy=homeDy}else{a.heading+=(this.rand()-.5)*.18;dx=Math.cos(a.heading)*40;dy=Math.sin(a.heading)*40}} else roamVector();
let norm=Math.hypot(dx,dy)||1;dx=dx/norm+(a.flockX||0)*.18;dy=dy/norm+(a.flockY||0)*.18;if(field.shapes.length){const v=field.steer(a.x,a.y,dx,dy,this.radius(a),a.id%2?1:-1,t?.maxLeaves?t.id:null,this.steeringOutput);dx=v.x;dy=v.y}norm=Math.hypot(dx,dy)||1; let norm=Math.hypot(dx,dy)||1;dx=dx/norm+(a.flockX||0)*.18;dy=dy/norm+(a.flockY||0)*.18;if(field.shapes.length){const v=field.steer(a.x,a.y,dx,dy,this.radius(a),a.turnBias||1,t?.maxLeaves?t.id:null,this.steeringOutput);dx=v.x;dy=v.y}const hv=this.steerHabitat(a,a.x,a.y,dx,dy,54,this.steeringOutput);dx=hv.x;dy=hv.y;norm=Math.hypot(dx,dy)||1;if((a.action==='徘徊'||grazing)&&norm>1e-9)a.heading=Math.atan2(dy,dx);
const sprint=a.action==='逃走'||(a.action==='摂餌'&&t&&!t.plant&&!t.maxLeaves&&t.id>0),budget=dailyMovementBudgetM(m,g.moveSpeed,mode),remaining=Math.max(0,budget-a.movedToday),modeSpeed=a.action==='逃走'?escapeSpeedMPerDay(m,budget,mode):a.action==='摂餌'?foragingSpeedMPerDay(m,budget,mode):routineTravelSpeedMPerDay(m,budget,mode),maxSpeed=maximumSpeedMPerDay(m,mode);speed=Math.min(maxSpeed,modeSpeed)*(.35+.65*a.stamina/staminaMax)/(1+stress*.45)*(.35+.65*fitness);if(a.habitat==='両棲')speed*=.72;if(a.action==='摂餌'&&t?.id>0&&!t?.maxLeaves)speed=Math.max(speed,Math.min(maxSpeed,Math.hypot(t.vx,t.vy)*1.12));if(a.action==='摂餌'&&this.shouldPauseAtFood(a,t))speed=0;speed=Math.min(speed,remaining/DT); const sprint=a.action==='逃走'||(a.action==='摂餌'&&t&&!t.plant&&!t.maxLeaves&&t.id>0),budget=dailyMovementBudgetM(m,g.moveSpeed,mode),remaining=Math.max(0,budget-a.movedToday),routineSpeed=routineTravelSpeedMPerDay(m,budget,mode),modeSpeed=a.action==='逃走'?escapeSpeedMPerDay(m,budget,mode):a.action==='摂餌'?foragingSpeedMPerDay(m,budget,mode):routineSpeed,maxSpeed=maximumSpeedMPerDay(m,mode);speed=Math.min(maxSpeed,modeSpeed)*(.35+.65*a.stamina/staminaMax)/(1+stress*.45)*(.35+.65*fitness);if(a.habitat==='両棲')speed*=.72;if(grazing)speed=Math.min(speed,Math.max(45,routineSpeed*.55));if(a.action==='摂餌'&&this.shouldPauseAtFood(a,t))speed=0;if(arrival&&speed>0)speed=limitSpeedForArrival(speed,arrival.distance,arrival.radius,DT);speed=Math.min(speed,remaining/DT);
a.vx=dx/norm*speed;a.vy=dy/norm*speed;const oldX=a.x,oldY=a.y;if(field.shapes.length){const p=field.move(a.x,a.y,a.vx*DT,a.vy*DT,this.radius(a),this.collisionOutput),h=this.moveHabitat(a,a.x,a.y,p.x,p.y,this.habitatOutput);if((h.x!==p.x||h.y!==p.y)&&!field.free(h.x,h.y,this.radius(a))){const safe=this.findHabitat(a,h.x,h.y);if(safe){h.x=safe.x;h.y=safe.y}}a.vx=(h.x-a.x)/DT;a.vy=(h.y-a.y)/DT;a.x=h.x;a.y=h.y}else{const h=this.moveHabitat(a,a.x,a.y,clamp(a.x+a.vx*DT,3,W-3),clamp(a.y+a.vy*DT,3,H-3),this.habitatOutput);a.vx=(h.x-a.x)/DT;a.vy=(h.y-a.y)/DT;a.x=h.x;a.y=h.y}a.movedToday+=Math.hypot(a.x-oldX,a.y-oldY);if(a.x<=3||a.x>=W-3||a.y<=3||a.y>=H-3)a.heading+=Math.PI*.7;a.stamina=clamp(a.stamina+DT*(sprint?-sprintCost(m,speed):recover*.15)/(1+stress),0,staminaMax) a.vx=dx/norm*speed;a.vy=dy/norm*speed;const oldX=a.x,oldY=a.y;if(field.shapes.length){const p=field.move(a.x,a.y,a.vx*DT,a.vy*DT,this.radius(a),this.collisionOutput),h=this.moveHabitat(a,a.x,a.y,p.x,p.y,this.habitatOutput);if((h.x!==p.x||h.y!==p.y)&&!field.free(h.x,h.y,this.radius(a))){const safe=this.findHabitat(a,h.x,h.y);if(safe){h.x=safe.x;h.y=safe.y}}a.vx=(h.x-a.x)/DT;a.vy=(h.y-a.y)/DT;a.x=h.x;a.y=h.y}else{const h=this.moveHabitat(a,a.x,a.y,clamp(a.x+a.vx*DT,3,W-3),clamp(a.y+a.vy*DT,3,H-3),this.habitatOutput);a.vx=(h.x-a.x)/DT;a.vy=(h.y-a.y)/DT;a.x=h.x;a.y=h.y}const actualMove=Math.hypot(a.x-oldX,a.y-oldY);a.movedToday+=actualMove;if(speed>0&&actualMove<Math.min(1,speed*DT*.08)&&a.roamTarget){a.roamTarget=null;a.turnBias*=-1}if(a.x<=3||a.x>=W-3||a.y<=3||a.y>=H-3){a.heading+=Math.PI*.7;a.roamTarget=null}a.stamina=clamp(a.stamina+DT*staminaMax*(sprint?-sprintStaminaFractionPerDay(speed,routineSpeed):recoverFraction*.12)/(1+stress),0,staminaMax)
}else{a.vx=a.vy=0;a.stamina=Math.min(staminaMax,a.stamina+recover*DT/(1+stress))} }else{a.vx=a.vy=0;a.stamina=Math.min(staminaMax,a.stamina+staminaMax*recoverFraction*DT/(1+stress))}
const metabolism=metabolismKJPerDay(m,localTemp,this.physiologyOf(a))*a.ability,resp=DT*metabolism*(a.action==='休息'?.78:1),moveCost=DT*locomotionCost(m,speed);a.energy-=resp+moveCost;if(a.action==='摂餌')this.feed(a,t,m,speed,staminaMax);if(a.energy>0)this.grow(a);a.energy=Math.min(a.energy,this.maxEnergy(a));if(a.energy<=0)this.kill(a,'飢餓');else if(a.age>g.lifespan)this.kill(a,'寿命');else{this.applyMortality(a,localTemp);if(!a.dead)this.reproduce(a)} const metabolism=metabolismKJPerDay(m,localTemp,this.physiologyOf(a))*a.ability,resp=DT*metabolism*(a.action==='休息'?.78:1),moveCost=DT*locomotionCost(m,speed);a.energy-=resp+moveCost;if(a.action==='摂餌')this.feed(a,t,m,speed,staminaMax);if(a.energy>0)this.grow(a);a.energy=Math.min(a.energy,this.maxEnergy(a));if(a.energy<=0)this.kill(a,'飢餓');else if(a.age>g.lifespan)this.kill(a,'寿命');else{this.applyMortality(a,localTemp);if(!a.dead)this.reproduce(a)}
} }
if(this.emptyCarcasses){this.carcasses=this.carcasses.filter(c=>c.remainingBiomass>0);this.emptyCarcasses=false}if(this.tick%24===0)this.animals=this.animals.filter(a=>!a.dead);if(this.tick%168===0)this.sample(); if(this.emptyCarcasses){this.carcasses=this.carcasses.filter(c=>c.remainingBiomass>0);this.emptyCarcasses=false}if(this.tick%24===0)this.animals=this.animals.filter(a=>!a.dead);if(this.tick%168===0)this.sample();

View file

@ -0,0 +1,57 @@
import fs from 'node:fs/promises';
import vm from 'node:vm';
import {performance} from 'node:perf_hooks';
import {createHash} from 'node:crypto';
const root=new URL('../../',import.meta.url);
const html=await fs.readFile(new URL('index.html',root),'utf8');
const scripts=[...html.matchAll(/<script[^>]*>([\s\S]*?)<\/script>/g)].map(m=>m[1]);
const kc={};vm.runInNewContext(scripts[0],kc);const kernels=kc.MANDEL_WEBGPU_KERNELS;
const element={width:1024,height:768,clientWidth:1024,clientHeight:768,style:{},hidden:false,value:'',textContent:'',disabled:false,addEventListener(){},classList:{toggle(){}},setAttribute(){},removeAttribute(){},showModal(){},close(){},toBlob(){},getBoundingClientRect(){return{left:0,top:0,width:this.clientWidth,height:this.clientHeight}}};
const app={MANDEL_WEBGPU_KERNELS:kernels,document:{querySelector:()=>element,getElementById:()=>element,documentElement:{classList:{toggle(){}}},body:{classList:{toggle(){}},appendChild(){}}},navigator:{hardwareConcurrency:8,deviceMemory:8},location:{search:'',hash:''},history:{replaceState(){}},performance,URLSearchParams,URL:{createObjectURL:()=>'',revokeObjectURL(){}},TextEncoder,Blob:class{},console,addEventListener(){},requestAnimationFrame:()=>1,cancelAnimationFrame(){},setTimeout,clearTimeout,setInterval,clearInterval,innerWidth:1024,innerHeight:768,devicePixelRatio:1,localStorage:{getItem(){return null},setItem(){},removeItem(){}}};
const cut=scripts[1].indexOf('// ── boot / teardown');
vm.runInNewContext(scripts[1].slice(0,cut)+`\nglobalThis.abInternal={state,canvas,snapshot,maxIter,pixelPrecisionBits,adaptiveProbeSources,adaptiveProbeChoice,referenceWorkerSource,certifiedInteriorTiles,integerGridReuseShift,WebGpuRenderer,fixedNum};})();`,app);
const I=app.abInternal;
const corpus=JSON.parse(await fs.readFile(new URL('experiments/benchmark-corpus.json',root),'utf8')).views;
const median=a=>{const b=[...a].sort((x,y)=>x-y),m=b.length>>1;return b.length%2?b[m]:(b[m-1]+b[m])/2};
const mean=a=>a.reduce((s,x)=>s+x,0)/Math.max(1,a.length);
function fromDec(s,bits=256){s=String(s).trim();let neg=s.startsWith('-');if(neg)s=s.slice(1);if(s.startsWith('+'))s=s.slice(1);const [mant,es='0']=s.toLowerCase().split('e'),exp=parseInt(es,10)||0,[i='0',fr='']=mant.split('.');let digits=(i+fr).replace(/^0+(?=\d)/,'')||'0',places=fr.length-exp;if(places<0){digits+='0'.repeat(-places);places=0}const den=10n**BigInt(places),v=(BigInt(digits)*(1n<<BigInt(bits))+den/2n)/den;return neg?-v:v}
function snapOf(v,bits=256){return{bits,re:fromDec(v.re,bits),im:fromDec(v.im,bits),span:fromDec(v.span,bits)}}
function setView(v,w=1024,h=768){I.canvas.width=w;I.canvas.height=h;I.canvas.clientWidth=w;I.canvas.clientHeight=h;const s=snapOf(v);Object.assign(I.state,s);I.state.adaptive=true;I.state.baseIter=350;I.state.continuationBudget=16384;return s}
// A/B 1: strict direct path: current A maintains Brent cycle state even though strict disables classification.
const f=Math.fround;
function analyticF32(cr,ci){cr=f(cr);ci=f(ci);const y2=f(ci*ci),x=f(cr-.25),q=f(f(x*x)+y2),lhs=f(q*f(q+x)),rhs=f(.25*y2),margin=f(16*5.960464477539063e-8*f(f(Math.abs(lhs)+Math.abs(rhs))+1));if(lhs<f(rhs-margin))return true;const x2=f(cr+1),bulb=f(f(x2*x2)+y2),bm=f(16*5.960464477539063e-8*f(f(Math.abs(bulb)+.0625)+1));return bulb<f(.0625-bm)}
function coordF32(view,x,y,w,h){const scale=f(f(view.span)/f(w));return[f(f(view.re)+f(f(f(x+.5)-f(.5*w))*scale)),f(f(view.im)+f(f(f(.5*h)-f(y+.5))*scale))]}
function strictA(cr,ci,maxIter){if(analyticF32(cr,ci))return 0;let zr=f(0),zi=f(0),n=0,cycleR=f(0),cycleI=f(0),cyclePower=1,cycleLam=0;while(n<maxIter){const zr2=f(zr*zr),zi2=f(zi*zi);zi=f(f(2*zr*zi)+ci);zr=f(f(zr2-zi2)+cr);n++;if(f(f(zr*zr)+f(zi*zi))>4)return n;cycleLam++;if(cycleLam>=cyclePower){cycleR=zr;cycleI=zi;cyclePower=Math.min(65536,cyclePower*2);cycleLam=0}}return maxIter}
function strictB(cr,ci,maxIter){if(analyticF32(cr,ci))return 0;let zr=f(0),zi=f(0),n=0;while(n<maxIter){const zr2=f(zr*zr),zi2=f(zi*zi);zi=f(f(2*zr*zi)+ci);zr=f(f(zr2-zi2)+cr);n++;if(f(f(zr*zr)+f(zi*zi))>4)return n}return maxIter}
function benchStrict(){const w=64,h=40,maxIter=4096,rows=[];for(const v of corpus){const pts=[];for(let y=0;y<h;y++)for(let x=0;x<w;x++)pts.push(coordF32(v,x,y,w,h));const run=fn=>{let sum=0;const t0=performance.now();for(const p of pts)sum+=fn(p[0],p[1],maxIter);return{ms:performance.now()-t0,sum}};run(strictA);run(strictB);const A=[],B=[];for(let k=0;k<5;k++){A.push(run(strictA));B.push(run(strictB))}if(A.at(-1).sum!==B.at(-1).sum)throw new Error('strict A/B result mismatch '+v.name);const a=median(A.map(x=>x.ms)),b=median(B.map(x=>x.ms));rows.push({view:v.name,aMs:a,bMs:b,speedup:a/b,changePct:(b/a-1)*100,iterationChecksum:A.at(-1).sum})}return rows}
// A/B 2: exact interior proof with a conservative cheap viewport bounding-box gate.
// Main cardioid is inside x[-3/4,1/4], |y|<3sqrt(3)/8<2/3. Period-2 bulb is inside x[-5/4,-3/4], |y|<=1/4.
function maybeOverlapsKnownInterior(v,w,h){const re=Number(v.re),im=Number(v.im),span=Number(v.span),halfX=span/2,halfY=span*h/(2*w),xl=re-halfX,xh=re+halfX,yl=im-halfY,yh=im+halfY;const hit=(ax,bx,ay,by)=>!(xh<ax||xl>bx||yh<ay||yl>by);return hit(-.75,.375,-2/3,2/3)||hit(-1.25,-.75,-.25,.25)}
function benchInteriorGate(){const sizes=[[1024,768],[2048,2048]],rows=[];for(const [w,h] of sizes){for(const v of corpus){const snap=setView(v,w,h);const runsA=[],runsB=[];let aRes,bRes;for(let k=0;k<7;k++){let t=performance.now();aRes=I.certifiedInteriorTiles(snap,w,h,32,null);runsA.push(performance.now()-t);t=performance.now();bRes=maybeOverlapsKnownInterior(v,w,h)?I.certifiedInteriorTiles(snap,w,h,32,null):{pixels:0};runsB.push(performance.now()-t)}if(aRes.pixels!==bRes.pixels)throw new Error('interior gate false negative '+v.name);rows.push({view:v.name,w,h,gate:maybeOverlapsKnownInterior(v,w,h),certified:aRes.pixels,aMs:median(runsA),bMs:median(runsB),savedMs:median(runsA)-median(runsB)})}}return rows}
// A/B 3: adaptive probe vs always base iteration. Fixed corpus + deterministic exploratory survey.
function scorerVM(){let result=null;const c={performance,postMessage:m=>{result=m},BigInt,Float32Array,Float64Array,ArrayBuffer,DataView,Math,Number,String,Map,Set,Uint32Array};c.self=c;vm.runInNewContext(I.referenceWorkerSource(),c);return{score(snap,iter,w,h,candidates){result=null;const targetBits=Math.max(I.pixelPrecisionBits(snap,w,40)+32,...candidates.map(x=>x.precisionBits||0));c.self.onmessage({data:{type:'score',id:1,bits:snap.bits,targetBits,re:snap.re.toString(),im:snap.im.toString(),span:snap.span.toString(),width:w,height:h,iter,candidates:candidates.map(x=>({re:x.re.toString(),im:x.im.toString()}))}});if(result?.type==='error')throw new Error(result.error);return result}}}
function probeOne(v,sc,w=1024,h=768){const snap=setView(v,w,h),iter=I.maxIter(),sources=I.adaptiveProbeSources(snap,w,h),t0=performance.now(),res=sc.score(snap,iter,w,h,sources),wall=performance.now()-t0,choice=I.adaptiveProbeChoice(res.escapes,iter,I.state.baseIter),baseWork=choice.work.find(x=>x.initial===I.state.baseIter)?.total??null,chosenWork=choice.work.find(x=>x.initial===choice.iter)?.total??null;return{iter,chosen:choice.iter,probeMs:wall,baseWork,chosenWork,workPenaltyAlwaysBase:chosenWork?baseWork/chosenWork:1,escapes:res.escapes}}
function exploratoryViews(){let seed=0x12345678;const rnd=()=>{seed=(Math.imul(seed,1664525)+1013904223)>>>0;return seed/4294967296};const anchors=[[-.75,0],[-.743643887, .131825904],[-.1011,.9563],[-1.25066,.02012],[-.16,1.0405],[-.1225611669,.7448617666],[-.5,0]];const spans=[2,1,.4,.1,.03,.01,.003,.001];const out=[];for(let i=0;i<48;i++){const a=anchors[i%anchors.length],sp=spans[(i*3)%spans.length],j=sp*.35;out.push({name:'survey-'+i,re:a[0]+(rnd()-.5)*j,im:a[1]+(rnd()-.5)*j,span:sp})}return out}
function benchProbe(){const sc=scorerVM(),fixed=corpus.map(v=>({view:v.name,...probeOne(v,sc)})),survey=[];for(const v of exploratoryViews())survey.push({view:v.name,...v,...probeOne(v,sc,640,480)});return{fixed,survey,nonBase:survey.filter(x=>x.chosen!==350)}}
// A/B 4: FAST full-screen repair. Tiled post-stats pass covers all compute pixels; symmetry covers the other half.
function coverageCase(w,h,iter,mirror){const r=Object.create(I.WebGpuRenderer.prototype),shape=r.numericTileShape(w,iter),tileW=shape.width,baseRows=r.initialNumericRows(w,iter),seen=new Uint8Array(w*h),computeH=mirror?Math.ceil(h/2):h;let rows=baseRows,y=0,step=0;while(y<computeH){const th=Math.min(rows,computeH-y);for(let x=0;x<w;x+=tileW){const tw=Math.min(tileW,w-x);for(let yy=0;yy<th;yy++)seen.fill(1,(y+yy)*w+x,(y+yy)*w+x+tw)}y+=th;step++;if(step%3===0)rows=Math.max(1,Math.min(computeH-y||1,Math.ceil(rows*.73)));else if(step%4===0)rows=Math.max(1,Math.min(computeH-y||1,rows+1))}if(mirror){for(let yy=0;yy<Math.floor(h/2);yy++){const my=h-1-yy;for(let x=0;x<w;x++)if(seen[yy*w+x])seen[my*w+x]=1}}let holes=0;for(const v of seen)if(!v)holes++;return{w,h,iter,mirror,tileW,initialRows:baseRows,holes,repairThreads:Math.ceil(w/8)*Math.ceil(h/8)*64,minimumMetaReadBytes:w*h*4}}
function benchCoverage(){const cases=[];for(const [w,h] of [[965,543],[1365,768],[1024,768],[2731,1536],[2048,2048],[513,511]])for(const iter of [350,4096,16384])for(const mirror of [false,true])cases.push(coverageCase(w,h,iter,mirror));if(cases.some(x=>x.holes))throw new Error('coverage hole found');return cases}
// A/B 5: numeric history exact-grid reuse hit survey and conditional-residency policy.
function historyPanSurvey(){const configs=[{name:'1920x1080-fast',cssW:1920,cssH:1080,w:965,h:543},{name:'1920x1080-standard',cssW:1920,cssH:1080,w:1365,h:768},{name:'1920x1080-high',cssW:1920,cssH:1080,w:2731,h:1536},{name:'1024x768-standard',cssW:1024,cssH:768,w:1024,h:768},{name:'1024x768-dpr2',cssW:1024,cssH:768,w:2048,h:1536}],rows=[];const base=snapOf(corpus[0]);for(const c of configs){let hits=0,total=0,reused=0;const old={...base,w:c.w,h:c.h};for(let dx=-500;dx<=500;dx++){if(dx===0)continue;const re=old.re-old.span*BigInt(Math.round(dx*1e6))/BigInt(Math.round(c.cssW*1e6)),next={...old,re};const sh=I.integerGridReuseShift(old,next,c.w,c.h);total++;if(sh){hits++;reused+=sh.pixels}}const g=(a,b)=>b?g(b,a%b):Math.abs(a),den=c.cssW/g(c.w,c.cssW),policyEligible=den<=8;rows.push({...c,hits,total,hitRate:hits/total,meanReuseRatio:hits?reused/hits/(c.w*c.h):0,reducedScaleDenominator:den,policyEligible,aResidentBytes:8*c.w*c.h,bResidentBytes:policyEligible?8*c.w*c.h:0})}return rows}
// A/B 6: continuation replay surrogate (direct arithmetic): A restarts survivors from zero; B resumes saved z.
function analytic64(cr,ci){const y2=ci*ci,x=cr-.25,q=x*x+y2;if(q*(q+x)<.25*y2)return true;const x2=cr+1;return x2*x2+y2<.0625}
function continueFrom(cr,ci,maxIter,zr=0,zi=0,n=0){let work=0;for(;n<maxIter;n++){const zr2=zr*zr,zi2=zi*zi;zi=2*zr*zi+ci;zr=zr2-zi2+cr;work++;if(zr*zr+zi*zi>4)return{escaped:true,n:n+1,zr,zi,work}}return{escaped:false,n,zr,zi,work}}
function continuationReplay(){const w=64,h=40,base=350,target=4096,rows=[];for(const v of corpus){let workA=0,workB=0,survivors=0,mismatch=0;for(let y=0;y<h;y++)for(let x=0;x<w;x++){const scale=v.span/w,cr=v.re+(x+.5-w/2)*scale,ci=v.im+(h/2-y-.5)*scale;if(analytic64(cr,ci))continue;const p=continueFrom(cr,ci,base);workA+=p.work;workB+=p.work;if(!p.escaped){survivors++;const a=continueFrom(cr,ci,target);const b=continueFrom(cr,ci,target,p.zr,p.zi,p.n);workA+=a.work;workB+=b.work;if(a.escaped!==b.escaped||a.n!==b.n)mismatch++}}rows.push({view:v.name,survivors,workA,workB,workReduction:1-workB/Math.max(1,workA),mismatch})}return rows}
function memoryTraffic(){const presets={fast:524288,standard:1048576,high:4194304};const rows=[];for(const [preset,n] of Object.entries(presets)){rows.push({preset,pixels:n,recolorNumericCopyA:8*n,recolorNumericCopyB:0,cpuRgbaRetainedA:4*n,cpuRgbaRetainedB:0,idleColorSnapshotA:12*n,idleColorSnapshotB:0,unknownPrepassMetaReadA:4*n,unknownPrepassMetaReadB:0,fastRepairMetaReadA:4*n,fastRepairMetaReadB:0})}const export512PerSlot=(512*512)*(4+4+4*4+4+4),activeStdFull=1048576*(32+4+4+4)+Math.ceil(1048576/32)*4+24,activeHighFull=4194304*(32+4+4+4)+Math.ceil(4194304/32)*4+24;return{rows,export:{retainedA:3*export512PerSlot,retainedB:0,reallocationSlotsB:3},deep:{standardFullA:activeStdFull,highFullA:activeHighFull,releaseAfterEasyB:0}}}
const results={date:new Date().toISOString(),source:{sha256:createHash('sha256').update(html).digest('hex'),webGpuTiming:'unavailable in container; Chromium exposes no usable WebGPU adapter'},strictCycle:benchStrict(),interiorGate:benchInteriorGate(),adaptiveProbe:benchProbe(),fastCoverage:benchCoverage(),numericHistory:historyPanSurvey(),continuationReplay:continuationReplay(),memoryTraffic:memoryTraffic()};
console.log(JSON.stringify(results,null,2));
if(process.argv[2])await fs.writeFile(process.argv[2],JSON.stringify(results,null,2)+'\n');

View file

@ -0,0 +1,8 @@
import fs from 'node:fs/promises';import vm from 'node:vm';import {performance} from 'node:perf_hooks';
const html=await fs.readFile(new URL('../../index.html',import.meta.url),'utf8'),scripts=[...html.matchAll(/<script[^>]*>([\s\S]*?)<\/script>/g)].map(m=>m[1]),kc={};vm.runInNewContext(scripts[0],kc);const element={width:256,height:192,clientWidth:256,clientHeight:192,style:{},hidden:false,value:'',textContent:'',disabled:false,addEventListener(){},classList:{toggle(){}},setAttribute(){},removeAttribute(){},showModal(){},close(){},getBoundingClientRect(){return{left:0,top:0,width:256,height:192}}};const app={MANDEL_WEBGPU_KERNELS:kc.MANDEL_WEBGPU_KERNELS,document:{querySelector:()=>element,getElementById:()=>element,documentElement:{classList:{toggle(){}}},body:{classList:{toggle(){}},appendChild(){}}},navigator:{hardwareConcurrency:8,deviceMemory:8},location:{search:'',hash:''},history:{replaceState(){}},performance,URLSearchParams,URL:{createObjectURL:()=>'',revokeObjectURL(){}},TextEncoder,Blob:class{},console,addEventListener(){},requestAnimationFrame:()=>1,cancelAnimationFrame(){},setTimeout,clearTimeout,setInterval,clearInterval,innerWidth:256,innerHeight:192,devicePixelRatio:1,localStorage:{getItem(){return null},setItem(){},removeItem(){}}};const cut=scripts[1].indexOf('// ── boot / teardown');vm.runInNewContext(scripts[1].slice(0,cut)+`\nglobalThis.x={certifiedInteriorTiles};})();`,app);const C=app.x;
function fromDec(s,bits=256){s=String(s);let neg=s[0]=='-';if(neg)s=s.slice(1);const [m,e0='0']=s.toLowerCase().split('e'),e=+e0||0,[i='0',f='']=m.split('.');let d=(i+f).replace(/^0+(?=\d)/,'')||'0',p=f.length-e;if(p<0){d+='0'.repeat(-p);p=0}let den=10n**BigInt(p),v=(BigInt(d)*(1n<<BigInt(bits))+den/2n)/den;return neg?-v:v}
function gate(v,w,h){const re=v.re,im=v.im,span=v.span,unit=1n<<BigInt(v.bits),bw=BigInt(w),bh=BigInt(h),xLo=2n*re-span,xHi=2n*re+span,xDen=2n*unit,yLo=2n*bw*im-span*bh,yHi=2n*bw*im+span*bh,yDen=2n*bw*unit,axis=(lo,hi,den,lp,lq,hp,hq)=>hi*BigInt(lq)>=BigInt(lp)*den&&lo*BigInt(hq)<=BigInt(hp)*den;return axis(xLo,xHi,xDen,-3,4,3,8)&&axis(yLo,yHi,yDen,-2,3,2,3)||axis(xLo,xHi,xDen,-5,4,-3,4)&&axis(yLo,yHi,yDen,-1,4,1,4)}
let seed=0x9e3779b9;const rnd=()=>{seed=(Math.imul(seed,1664525)+1013904223)>>>0;return seed/2**32};let gateFalse=0,falseNeg=0,totalProof=0;for(let i=0;i<240;i++){const span=10**(-4+rnd()*4.7),v={bits:256,re:fromDec(-2+rnd()*3),im:fromDec(-1.2+rnd()*2.4),span:fromDec(span)},g=gate(v,256,192),p=C.certifiedInteriorTiles(v,256,192,32,null).pixels;if(!g)gateFalse++;if(!g&&p){falseNeg++;totalProof+=p}}
// FAST semantic coverage with random seeded/proven/unknown metadata. 0 means untouched hole; any invocation writes nonzero unless a nonzero seed/proof is intentionally retained.
let fastFalse=0,fastCases=0;for(let c=0;c<200;c++){const w=1+Math.floor(rnd()*700),h=1+Math.floor(rnd()*500),mirror=rnd()<.5,computeH=mirror?Math.ceil(h/2):h,meta=new Uint32Array(w*h);for(let i=0;i<meta.length;i++){const q=rnd();meta[i]=q<.1?(3<<28):q<.2?(1<<28)|5:q<.3?123:0}const tileW=1+Math.floor(rnd()*Math.min(w,200)),rows=1+Math.floor(rnd()*Math.min(computeH,80));for(let y=0;y<computeH;y+=rows)for(let x=0;x<w;x+=tileW){const tw=Math.min(tileW,w-x),th=Math.min(rows,computeH-y);for(let yy=0;yy<th;yy++)for(let xx=0;xx<tw;xx++){const i=(y+yy)*w+x+xx,cls=meta[i]>>>28;if(cls===3)continue;if(cls!==0)continue;meta[i]=(1<<28)|1}}if(mirror)for(let y=0;y<Math.floor(h/2);y++)for(let x=0;x<w;x++)meta[(h-1-y)*w+x]=meta[y*w+x]||1;let holes=0;for(const m of meta)if(m===0)holes++;if(holes)fastFalse++;fastCases++}
console.log(JSON.stringify({interiorGateRandom:{cases:240,gateFalse,falseNeg,totalProofInFalseNeg:totalProof},fastCoverageSemantic:{cases:fastCases,casesWithHoles:fastFalse}},null,2));

View file

@ -0,0 +1,13 @@
import fs from 'node:fs/promises';
import vm from 'node:vm';
for (const file of process.argv.slice(2)) {
const html=await fs.readFile(file,'utf8');
const scripts=[...html.matchAll(/<script[^>]*>([\s\S]*?)<\/script>/g)].map(m=>m[1]);
if(scripts.length!==2) throw new Error(file+': expected 2 scripts, got '+scripts.length);
for(const s of scripts)new vm.Script(s);
const kc={}; vm.runInNewContext(scripts[0],kc);
const kernels=kc.MANDEL_WEBGPU_KERNELS;
if(!kernels||Object.keys(kernels).length!==20)throw new Error(file+': kernel bundle malformed');
for(const k of ['DIRECT_F32_WGSL','FAST_PERTURB_WGSL','UNKNOWN_STATS_WGSL','COLOR_WGSL'])if(typeof kernels[k]!=='string')throw new Error(file+': missing '+k);
console.log('PASS',file.split('/').at(-1),'JS syntax, 19 WGSL kernels + version');
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,16 @@
{
"purpose": "Fixed surrogate corpus for evaluating proposed Mandelbrot viewer improvements without changing production index.html.",
"views": [
{"name":"overview","re":-0.5,"im":0,"span":3.4},
{"name":"period3-bulb","re":-0.1225611668766536,"im":0.7448617666197442,"span":0.10},
{"name":"period3-core","re":-0.1225611668766536,"im":0.7448617666197442,"span":0.025},
{"name":"seahorse-boundary","re":-0.743643887037151,"im":0.13182590420533,"span":0.004},
{"name":"seahorse-deep","re":-0.743643887037151,"im":0.13182590420533,"span":0.000002},
{"name":"exterior","re":0.7,"im":0.2,"span":1.2}
],
"notes": [
"CPU tests are surrogates for algorithmic work and classification behavior, not WebGPU frame-time benchmarks.",
"Deep-zoom tests that depend on arbitrary precision are represented by perturbation/series locality tests rather than full production-equivalent rendering.",
"Do not change these views when comparing future revisions; add new named views instead."
]
}

View file

@ -0,0 +1,40 @@
import fs from 'node:fs/promises';
import vm from 'node:vm';
import {performance} from 'node:perf_hooks';
const html=await fs.readFile(new URL('../index.html',import.meta.url),'utf8');
const scripts=[...html.matchAll(/<script[^>]*>([\s\S]*?)<\/script>/g)].map(m=>m[1]);
const kc={};vm.runInNewContext(scripts[0],kc);const kernels=kc.MANDEL_WEBGPU_KERNELS;
const element={width:1024,height:768,clientWidth:1024,clientHeight:768,style:{},hidden:false,value:'',textContent:'',disabled:false,addEventListener(){},classList:{toggle(){}},setAttribute(){},removeAttribute(){},showModal(){},close(){},toBlob(){},getBoundingClientRect(){return{left:0,top:0,width:this.clientWidth,height:this.clientHeight}}};
const app={MANDEL_WEBGPU_KERNELS:kernels,document:{querySelector:()=>element,getElementById:()=>element,documentElement:{classList:{toggle(){}}},body:{classList:{toggle(){}},appendChild(){}}},navigator:{hardwareConcurrency:8,deviceMemory:8},location:{search:'',hash:''},history:{replaceState(){}},performance,URLSearchParams,URL:{createObjectURL:()=>'',revokeObjectURL(){}},TextEncoder,Blob:class{},console,addEventListener(){},requestAnimationFrame:()=>1,cancelAnimationFrame(){},setTimeout,clearTimeout,setInterval,clearInterval,innerWidth:1024,innerHeight:768,devicePixelRatio:1,localStorage:{getItem(){return null},setItem(){},removeItem(){}}};
const cut=scripts[1].indexOf('// ── boot / teardown');
vm.runInNewContext(scripts[1].slice(0,cut)+`\nglobalThis.auditInternal={state,canvas,snapshot,maxIter,zoomExp,pixelPrecisionBits,adaptiveProbeSources,adaptiveProbeChoice,referenceWorkerSource,certifiedInteriorTiles,integerGridReuseShift,fmtFixed,updateStats};})();`,app);
const I=app.auditInternal;
const corpus=JSON.parse(await fs.readFile(new URL('./benchmark-corpus.json',import.meta.url),'utf8')).views;
const median=a=>{const b=[...a].sort((x,y)=>x-y),m=b.length>>1;return b.length%2?b[m]:(b[m-1]+b[m])/2};
function fromDec(s,bits=256){s=String(s).trim();let neg=s.startsWith('-');if(neg)s=s.slice(1);if(s.startsWith('+'))s=s.slice(1);const [mant,es='0']=s.toLowerCase().split('e'),exp=parseInt(es,10)||0,[i='0',fr='']=mant.split('.');let digits=(i+fr).replace(/^0+(?=\d)/,'')||'0',places=fr.length-exp;if(places<0){digits+='0'.repeat(-places);places=0}const den=10n**BigInt(places),v=(BigInt(digits)*(1n<<BigInt(bits))+den/2n)/den;return neg?-v:v}
function snapOf(v,bits=256){return{bits,re:fromDec(v.re,bits),im:fromDec(v.im,bits),span:fromDec(v.span,bits)}}
function setView(v,w=1024,h=768){I.canvas.width=w;I.canvas.height=h;I.canvas.clientWidth=w;I.canvas.clientHeight=h;const s=snapOf(v);Object.assign(I.state,s);return s}
function benchInteriorProof(){const sizes=[[1024,768],[2048,2048]],rows=[];for(const [w,h] of sizes){for(const v of corpus){const snap=setView(v,w,h);const times=[];let r;for(let k=0;k<7;k++){const t0=performance.now();r=I.certifiedInteriorTiles(snap,w,h,32,null);times.push(performance.now()-t0)}rows.push({view:v.name,w,h,pixels:w*h,certified:r.pixels,certifiedRatio:r.pixels/(w*h),medianMs:median(times),times})}}return rows}
function runScoreWorker(snap,iter,w,h,candidates){let result;const c={performance,postMessage:m=>{result=m},BigInt,Float32Array,Float64Array,ArrayBuffer,DataView,Math,Number,String,Map,Set,Uint32Array};c.self=c;vm.runInNewContext(I.referenceWorkerSource(),c);const targetBits=Math.max(I.pixelPrecisionBits(snap,w,40)+32,...candidates.map(x=>x.precisionBits||0));c.self.onmessage({data:{type:'score',id:1,bits:snap.bits,targetBits,re:snap.re.toString(),im:snap.im.toString(),span:snap.span.toString(),width:w,height:h,iter,candidates:candidates.map(x=>({re:x.re.toString(),im:x.im.toString()}))}});if(result?.type==='error')throw new Error(result.error);return result}
function benchAdaptiveProbe(){const w=1024,h=768,rows=[];for(const v of corpus){const snap=setView(v,w,h),adaptiveIter=I.maxIter(),candidates=I.adaptiveProbeSources(snap,w,h);const times=[];let res;for(let k=0;k<3;k++){const t0=performance.now();res=runScoreWorker(snap,adaptiveIter,w,h,candidates);times.push(performance.now()-t0)}const choice=I.adaptiveProbeChoice(res.escapes,adaptiveIter,I.state.baseIter);rows.push({view:v.name,adaptiveIter,chosenInitialIter:choice.iter,targetBits:I.pixelPrecisionBits(snap,w,40)+32,samples:candidates.length,scoreReportedMs:res.scoreMs,wallMedianMs:median(times),escapeSamples:res.escapes,workChoices:choice.work})}return rows}
const f=Math.fround;
function analyticF32(cr,ci){cr=f(cr);ci=f(ci);const y2=f(ci*ci),x=f(cr-.25),q=f(f(x*x)+y2),lhs=f(q*f(q+x)),rhs=f(.25*y2),margin=f(16*5.960464477539063e-8*f(f(Math.abs(lhs)+Math.abs(rhs))+1));if(lhs<f(rhs-margin))return true;const x2=f(cr+1),bulb=f(f(x2*x2)+y2),bm=f(16*5.960464477539063e-8*f(f(Math.abs(bulb)+.0625)+1));return bulb<f(.0625-bm)}
function coordF32(view,x,y,w,h){const scale=f(f(view.span)/f(w)),cr=f(f(view.re)+f(f(f(x+.5)-f(.5*w))*scale)),ci=f(f(view.im)+f(f(f(.5*h)-f(y+.5))*scale));return[cr,ci]}
function cmul(a,b){return[f(f(a[0]*b[0])-f(a[1]*b[1])),f(f(a[0]*b[1])+f(a[1]*b[0]))]}
function directBase(cr,ci,maxIter){if(analyticF32(cr,ci))return 0;let zr=f(0),zi=f(0);for(let n=1;n<=maxIter;n++){const zr2=f(zr*zr),zi2=f(zi*zi);zi=f(f(2*zr*zi)+ci);zr=f(f(zr2-zi2)+cr);if(f(f(zr*zr)+f(zi*zi))>4)return n}return maxIter}
function directPeriodic(cr,ci,maxIter){if(analyticF32(cr,ci))return 0;let zr=f(0),zi=f(0),n=0,cycleR=f(0),cycleI=f(0),cyclePower=1,cycleLam=0,candPeriod=0,candAge=0,candR=f(0),candI=f(0),candMul=[f(1),f(0)],candRepeats=0;while(n<maxIter){const prev=[zr,zi],zr2=f(zr*zr),zi2=f(zi*zi);zi=f(f(2*zr*zi)+ci);zr=f(f(zr2-zi2)+cr);n++;if(f(f(zr*zr)+f(zi*zi))>4)return n;if(candPeriod>0){candMul=cmul(candMul,[f(2*prev[0]),f(2*prev[1])]);candAge++;if(candAge>=candPeriod){const tol=f(4*5.960464477539063e-8*f(1+Math.max(Math.abs(zr),Math.abs(zi)))),close=Math.max(Math.abs(f(zr-candR)),Math.abs(f(zi-candI)))<=tol,attracting=f(f(candMul[0]*candMul[0])+f(candMul[1]*candMul[1]))<.9025;if(close&&attracting){candRepeats++;if(candRepeats>=2)return n;candR=zr;candI=zi;candAge=0;candMul=[f(1),f(0)]}else{candPeriod=0;candRepeats=0}}}if(candPeriod===0&&n>=64){const tol=f(4*5.960464477539063e-8*f(1+Math.max(Math.abs(zr),Math.abs(zi))));if(Math.max(Math.abs(f(zr-cycleR)),Math.abs(f(zi-cycleI)))<=tol){const period=cycleLam+1;if(period<=2048){candPeriod=period;candAge=0;candR=zr;candI=zi;candMul=[f(1),f(0)];candRepeats=0}}}cycleLam++;if(cycleLam>=cyclePower){cycleR=zr;cycleI=zi;cyclePower=Math.min(65536,cyclePower*2);cycleLam=0}}return maxIter}
function benchPeriodicityRuntime(){const w=64,h=40,maxIter=4096,rows=[];for(const v of corpus){const pts=[];for(let y=0;y<h;y++)for(let x=0;x<w;x++)pts.push(coordF32(v,x,y,w,h));const run=fn=>{let sum=0;const t0=performance.now();for(const [cr,ci] of pts)sum+=fn(cr,ci,maxIter);return{ms:performance.now()-t0,sum}};run(directBase);run(directPeriodic);const b=[],p=[];for(let k=0;k<4;k++){b.push(run(directBase));p.push(run(directPeriodic))}rows.push({view:v.name,baseMedianMs:median(b.map(x=>x.ms)),periodicMedianMs:median(p.map(x=>x.ms)),runtimeRatio:median(p.map(x=>x.ms))/median(b.map(x=>x.ms)),baseIterationSum:b.at(-1).sum,periodicIterationSum:p.at(-1).sum,iterationReduction:1-p.at(-1).sum/Math.max(1,b.at(-1).sum)})}return rows}
function memoryAudit(){const presets={fast:524288,standard:1048576,high:4194304},rows=[];for(const [preset,n] of Object.entries(presets)){rows.push({preset,pixels:n,baseFrameBytes:28*n,numericHistoryBytes:8*n,colorSourceBytes:12*n,deepCorrectionQueueBytes:4*n,cpuFallbackRgbaBytes:4*n,colorAutoNumericHistoryCopyBytesPerSecond:8*n*20,provisionalSnapshotCopyBytesPerSecondAt8Hz:8*n*8})}const export512PerSlot=(512*512)*(4+4+4*4+4+4); // meta,smooth,4 samples,resolve,read
const export256PerSlot=(256*256)*(4+4+4*4+4+4);
return{rows,export:{tile512PerSlotBytes:export512PerSlot,tile512Depth3Bytes:3*export512PerSlot,tile256PerSlotBytes:export256PerSlot,tile256Depth2Bytes:2*export256PerSlot,nonAaUnusedSampleTextureBytesPer512Slot:4*512*512*4}}}
function gridReusePanAudit(){const rows=[];for(const dpr of [1,1.25,1.5,2])for(const cssW of [800,1024,1365]){const w=Math.round(cssW*dpr),h=Math.round(w*.75),old={...snapOf(corpus[0]),w,h};for(const dx of [1,3,10,37,100]){const denom=BigInt(Math.round(cssW*1e6)),re=old.re-old.span*BigInt(Math.round(dx*1e6))/denom,newv={...old,re};const sh=I.integerGridReuseShift(old,newv,w,h);rows.push({dpr,cssW,w,dx,reused:!!sh,sx:sh?.sx??null,pixels:sh?.pixels??0,ratio:sh?sh.pixels/(w*h):0})}}return rows}
const results={date:new Date().toISOString(),sourceSha256:null,interiorProof:benchInteriorProof(),adaptiveProbe:benchAdaptiveProbe(),periodicityRuntimeSurrogate:benchPeriodicityRuntime(),memory:memoryAudit(),gridReusePan:gridReusePanAudit(),staticFindings:{certifyInteriorRunsUnusedUnknownStats:/encodeUnknownStats\(e,\{meta:f\.meta,unresolved:f\.unresolved,n:f\.n\}\);\s*return await this\.submitStage\(e,historySeed\?/.test(html),numericHistoryAlwaysAllocated:/historyMeta:buf\(d,n\*4/.test(html)&&/historySmooth:buf\(d,n\*4/.test(html),commitHistoryAlwaysCommitsNumeric:/this\.commitNumericHistory\(view,state\.fieldView\?\.iter/.test(html),colorAuto20Hz:/state\.rendering\?100:50/.test(html),colorSourceSnapshotCopiesNumeric:/copyBufferToBuffer\(f\.meta,0,source\.meta,0,f\.n\*4\)/.test(html)&&/copyBufferToBuffer\(f\.smooth,0,source\.smooth,0,f\.n\*4\)/.test(html),cpuFallbackRgbaUnused:(html.match(/cpuFrameRgba/g)||[]).length===2,exportWorkspacePersists:!/exportWorkspaceDestroy\(\)/.test(html.slice(html.indexOf('async function runExport'),html.indexOf("$('png').onclick"))),fastCoverageRepair:/fast-coverage-repair/.test(html)&&/this\.encodeFastNumeric\(e,\{pbuf:f\.numericParams/.test(html)}};
console.log(JSON.stringify(results,null,2));
if(process.argv[2])await fs.writeFile(process.argv[2],JSON.stringify(results,null,2)+'\n');

View file

@ -0,0 +1,46 @@
import fs from 'node:fs/promises';
import vm from 'node:vm';
import os from 'node:os';
import {Worker} from 'node:worker_threads';
import {performance} from 'node:perf_hooks';
const html=await fs.readFile(new URL('../index.html',import.meta.url),'utf8');
const scripts=[...html.matchAll(/<script[^>]*>([\s\S]*?)<\/script>/g)].map(m=>m[1]);
const kc={};vm.runInNewContext(scripts[0],kc);const kernels=kc.MANDEL_WEBGPU_KERNELS;
const element={width:800,height:600,clientWidth:800,clientHeight:600,style:{},hidden:false,value:'',textContent:'',disabled:false,addEventListener(){},classList:{toggle(){}},setAttribute(){},removeAttribute(){},showModal(){},close(){},toBlob(){},getBoundingClientRect(){return{left:0,top:0,width:800,height:600}}};
const app={MANDEL_WEBGPU_KERNELS:kernels,document:{querySelector:()=>element,getElementById:()=>element,documentElement:{classList:{toggle(){}}},body:{classList:{toggle(){}},appendChild(){}}},navigator:{hardwareConcurrency:4,deviceMemory:8},location:{search:'',hash:''},history:{replaceState(){}},performance,URLSearchParams,URL:{createObjectURL:()=>'',revokeObjectURL(){}},TextEncoder,Blob:class{},console,addEventListener(){},requestAnimationFrame:()=>1,cancelAnimationFrame(){},setTimeout,clearTimeout,setInterval,clearInterval,innerWidth:800,innerHeight:600,devicePixelRatio:1,localStorage:{getItem(){return null},setItem(){},removeItem(){}}};
const cut=scripts[1].indexOf('// ── boot / teardown');
vm.runInNewContext(scripts[1].slice(0,cut)+`\nglobalThis.internal={fastReferenceWorkerSource,cpuFallbackWorkerSource,chooseBackend};})();`,app);
const internal=app.internal;
const corpus=JSON.parse(await fs.readFile(new URL('./benchmark-corpus.json',import.meta.url),'utf8')).views;
const median=a=>{const b=[...a].sort((x,y)=>x-y),m=b.length>>1;return b.length%2?b[m]:(b[m-1]+b[m])/2};
const f=Math.fround;
function analyticF32(cr,ci){cr=f(cr);ci=f(ci);const y2=f(ci*ci),x=f(cr-.25),q=f(f(x*x)+y2),lhs=f(q*f(q+x)),rhs=f(.25*y2),margin=f(16*5.960464477539063e-8*f(f(Math.abs(lhs)+Math.abs(rhs))+1));if(lhs<f(rhs-margin))return true;const x2=f(cr+1),bulb=f(f(x2*x2)+y2),bm=f(16*5.960464477539063e-8*f(f(Math.abs(bulb)+.0625)+1));return bulb<f(.0625-bm)}
function coordF32(view,x,y,w,h){const scale=f(f(view.span)/f(w)),cr=f(f(view.re)+f(f(f(x+.5)-f(.5*w))*scale)),ci=f(f(view.im)+f(f(f(.5*h)-f(y+.5))*scale));return[cr,ci]}
function cMulF(a,b){return[f(f(a[0]*b[0])-f(a[1]*b[1])),f(f(a[0]*b[1])+f(a[1]*b[0]))]}
function iterateProd(cr,ci,maxIter,periodic){if(analyticF32(cr,ci))return{cls:'analytic',work:0,iter:0};let zr=f(0),zi=f(0),n=0,cycleR=f(0),cycleI=f(0),power=1,lam=0,candPeriod=0,candAge=0,candR=0,candI=0,candMul=[1,0],candRepeats=0;while(n<maxIter){const prev=[zr,zi],zr2=f(zr*zr),zi2=f(zi*zi);zi=f(f(f(2*zr)*zi)+ci);zr=f(f(zr2-zi2)+cr);n++;const mag=f(f(zr*zr)+f(zi*zi));if(mag>4)return{cls:'escaped',work:n,iter:n};if(periodic&&candPeriod>0){candMul=cMulF(candMul,[f(2*prev[0]),f(2*prev[1])]);candAge++;if(candAge>=candPeriod){const tol=f(4*5.960464477539063e-8*f(1+Math.max(Math.abs(zr),Math.abs(zi)))),close=Math.max(Math.abs(f(zr-candR)),Math.abs(f(zi-candI)))<=tol,attracting=f(f(candMul[0]*candMul[0])+f(candMul[1]*candMul[1]))<.9025;if(close&&attracting){candRepeats++;if(candRepeats>=2)return{cls:'heuristic',work:n,iter:n};candR=zr;candI=zi;candAge=0;candMul=[1,0]}else{candPeriod=0;candRepeats=0}}}if(periodic&&candPeriod===0&&n>=64){const tol=f(4*5.960464477539063e-8*f(1+Math.max(Math.abs(zr),Math.abs(zi))));if(Math.max(Math.abs(f(zr-cycleR)),Math.abs(f(zi-cycleI)))<=tol){const period=lam+1;if(period<=2048){candPeriod=period;candAge=0;candR=zr;candI=zi;candMul=[1,0];candRepeats=0}}}lam++;if(lam>=power){cycleR=zr;cycleI=zi;power=Math.min(65536,power*2);lam=0}}return{cls:'limit',work:maxIter,iter:maxIter}}
function iterateOracle(cr,ci,maxIter){let zr=0,zi=0;for(let n=1;n<=maxIter;n++){const zr2=zr*zr,zi2=zi*zi;zi=2*zr*zi+ci;zr=zr2-zi2+cr;if(zr*zr+zi*zi>4)return n}return 0}
function periodicityBench(){const w=96,h=64,maxIter=8192,oracleIter=65536,rows=[];for(const view of corpus){const snap={bits:256,re:fromDec(String(view.re)),im:fromDec(String(view.im)),span:fromDec(String(view.span))},backend=internal.chooseBackend(snap,800).backend;let baseWork=0,heurWork=0,candidates=0,falseCandidates=0,baseLimit=0,heurLimit=0;const t0=performance.now();for(let y=0;y<h;y++)for(let x=0;x<w;x++){const [cr,ci]=coordF32(view,x,y,w,h),b=iterateProd(cr,ci,maxIter,false),q=iterateProd(cr,ci,maxIter,true);baseWork+=b.work;heurWork+=q.work;if(b.cls==='limit')baseLimit++;if(q.cls==='limit')heurLimit++;if(q.cls==='heuristic'){candidates++;if(iterateOracle(cr,ci,oracleIter)>q.iter)falseCandidates++}}rows.push({view:view.name,productionBackend:backend,pixels:w*h,baseWork,heuristicWork:heurWork,workReduction:backend==='direct'?1-heurWork/Math.max(1,baseWork):null,candidates:backend==='direct'?candidates:0,falseCandidates:backend==='direct'?falseCandidates:0,baseLimit,heuristicLimit:backend==='direct'?heurLimit:baseLimit,diagnosticIfForcedDirect:backend==='direct'?null:{workReduction:1-heurWork/Math.max(1,baseWork),candidates,falseCandidates},ms:performance.now()-t0})}return{w,h,maxIter,oracleIter,rows,note:'Production DIRECT_F32 rule with two attracting-cycle confirmations. FAST/perturbation periodic early-stop is intentionally disabled.'}}
function fromDec(s,bits=256){s=String(s).trim();let neg=s.startsWith('-');if(neg)s=s.slice(1);if(s.startsWith('+'))s=s.slice(1);const [mant,es='0']=s.toLowerCase().split('e'),exp=parseInt(es,10)||0,[i='0',fr='']=mant.split('.');let digits=(i+fr).replace(/^0+(?=\d)/,'')||'0',places=fr.length-exp;if(places<0){digits+='0'.repeat(-places);places=0}const den=10n**BigInt(places),v=(BigInt(digits)*(1n<<BigInt(bits))+den/2n)/den;return neg?-v:v}
function workerBuildFast(span){let result;const c={performance,postMessage:m=>{result=m},BigInt,Float32Array,Float64Array,ArrayBuffer,DataView,Math,Number,String,Map,Set,Uint32Array};c.self=c;vm.runInNewContext(internal.fastReferenceWorkerSource(),c);c.self.onmessage({data:{type:'build',id:1,key:'bench',re:fromDec('-0.743643887037151').toString(),im:fromDec('0.13182590420533').toString(),span:fromDec(span).toString(),sourceBits:256,targetBits:320,width:800,height:600,iter:1200}});if(result?.type==='error')throw new Error(result.error);return result}
function cMul(a,b){return[a[0]*b[0]-a[1]*b[1],a[0]*b[1]+a[1]*b[0]]}
function refsAt(buf,n){const d=new DataView(buf),o=n*16;return[d.getFloat32(o,true)+d.getFloat32(o+8,true),d.getFloat32(o+4,true)+d.getFloat32(o+12,true)]}
function evalSeries(coeffs,dc){let sum=[0,0],pow=[1,0];for(let k=0;k<4;k++){pow=cMul(pow,dc);const a=[coeffs[k*2],coeffs[k*2+1]],t=cMul(a,pow);sum=[sum[0]+t[0],sum[1]+t[1]]}return sum}
function directDelta(refs,jump,dc){let d=[0,0];for(let n=0;n<jump;n++){const z=refsAt(refs,n),lin=cMul([2*z[0],2*z[1]],d),sq=cMul(d,d);d=[lin[0]+sq[0]+dc[0],lin[1]+sq[1]+dc[1]]}return d}
function seriesBench(){const spans=['1e-5','1e-7','1e-9','1e-11'],rows=[];for(const spanText of spans){const r=workerBuildFast(spanText),span=Number(spanText),samples=[[-.5,-.375],[-.5,.375],[.5,-.375],[.5,.375],[0,-.375],[0,.375],[-.5,0],[.5,0]];let maxAbsError=0,maxRelError=0;for(const [x,y] of samples){const dc=[x*span,y*span],p=evalSeries(r.series.coeffs,dc),e=directDelta(r.refs,r.series.jump,dc),ae=Math.hypot(p[0]-e[0],p[1]-e[1]),re=ae/Math.max(1e-30,Math.hypot(e[0],e[1]));maxAbsError=Math.max(maxAbsError,ae);maxRelError=Math.max(maxRelError,re)}rows.push({span:Number(spanText),jump:r.series.jump,errorLog2:r.series.errorLog2,maxAbsError,maxRelativeError:maxRelError,refLen:r.refLen,buildMs:r.buildMs})}return{rows,note:'Production fast-reference worker output. Error compares packed 4th-order series against direct perturbation over 8 screen-domain deltas.'}}
function sparseMemoryBench(){const presets={fast:524288,standard:1048576,high:4194304},rows=[];for(const [preset,n] of Object.entries(presets))for(const activeRatio of [1,.25,.1,.01]){const oldBytes=44*n,capacity=Math.min(n,Math.max(Math.ceil(n*activeRatio),Math.ceil(n*activeRatio*1.25)+256)),newBytes=44*capacity+4*Math.ceil(n/32),reduction=1-newBytes/oldBytes;rows.push({preset,pixels:n,activeRatio,capacity,oldBytes,newBytes,reduction})}return{rows,note:'Production allocation: 32 B state + 2x4 B queues + 4 B pixel map per active slot, plus 1-bit membership bitmap. Capacity adds 25% + 256 headroom.'}}
// Same perturbation locality surrogate as the first evaluation; production now requests up to 3 failure-guided references instead of one.
function analytic(cr,ci){const y2=ci*ci,x=cr-.25,q=x*x+y2;return q*(q+x)<.25*y2||(cr+1)*(cr+1)+y2<.0625}
function iterate(cr,ci,maxIter){if(analytic(cr,ci))return{cls:'analytic',iter:0};let zr=0,zi=0;for(let n=1;n<=maxIter;n++){const zr2=zr*zr,zi2=zi*zi;zi=2*zr*zi+ci;zr=zr2-zi2+cr;if(zr*zr+zi*zi>4)return{cls:'escaped',iter:n}}return{cls:'limit',iter:maxIter}}
function coord(view,x,y,w,h){const scale=view.span/w;return[view.re+(x+.5-w/2)*scale,view.im+(h/2-y-.5)*scale]}
function mMul(ar,ai,br,bi){return[ar*br-ai*bi,ar*bi+ai*br]}
function mFMul(ar,ai,br,bi){return[f(f(ar*br)-f(ai*bi)),f(f(ar*bi)+f(ai*br))]}
function buildReference(cr,ci,maxIter){const re=new Float64Array(maxIter+1),im=new Float64Array(maxIter+1),hiRe=new Float32Array(maxIter+1),hiIm=new Float32Array(maxIter+1),loRe=new Float32Array(maxIter+1),loIm=new Float32Array(maxIter+1);let zr=0,zi=0,valid=maxIter;for(let n=0;n<=maxIter;n++){re[n]=zr;im[n]=zi;hiRe[n]=f(zr);hiIm[n]=f(zi);loRe[n]=f(zr-hiRe[n]);loIm[n]=f(zi-hiIm[n]);if(n===maxIter)break;const zr2=zr*zr,zi2=zi*zi;zi=2*zr*zi+ci;zr=zr2-zi2+cr;if(!Number.isFinite(zr+zi)||Math.hypot(zr,zi)>1e18){valid=n;break}}return{cr,ci,re,im,hiRe,hiIm,loRe,loIm,valid}}
function mPerturbEscape(cRe,cIm,ref,maxIter){if(ref.valid<maxIter)return{cls:'reference-end',iter:ref.valid};let wr=f(0),wi=f(0),dr=f(cRe-ref.cr),di=f(cIm-ref.ci);for(let n=0;n<maxIter;n++){const zr=ref.hiRe[n]+ref.loRe[n]+wr,zi=ref.hiIm[n]+ref.loIm[n]+wi,mag=zr*zr+zi*zi;if(mag>4)return{cls:'escaped',iter:n};const [lr,li]=mFMul(ref.hiRe[n],ref.hiIm[n],wr,wi),[lr2,li2]=mFMul(ref.loRe[n],ref.loIm[n],wr,wi),[sr,si]=mFMul(wr,wi,wr,wi);wr=f(f(2*f(lr+lr2))+sr+dr);wi=f(f(2*f(li+li2))+si+di)}const n=maxIter,zr=ref.hiRe[n]+ref.loRe[n]+wr,zi=ref.hiIm[n]+ref.loIm[n]+wi;return zr*zr+zi*zi>4?{cls:'escaped',iter:n}:{cls:'limit',iter:maxIter}}
function perturbGrid(view,w,h,maxIter,grid){const refs=[],seen=new Set(),add=(x,y)=>{const key=`${x.toFixed(6)}:${y.toFixed(6)}`;if(seen.has(key))return;seen.add(key);const [cr,ci]=coord(view,x,y,w,h);refs.push({...buildReference(cr,ci,maxIter),x,y})};add((w-1)/2,(h-1)/2);if(grid>1)for(let gy=0;gy<grid;gy++)for(let gx=0;gx<grid;gx++)add((gx+.5)*w/grid-.5,(gy+.5)*h/grid-.5);let mismatchClass=0,mismatchIter=0,refEnd=0,total=0;for(let y=0;y<h;y++)for(let x=0;x<w;x++){const [cr,ci]=coord(view,x,y,w,h),truth=iterate(cr,ci,maxIter);if(truth.cls==='analytic')continue;let ref=null,best=Infinity;for(const r of refs){if(r.valid<maxIter)continue;const d=(x-r.x)**2+(y-r.y)**2;if(d<best){best=d;ref=r}}if(!ref){refEnd++;continue}const p=mPerturbEscape(cr,ci,ref,maxIter);total++;const tc=truth.cls==='escaped'?'escaped':'limit';if(p.cls!==tc)mismatchClass++;else if(tc==='escaped'&&Math.abs(p.iter-truth.iter)>1)mismatchIter++}return{grid,references:refs.length,total,refEnd,mismatchClass,mismatchIter,mismatchRate:mismatchClass/Math.max(1,total)}}
function multiReferenceBench(){const w=64,h=40,maxIter=1200,views=[{name:'seahorse-1e-5',re:-.743643887037151,im:.13182590420533,span:1e-5},{name:'seahorse-1e-7',re:-.743643887037151,im:.13182590420533,span:1e-7}],rows=[];for(const v of views)for(const grid of [1,2,3])rows.push({view:v.name,...perturbGrid(v,w,h,maxIter,grid)});return{maxGuidedPasses:(html.match(/FAILURE_GUIDED_REF_MAX_PASSES=(\d+)/)||[])[1]|0,w,h,maxIter,rows,note:'CPU f32 perturbation locality surrogate. Production selects up to three additional references from failure clusters instead of fixed grids.'}}
function makeNodeWorkerSource(){return `const {parentPort}=require('node:worker_threads');globalThis.self=globalThis;globalThis.postMessage=(m,t)=>parentPort.postMessage(m,t);${internal.cpuFallbackWorkerSource()}\nparentPort.on('message',d=>self.onmessage({data:d}));`}
async function cpuFallbackBench(){const source=makeNodeWorkerSource(),w=640,h=360,iter=350,threads=Math.min(4,Math.max(2,os.cpus().length));const payload=(y0,y1,id=1)=>({type:'render',id,w,h,y0,y1,maxIter:iter,re:-.5,im:0,span:3.4,style:{palette:0,cycle:1.0,shift:0}});async function run(count){const workers=Array.from({length:count},()=>new Worker(source,{eval:true}));const once=()=>Promise.all(workers.map((wk,i)=>new Promise((resolve,reject)=>{wk.once('message',resolve);wk.once('error',reject);wk.postMessage(payload(Math.floor(h*i/count),Math.floor(h*(i+1)/count),i+1))})));await once();const times=[];let parts;for(let k=0;k<7;k++){const t0=performance.now();parts=await once();times.push(performance.now()-t0)}await Promise.all(workers.map(w=>w.terminate()));return{medianMs:median(times),times,work:parts.reduce((s,p)=>s+p.work,0),escaped:parts.reduce((s,p)=>s+p.escaped,0),operationLimit:parts.reduce((s,p)=>s+p.operationLimit,0),heuristicInterior:parts.reduce((s,p)=>s+p.heuristicInterior,0)}}const one=await run(1),multi=await run(threads);return{w,h,iter,threads,one,multi,speedup:one.medianMs/multi.medianMs,workMatch:one.work===multi.work,escapedMatch:one.escaped===multi.escaped,operationLimitMatch:one.operationLimit===multi.operationLimit,heuristicMatch:one.heuristicInterior===multi.heuristicInterior,note:'Production CPU fallback Worker source executed under node:worker_threads shim.'}}
const results={date:new Date().toISOString(),periodicity:periodicityBench(),series:seriesBench(),sparseActiveState:sparseMemoryBench(),multiReference:multiReferenceBench(),cpuFallback:await cpuFallbackBench(),sourceAudit:{heuristicClass:/FIELD_INTERIOR_HEURISTIC:u32=2u/.test(html),strictDisablesPeriodicity:/candPeriod==0u && n>=64u/.test(html)&&!/cycleReady/.test(kernels.FAST_PERTURB_WGSL),seriesBinding:/binding\(5\).*series:SeriesData/s.test(html),compactPixelMap:/deepActivePixel/.test(html)&&/pixelMap/.test(kernels.DEEP_ACTIVE_CONTINUE_WGSL),multiReferencePasses:/FAILURE_GUIDED_REF_MAX_PASSES=3/.test(html),cpuFallback:/class CpuFallbackRenderer/.test(html)},environment:{node:process.version,cpus:os.cpus().length,platform:process.platform,arch:process.arch}};
console.log(JSON.stringify(results,null,2));
if(process.argv[2])await fs.writeFile(process.argv[2],JSON.stringify(results,null,2)+'\n');

View file

@ -0,0 +1,47 @@
import fs from 'node:fs/promises';
import vm from 'node:vm';
import {createHash} from 'node:crypto';
const beforeUrl=new URL('../../docs/reported-view-bugfix/index.before.html',import.meta.url);
const afterUrl=new URL('../../index.html',import.meta.url);
const view={
bits:273,
re:'-15958919693715511752069626580195447581643016686947107473855747766272418724092005269',
im:'3884297345146583343999140717464554120497735725078805729405733239132420430333375719',
span:'35679807704506851074208413824041445358585910697267232113437882789120793',
width:1108,height:1582,iter:1536,
};
function extract(html){return [...html.matchAll(/<script[^>]*>([\s\S]*?)<\/script>/g)].map(m=>m[1])}
function appInternal(html){
const scripts=extract(html);const kc={};vm.runInNewContext(scripts[0],kc);const kernels=kc.MANDEL_WEBGPU_KERNELS;
const element={width:view.width,height:view.height,clientWidth:view.width,clientHeight:view.height,style:{},hidden:false,value:'',textContent:'',disabled:false,addEventListener(){},classList:{toggle(){}},setAttribute(){},removeAttribute(){},showModal(){},close(){},toBlob(){},getBoundingClientRect(){return{left:0,top:0,width:view.width,height:view.height}}};
const context={MANDEL_WEBGPU_KERNELS:kernels,document:{querySelector:()=>element,getElementById:()=>element,documentElement:{classList:{toggle(){}}},body:{classList:{toggle(){}},appendChild(){}}},navigator:{hardwareConcurrency:4,deviceMemory:8},location:{search:'',hash:''},history:{replaceState(){}},performance,URLSearchParams,URL:{createObjectURL:()=>`blob:test`,revokeObjectURL(){}},TextEncoder,Blob:class{},console,addEventListener(){},requestAnimationFrame:()=>1,cancelAnimationFrame(){},setTimeout,clearTimeout,setInterval,clearInterval,innerWidth:view.width,innerHeight:view.height,devicePixelRatio:1,localStorage:{getItem(){return null},setItem(){},removeItem(){}}};
const cut=scripts[1].indexOf('// ── boot / teardown');
const code=scripts[1].slice(0,cut)+`\nglobalThis.internal={fastReferenceWorkerSource,pixelPrecisionBits};})();`;
vm.runInNewContext(code,context);return context.internal;
}
function runWorker(source,message){let result;const wc={self:{},postMessage:r=>{result=r},performance};vm.runInNewContext(source,wc);wc.self.onmessage({data:message});if(!result)throw new Error('worker returned nothing');if(result.type==='error')throw new Error(result.error);return result}
function bitLen(v){v=v<0n?-v:v;return v===0n?0:v.toString(2).length}
function fixedLog2Abs(v,bits){v=v<0n?-v:v;if(v===0n)return-Infinity;const bl=bitLen(v),take=Math.min(53,bl),sh=bl-take,top=Number(v>>BigInt(sh));return Math.log2(top)+sh-bits}
function fixedRatio(a,b){if(!b||!a)return 0;let neg=a<0n;if(neg)a=-a;const q=(a<<52n)/b,v=Number(q)/4503599627370496;return neg?-v:v}
function spanMantExp(span,bits){const l=fixedLog2Abs(span,bits),exp=Math.floor(l);return{mant:Math.pow(2,l-exp),exp}}
function complexMul(ar,ai,br,bi){return [Math.fround(Math.fround(ar*br)-Math.fround(ai*bi)),Math.fround(Math.fround(ar*bi)+Math.fround(ai*br))]}
function nonFiniteSeriesStarts(series,refX,refY,spanMant,width,height,cols=32,rows=32){
if(!series?.jump)return 0;let bad=0;const c=series.coeffs.map(Math.fround);
for(let jy=0;jy<rows;jy++)for(let ix=0;ix<cols;ix++){
const gx=(ix+.5)*width/cols,gy=(jy+.5)*height/rows,dx=Math.fround((gx-refX)/width),dy=Math.fround((refY-gy)/width),d=[Math.fround(spanMant*dx),Math.fround(spanMant*dy)];
const d2=complexMul(d[0],d[1],d[0],d[1]),d4=complexMul(d2[0],d2[1],d2[0],d2[1]);
const t4=complexMul(c[6],c[7],d4[0],d4[1]);
if(!Number.isFinite(t4[0])||!Number.isFinite(t4[1]))bad++;
}
return bad;
}
async function analyze(path){
const html=await fs.readFile(path,'utf8'),a=appInternal(html);const snap={bits:view.bits,re:BigInt(view.re),im:BigInt(view.im),span:BigInt(view.span)};const targetBits=a.pixelPrecisionBits(snap,view.width,40);
const result=runWorker(a.fastReferenceWorkerSource(),{type:'build',id:1,key:'reported-view',sourceBits:view.bits,targetBits,re:view.re,im:view.im,span:view.span,width:view.width,height:view.height,iter:view.iter});
const refRe=BigInt(result.referenceRe),refIm=BigInt(result.referenceIm),span=BigInt(view.span),refX=view.width*.5+fixedRatio(refRe-BigInt(view.re),span)*view.width,refY=view.height*.5-fixedRatio(refIm-BigInt(view.im),span)*view.width,se=spanMantExp(span,view.bits);
return {sha256:createHash('sha256').update(html).digest('hex'),targetBits,refLen:result.refLen,selectionEscape:result.selectionEscape,referencePixel:{x:refX,y:refY},series:result.series,nonFiniteSeriesStartSamples:nonFiniteSeriesStarts(result.series,refX,refY,se.mant,view.width,view.height),seriesSampleCount:32*32};
}
const before=await analyze(beforeUrl),after=await analyze(afterUrl);const output={view,before,after,checks:{baselineSeriesActive:before.series.jump>0,baselineHasNonFiniteSeriesStarts:before.nonFiniteSeriesStartSamples>0,patchedSeriesDisabled:after.series.jump===0&&after.nonFiniteSeriesStartSamples===0}};
console.log(JSON.stringify(output,null,2));

View file

@ -1,4 +1,4 @@
<!doctype html><html lang="ja"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#101d20"><title>ECOSPHERE | 食物連鎖シミュレータ</title><link rel="stylesheet" href="style.css?v=41"></head><body> <!doctype html><html lang="ja"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#101d20"><title>ECOSPHERE | 食物連鎖シミュレータ</title><link rel="stylesheet" href="style.css?v=45"></head><body>
<header><a class="brand" href="./"><span class="brandmark">◉</span> ECOSPHERE</a><div class="header-right"><div class="date"><strong id="date">経過 0000日 00:00</strong><span id="temperature">— °C</span></div><span class="live" id="runStatus">準備中</span><button id="helpBtn" class="icon-button" aria-label="モデルと操作説明">?</button></div></header> <header><a class="brand" href="./"><span class="brandmark">◉</span> ECOSPHERE</a><div class="header-right"><div class="date"><strong id="date">経過 0000日 00:00</strong><span id="temperature">— °C</span></div><span class="live" id="runStatus">準備中</span><button id="helpBtn" class="icon-button" aria-label="モデルと操作説明">?</button></div></header>
<main><section class="workspace"> <main><section class="workspace">
<div class="field" id="field"><div class="field-toolbar"><div class="toolbar"><div class="tools" role="group" aria-label="編集ツール"><button class="active" data-tool="observe" aria-label="観察">⌖ <span>観察</span></button><button data-tool="water" aria-label="水域編集">≈ <span>水域</span></button><button data-tool="rocks" aria-label="岩の編集">◒ <span>岩</span></button></div><label class="layer-label">表示 <select id="layer" aria-label="表示レイヤー"><option value="plants">生産者量</option><option value="moisture">土壌水分</option><option value="trophic">栄養段階</option><option value="habitat">水域適応</option></select></label><label class="layer-label">水域 <select id="aquaticProfile" aria-label="水域環境プロファイル"><option value="freshwater">淡水</option><option value="marine">海洋</option></select></label></div> <div class="field" id="field"><div class="field-toolbar"><div class="toolbar"><div class="tools" role="group" aria-label="編集ツール"><button class="active" data-tool="observe" aria-label="観察">⌖ <span>観察</span></button><button data-tool="water" aria-label="水域編集">≈ <span>水域</span></button><button data-tool="rocks" aria-label="岩の編集">◒ <span>岩</span></button></div><label class="layer-label">表示 <select id="layer" aria-label="表示レイヤー"><option value="plants">生産者量</option><option value="moisture">土壌水分</option><option value="trophic">栄養段階</option><option value="habitat">水域適応</option></select></label><label class="layer-label">水域 <select id="aquaticProfile" aria-label="水域環境プロファイル"><option value="freshwater">淡水</option><option value="marine">海洋</option></select></label></div>
@ -9,4 +9,4 @@
</section><aside><div class="side-tabs" role="tablist"><button role="tab" aria-selected="true" data-tab="overview" class="active">生態系</button><button role="tab" aria-selected="false" data-tab="individual">個体</button></div> </section><aside><div class="side-tabs" role="tablist"><button role="tab" aria-selected="true" data-tab="overview" class="active">生態系</button><button role="tab" aria-selected="false" data-tab="individual">個体</button></div>
<div id="overview" class="tab-panel"><div class="section-heading"><h2>生態系のいま</h2><span class="tiny">LIVE</span></div><div class="metrics"><div><span>総個体数</span><strong id="population">—<small>個体</small></strong></div><div><span>生存種</span><strong id="speciesCount">16<small>種</small></strong></div><div><span>生産者量</span><strong id="plantMass">—</strong></div><div><span>最大世代</span><strong id="generation">0<small>世代</small></strong></div></div><div class="section-heading"><h2>観測ログ</h2><span>直近の変化</span></div><div id="events"></div></div> <div id="overview" class="tab-panel"><div class="section-heading"><h2>生態系のいま</h2><span class="tiny">LIVE</span></div><div class="metrics"><div><span>総個体数</span><strong id="population">—<small>個体</small></strong></div><div><span>生存種</span><strong id="speciesCount">16<small>種</small></strong></div><div><span>生産者量</span><strong id="plantMass">—</strong></div><div><span>最大世代</span><strong id="generation">0<small>世代</small></strong></div></div><div class="section-heading"><h2>観測ログ</h2><span>直近の変化</span></div><div id="events"></div></div>
<div id="individual" class="tab-panel" hidden><div class="empty"><span>⌖</span><h2>ひとつの個体を観察する</h2><p>フィールド上の個体をクリックすると、行動・エネルギー・形質を確認できます。</p></div></div></aside></main> <div id="individual" class="tab-panel" hidden><div class="empty"><span>⌖</span><h2>ひとつの個体を観察する</h2><p>フィールド上の個体をクリックすると、行動・エネルギー・形質を確認できます。</p></div></div></aside></main>
<dialog id="help"><button id="closeHelp" class="close" aria-label="閉じる">×</button><h2>操作と生態系</h2><h3>操作</h3><p>生物をクリックすると行動と形質を確認できます。水域・岩は追加、移動、拡大縮小、削除ができます。水域は淡水/海洋プロファイルを切り替えられます。</p><h3>生態系</h3><p>時間は day、距離は m、動物体重は kg、エネルギーは kJ です。生産者は格子上の biomass、動物は個体として扱います。食性の単一値ではなく、一次・二次・高次消費者と資源の相互作用エッジで摂食を決めます。出生後も親は生存し、加速された突然変異・自動種分化・自動移入は行いません。</p><h3>今回除外した機能</h3><p>水深レイヤーと人為的攪乱(漁獲、農薬、富栄養化、生息地消失、温暖化イベント等)は実装していません。初期水域は旧版と同じ地形系列を基準に、格子面積がおよそ2倍になるよう拡張しています。</p><h3>表示と計算</h3><p>標準の生態刻みは 1/24 day です。式、単位、出典、検証条件は<a href="research.html" target="_blank" rel="noopener">モデルの根拠</a>をご覧ください。</p></dialog><script type="module" src="app.js?v=41"></script></body></html> <dialog id="help"><button id="closeHelp" class="close" aria-label="閉じる">×</button><h2>操作と生態系</h2><h3>操作</h3><p>生物をクリックすると行動と形質を確認できます。水域・岩は追加、移動、拡大縮小、削除ができます。水域は淡水/海洋プロファイルを切り替えられます。</p><h3>生態系</h3><p>時間は day、距離は m、動物体重は kg、エネルギーは kJ です。生産者は格子上の biomass、動物は個体として扱います。食性の単一値ではなく、一次・二次・高次消費者と資源の相互作用エッジで摂食を決めます。出生後も親は生存し、加速された突然変異・自動種分化・自動移入は行いません。</p><h3>今回除外した機能</h3><p>水深レイヤーと人為的攪乱(漁獲、農薬、富栄養化、生息地消失、温暖化イベント等)は実装していません。初期水域は旧版と同じ地形系列を基準に、格子面積がおよそ2倍になるよう拡張しています。</p><h3>表示と計算</h3><p>標準の生態刻みは 1/24 day です。通常表示の×1は1 day/秒を目標に進みます。実効速度は個体数と端末性能に応じて表示されます。式、単位、出典、検証条件は<a href="research.html" target="_blank" rel="noopener">モデルの根拠</a>をご覧ください。</p></dialog><script type="module" src="app.js?v=45"></script></body></html>

View file

@ -1,12 +1,18 @@
import {maximumSpeedMultiplier} from './ecology/movement.js'; import {maximumSpeedMultiplier} from './ecology/movement.js?v=45';
import {basalMetabolismKJPerDay} from './ecology/metabolism.js'; import {basalMetabolismKJPerDay} from './ecology/metabolism.js?v=45';
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v));
export const sizeSpeedBoost=maximumSpeedMultiplier; export const sizeSpeedBoost=maximumSpeedMultiplier;
export const staminaLimit=(mass,gene)=>gene*Math.max(.2,mass/3); export const staminaLimit=(mass,gene)=>gene*Math.max(.2,mass/3);
export const recoveryRate=(mass,gene)=>gene*Math.pow(Math.max(.05,mass/3),.75); // Recovery is defined as a fraction of the individual's current stamina capacity per day.
// The genome trait therefore controls recovery time without making large bodies take many days
// simply because their absolute stamina pool is larger.
export const staminaRecoveryFractionPerDay=(mass,gene)=>clamp((.46+.32*gene)*Math.pow(Math.max(.05,mass/3),-.035),.62,1.45);
// Sprint/escape/chase drain is likewise capacity-relative. Routine travel does not consume
// sprint stamina; only locomotion above the routine gait is charged here.
export const sprintStaminaFractionPerDay=(speed,routineSpeed)=>.72+.48*clamp(speed/Math.max(1,routineSpeed)-1,0,2);
// Returns kJ/day when speed is m/day. // Returns kJ/day when speed is m/day.
export const locomotionCost=(mass,speed)=>.018*Math.pow(Math.max(mass,.001),.684)*Math.max(0,speed); export const locomotionCost=(mass,speed)=>.018*Math.pow(Math.max(mass,.001),.684)*Math.max(0,speed);
export const sprintCost=(mass,speed)=>.034*Math.pow(Math.max(mass,.001),.684)*Math.max(0,speed);
export const thermalFitness=(temperature,preferred,tolerance)=>Math.exp(-0.5*(((temperature-preferred)/Math.max(1,tolerance))**2)); export const thermalFitness=(temperature,preferred,tolerance)=>Math.exp(-0.5*(((temperature-preferred)/Math.max(1,tolerance))**2));
export const metabolismKJPerDay=basalMetabolismKJPerDay; export const metabolismKJPerDay=basalMetabolismKJPerDay;
export const decayRate=temperature=>.018*Math.pow(1.88,(temperature-20)/10); export const decayRate=temperature=>.018*Math.pow(1.88,(temperature-20)/10);

View file

@ -1,6 +1,6 @@
// Continuous circles and rotated fallen-log rectangles. // Continuous circles and rotated fallen-log rectangles.
// A uniform spatial index limits swept collision checks to nearby obstacles. // A uniform spatial index limits swept collision checks to nearby obstacles.
import {W as WIDTH,H as HEIGHT} from './world-size.js'; import {W as WIDTH,H as HEIGHT} from './world-size.js?v=45';
const SIZE=120,EPS=.0001; const SIZE=120,EPS=.0001;
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v)); const clamp=(v,a,b)=>Math.max(a,Math.min(b,v));
const local=(c,x,y)=>{const co=Math.cos(c.angle||0),si=Math.sin(c.angle||0),dx=x-c.x,dy=y-c.y;return {x:co*dx+si*dy,y:-si*dx+co*dy}}; const local=(c,x,y)=>{const co=Math.cos(c.angle||0),si=Math.sin(c.angle||0),dx=x-c.x,dy=y-c.y;return {x:co*dx+si*dy,y:-si*dx+co*dy}};

View file

@ -1 +1 @@
{"type":"module","private":true,"name":"ecosphere-food-chain-simulator","version":"41.0.0"} {"type":"module","private":true,"name":"ecosphere-food-chain-simulator","version":"45.0.0","scripts":{"test":"node test/ecology.test.mjs"}}

View file

@ -1,13 +1,13 @@
<!doctype html><html lang="ja"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>モデル仕様と根拠 | ECOSPHERE</title><link rel="stylesheet" href="style.css?v=41"><style>.research{max-width:1050px;margin:auto;padding:40px 24px 80px;font-size:16px;line-height:1.9}.research h1{font-size:28px}.research h2{font-size:21px;margin:35px 0 16px}.research p{color:#bdccc3}.research a{color:#d5e697}.research .table-wrap{overflow:auto}.research table{border-collapse:collapse;width:100%;font-size:14px;min-width:690px}.research td,.research th{border-bottom:1px solid #3b4d43;text-align:left;padding:15px;vertical-align:top}.research th{color:#d5e697}.research code{color:#e2e8c6;font-size:14px;white-space:normal}.research li{margin:12px 0}.research .formula{padding:18px;background:#203029;border-left:3px solid #a4bc72}.research small{font-size:14px;color:#a0b3a8}</style></head><body><header><a class="brand" href="./">◉ ECOSPHERE</a><a href="./" style="color:#d5e697">観測画面へ</a></header><article class="research"><span class="eyebrow">MODEL · v41</span><h1>実単位ベースの食物網モデル</h1> <!doctype html><html lang="ja"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>モデル仕様と根拠 | ECOSPHERE</title><link rel="stylesheet" href="style.css?v=45"><style>.research{max-width:1050px;margin:auto;padding:40px 24px 80px;font-size:16px;line-height:1.9}.research h1{font-size:28px}.research h2{font-size:21px;margin:35px 0 16px}.research p{color:#bdccc3}.research a{color:#d5e697}.research .table-wrap{overflow:auto}.research table{border-collapse:collapse;width:100%;font-size:14px;min-width:690px}.research td,.research th{border-bottom:1px solid #3b4d43;text-align:left;padding:15px;vertical-align:top}.research th{color:#d5e697}.research code{color:#e2e8c6;font-size:14px;white-space:normal}.research li{margin:12px 0}.research .formula{padding:18px;background:#203029;border-left:3px solid #a4bc72}.research small{font-size:14px;color:#a0b3a8}</style></head><body><header><a class="brand" href="./">◉ ECOSPHERE</a><a href="./" style="color:#d5e697">観測画面へ</a></header><article class="research"><span class="eyebrow">MODEL · v45</span><h1>実単位ベースの食物網モデル</h1>
<p>v41 は v38 の描画・空間探索・水域編集・Web Worker を維持し、生態学コアを day / m / kg / kJ へ置換した版です。生産者は field、動物プランクトンは cohort、大型消費者は individual agent として扱います。</p> <p>v45 は v38 の描画・空間探索・水域編集・Web Worker を維持し、生態学コアを day / m / kg / kJ へ置換した版です。生産者は field、動物プランクトンは cohort、大型消費者は individual agent として扱います。</p>
<h2>実装範囲</h2><p><strong>水深・鉛直層は扱いません。</strong>漁獲、農薬、富栄養化、生息地消失などの<strong>人為的攪乱も実装していません</strong>。自動移入と自動種分化も停止しています。</p> <h2>実装範囲</h2><p><strong>水深・鉛直層は扱いません。</strong>漁獲、農薬、富栄養化、生息地消失などの<strong>人為的攪乱も実装していません</strong>。自動移入と自動種分化も停止しています。</p>
<h2>単位</h2><div class="table-wrap"><table><tr><th>量</th><th>実装</th></tr><tr><td>時間</td><td>day、標準 <code>dt=1/24 day</code></td></tr><tr><td>距離</td><td>m、世界 4800 × 3200 m</td></tr><tr><td>動物体重</td><td>kg wet mass</td></tr><tr><td>動物エネルギー</td><td>kJ</td></tr><tr><td>producer / zooplankton</td><td>kg biomass m<sup>-2</sup></td></tr></table></div> <h2>単位</h2><div class="table-wrap"><table><tr><th>量</th><th>実装</th></tr><tr><td>時間</td><td>day、標準 <code>dt=1/24 day</code></td></tr><tr><td>距離</td><td>m、世界 4800 × 3200 m</td></tr><tr><td>動物体重</td><td>kg wet mass</td></tr><tr><td>動物エネルギー</td><td>kJ</td></tr><tr><td>producer / zooplankton</td><td>kg biomass m<sup>-2</sup></td></tr></table></div>
<h2>生産者・cohort</h2><div class="formula"><strong>植物プランクトン最大増殖率</strong><br><code>μmax(T)=0.81 exp(0.0631T) day^-1</code></div><p>10 / 20 / 30°C の golden test は約 1.52 / 2.86 / 5.38 day<sup>-1</sup>。水域では光・窒素・リン制限を掛け、zooplankton は個体を大量生成せず格子上の cohort biomass として更新します。DO は phytoplankton growth の式へ直接掛けません。</p> <h2>生産者・cohort</h2><div class="formula"><strong>植物プランクトン最大増殖率</strong><br><code>μmax(T)=0.81 exp(0.0631T) day^-1</code></div><p>10 / 20 / 30°C の golden test は約 1.52 / 2.86 / 5.38 day<sup>-1</sup>。水域では光・窒素・リン制限を掛け、zooplankton は個体を大量生成せず格子上の cohort biomass として更新します。DO は phytoplankton growth の式へ直接掛けません。</p>
<h2>食物網・Holling応答</h2><p>単一の <code>diet</code> は廃止し、consumer–resource interaction edge を摂食関係のソースにします。植物資源の標準同化効率は 0.45、動物資源は 0.85 です。</p><div class="formula"><code>Fij = aij Pij Nj^q / (1 + Σ aik Pik hik Nk^q)</code></div><p><code>q=1</code> を Holling II、<code>q=2</code> を Holling III とし、handling time は実際に複数資源の分母へ入ります。個体捕食は <code>p=1-exp(-rate×dt)</code> に変換します。</p> <h2>食物網・Holling応答</h2><p>単一の <code>diet</code> は廃止し、consumer–resource interaction edge を摂食関係のソースにします。植物資源の標準同化効率は 0.45、動物資源は 0.85 です。</p><div class="formula"><code>Fij = aij Pij Nj^q / (1 + Σ aik Pik hik Nk^q)</code></div><p><code>q=1</code> を Holling II、<code>q=2</code> を Holling III とし、handling time は実際に複数資源の分母へ入ります。個体捕食は <code>p=1-exp(-rate×dt)</code> に変換します。</p>
<p>Rall et al. (2012) の mass / temperature scaling を attack / handling に接続しています。all-data slopes は attack が consumer mass <code>+0.47</code>、resource mass <code>+0.15</code>、activation energy <code>+0.44 eV</code>、handling が consumer mass <code>-0.48</code>、resource mass <code>+0.34</code>、activation energy <code>-0.27 eV</code> です。捕食サイズ選好は profile 別 predator:prey mass ratio を中心とする対数正規関数です。</p> <p>Rall et al. (2012) の mass / temperature scaling は<strong>動物個体どうしの捕食</strong>の attack / handling に接続しています。all-data slopes は attack が consumer mass <code>+0.47</code>、resource mass <code>+0.15</code>、activation energy <code>+0.44 eV</code>、handling が consumer mass <code>-0.48</code>、resource mass <code>+0.34</code>、activation energy <code>-0.27 eV</code> です。producer / zooplankton は連続 biomass field なので、密度を架空の prey body mass へ変換せず、biomass density に対する飽和摂食を使います。</p>
<h2>代謝</h2><div class="formula"><code>B(M,T)=B0 M^(3/4) × temperature response</code></div><p>ectotherm は Arrhenius 型温度補正を使い、endotherm とは normalization を分離します。旧版の肉食個体だけを一律 0.55 倍する代謝補正は使いません。</p> <h2>代謝</h2><div class="formula"><code>B(M,T)=B0 M^(3/4) × temperature response</code></div><p>ectotherm は Arrhenius 型温度補正を使い、endotherm とは normalization を分離します。旧版の肉食個体だけを一律 0.55 倍する代謝補正は使いません。</p>
<h2>移動</h2><p><code>maximum / routine / foraging / escape speed</code>、daily movement budget、home range、dispersal を分離しました。最大速度は Hirt et al. (2017) Supplementary Table 4 の式 <code>v=aM^b(1-exp(-hM^i))</code> を用います。</p><div class="table-wrap"><table><tr><th>mode</th><th>a</th><th>b</th><th>h</th><th>i</th></tr><tr><td>flying</td><td>142.8</td><td>0.24</td><td>2.4</td><td>-0.72</td></tr><tr><td>running</td><td>25.5</td><td>0.26</td><td>22.0</td><td>-0.60</td></tr><tr><td>swimming</td><td>11.2</td><td>0.36</td><td>19.5</td><td>-0.56</td></tr></table></div> <h2>移動</h2><p><code>maximum / routine / foraging / escape speed</code>、daily movement budget、自由移動 waypoint を分離しました。最大速度は Hirt et al. (2017) Supplementary Table 4 の式 <code>v=aM^b(1-exp(-hM^i))</code> を用います。</p><div class="table-wrap"><table><tr><th>mode</th><th>a</th><th>b</th><th>h</th><th>i</th></tr><tr><td>flying</td><td>142.8</td><td>0.24</td><td>2.4</td><td>-0.72</td></tr><tr><td>running</td><td>25.5</td><td>0.26</td><td>22.0</td><td>-0.60</td></tr><tr><td>swimming</td><td>11.2</td><td>0.36</td><td>19.5</td><td>-0.56</td></tr></table></div>
<h2>出生・死亡</h2><p>親を死亡させる <code>divide()</code> は廃止しました。成熟、繁殖季節、繁殖間隔、energy reserve を満たすと offspring が出生し、親は生存します。死亡要因は捕食、飢餓、寿命、背景死亡、温度ストレスとして処理します。v41 では研究用の詳細集計出力は持ちません。</p> <p><strong>v45移動修正:</strong> スポーン地点への復帰制約を廃止し、自由移動 waypoint を現在位置から継続生成します。非両棲個体は初期配置時に岸線から余裕を取って配置し、岸線接近時は signed-distance 勾配から法線を求め、禁止側への速度成分だけを除いて接線方向へ連続的に移動します。左右候補角の交互選択、回転指数検出、逆トルクは使いません。producer / zooplankton の仮想目標は到達時に破棄し、摂食しながら自由移動へ戻すことで目標点との往復を防ぎます。stamina は容量比で消費・回復し、通常移動では sprint stamina を消費しません。通常表示の×1は1 day/秒を目標とし、実効 day/秒を別表示します。</p><h2>出生・死亡</h2><p>親を死亡させる <code>divide()</code> は廃止しました。成熟、繁殖季節、繁殖間隔、energy reserve を満たすと offspring が出生し、親は生存します。generic 初期種は同じ trophic role でも繁殖季節が完全同期しないよう species ごとに位相をずらします。死亡要因は捕食、飢餓、寿命、背景死亡、温度ストレスとして処理します。研究用の詳細集計出力は持ちません。</p>
<h2>初期水域</h2><p>初期 procedural water の各半径へ <code>√2</code> を一度掛け、旧版より<strong>概ね2倍の面積</strong>にします。水域同士が重なるため厳密な2倍にはしません。wet-cell 面積を測って二分探索する処理はありません。<strong>ユーザーが編集した水域は受け取った形状をそのまま適用</strong>し、水域追加ツールの既定半径も v38 と同じ 170 m です。</p> <h2>初期水域</h2><p>初期 procedural water の各半径へ <code>√2</code> を一度掛け、旧版より<strong>概ね2倍の面積</strong>にします。水域同士が重なるため厳密な2倍にはしません。wet-cell 面積を測って二分探索する処理はありません。<strong>ユーザーが編集した水域は受け取った形状をそのまま適用</strong>し、水域追加ツールの既定半径も v38 と同じ 170 m です。</p>
<h2>環境 forcing</h2><p><code>ClimateProvider</code> は seasonal fallback と観測 series を扱える内部機構を持ちますが、v41 では実験用 Worker API を公開しません。通常UIで切り替えられる外部入力は freshwater / marine profile のみです。</p><h2>出力</h2><p>研究用 batch API と詳細 flux 出力は削除しました。Worker が送るのは描画・個体観察・グラフ表示に必要な内部状態だけです。</p> <h2>環境 forcing</h2><p><code>ClimateProvider</code> は seasonal fallback と観測 series を扱える内部機構を持ちますが、実験用 Worker API は公開しません。通常UIで切り替えられる外部入力は freshwater / marine profile のみです。</p><h2>出力</h2><p>研究用 batch API と詳細 flux 出力は削除しました。Worker が送るのは描画・個体観察・グラフ表示に必要な内部状態だけです。</p>
<h2>参照と較正</h2><ol><li>Bissinger et al. (2008) / Eppley: phytoplankton temperature-growth upper envelope。</li><li>Gillooly et al.: mass/temperature metabolic scaling。</li><li>Rall et al. (2012): attack rate / handling time の mass・temperature scaling。</li><li>Brose et al.: predator–prey body-size relationship。</li><li>Hirt et al. (2017): locomotion mode 別 maximum speed model。</li></ol><p><small>文献から一意に決まらない edge baseline、B0、life-history、zooplankton cohort 係数等は simulator calibration です。区分は <code>PARAMETER-PROVENANCE.json</code> に記録しています。</small></p><p><a href="./">観測画面に戻る</a></p></article></body></html> <h2>参照と較正</h2><ol><li>Bissinger et al. (2008) / Eppley: phytoplankton temperature-growth upper envelope。</li><li>Gillooly et al.: mass/temperature metabolic scaling。</li><li>Rall et al. (2012): attack rate / handling time の mass・temperature scaling。</li><li>Brose et al.: predator–prey body-size relationship。</li><li>Hirt et al. (2017): locomotion mode 別 maximum speed model。</li></ol><p><small>文献から一意に決まらない edge baseline、B0、life-history、zooplankton cohort 係数等は simulator calibration です。区分は <code>PARAMETER-PROVENANCE.json</code> に記録しています。</small></p><p><a href="./">観測画面に戻る</a></p></article></body></html>

View file

@ -1,6 +1,6 @@
import {waterPath,shoreRadius,withinWater} from './water.js'; import {waterPath,shoreRadius,withinWater} from './water.js?v=45';
import {W,H} from './engine.js'; import {W,H} from './engine.js?v=45';
import {bodyPath} from './body-shapes.js'; import {bodyPath} from './body-shapes.js?v=45';
const BIN=128,NY=Math.ceil(H/BIN),EMPTY=[]; const BIN=128,NY=Math.ceil(H/BIN),EMPTY=[];
let indexedTrees=null,treeBins=null; let indexedTrees=null,treeBins=null;
let indexedWater=null,coastline=null; let indexedWater=null,coastline=null;

View file

@ -1,3 +1,3 @@
import {GW,GH} from './engine.js'; import {GW,GH} from './engine.js?v=45';
const stops=[[0,105,89,59],[.32,103,113,61],[.68,72,108,48],[1,41,80,40]]; const stops=[[0,105,89,59],[.32,103,113,61],[.68,72,108,48],[1,41,80,40]];
export function paintTerrain(tc,state,layer){const im=tc.createImageData(GW,GH);for(let i=0;i<GW*GH;i++){let r,g,b;if(layer==='moisture'){const m=state.moisture[i];r=23+m*26;g=43+m*80;b=44+m*101}else{const p=Math.min(1,state.plants[i]/.5);let k=0;while(k<2&&p>stops[k+1][0])k++;const lo=stops[k],hi=stops[k+1],f=(p-lo[0])/(hi[0]-lo[0]);r=lo[1]+(hi[1]-lo[1])*f;g=lo[2]+(hi[2]-lo[2])*f;b=lo[3]+(hi[3]-lo[3])*f}const j=i*4;im.data[j]=r;im.data[j+1]=g;im.data[j+2]=b;im.data[j+3]=255}tc.putImageData(im,0,0)} export function paintTerrain(tc,state,layer){const im=tc.createImageData(GW,GH);for(let i=0;i<GW*GH;i++){let r,g,b;if(layer==='moisture'){const m=state.moisture[i];r=23+m*26;g=43+m*80;b=44+m*101}else{const p=Math.min(1,state.plants[i]/.5);let k=0;while(k<2&&p>stops[k+1][0])k++;const lo=stops[k],hi=stops[k+1],f=(p-lo[0])/(hi[0]-lo[0]);r=lo[1]+(hi[1]-lo[1])*f;g=lo[2]+(hi[2]-lo[2])*f;b=lo[3]+(hi[3]-lo[3])*f}const j=i*4;im.data[j]=r;im.data[j+1]=g;im.data[j+2]=b;im.data[j+3]=255}tc.putImageData(im,0,0)}

View file

@ -1,107 +1,58 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises'; import {readFile} from 'node:fs/promises';
import {performance} from 'node:perf_hooks'; import {World,DT} from '../engine.js?v=45';
import {World,DT,H,GW,GH,CELL,INITIAL_SPECIES} from '../engine.js'; import {fieldResourceIntakeKgPerDay,interactionEdge} from '../ecology/feeding.js?v=45';
import {inWater} from '../water.js';
import {phytoplanktonMuMax,producerGrowthPerDay,updateZooplanktonCohort} from '../ecology/resources.js';
import {interactionEdge,rallFeedingParameters,functionalResponseRatePerDay} from '../ecology/feeding.js';
import {rateToProbability} from '../ecology/units.js';
import {reproductionConfig,energyLimitedGrowth,initialStructuralMassKg} from '../ecology/demography.js';
import {maximumSpeedMPerDay,routineTravelSpeedMPerDay,homeRangeRadiusM,sampleDispersalDistanceM,dailyMovementBudgetM,locomotionCalibration} from '../ecology/movement.js';
import {FRESHWATER,TERRESTRIAL} from '../ecology/profiles.js';
import {ClimateProvider} from '../ecology/climate.js';
const approx=(actual,expected,tol,msg)=>assert.ok(Math.abs(actual-expected)<=tol,`${msg}: ${actual} vs ${expected}`); const quantile=(a,p)=>a.sort((x,y)=>x-y)[Math.floor((a.length-1)*p)]||0;
const sumField=(field)=>field.reduce((sum,v)=>sum+v,0)*CELL*CELL;
// Published phytoplankton upper envelope and aquatic limitation structure. // 1) Module contract: catches missing named exports before browser release.
approx(phytoplanktonMuMax(10),1.52,.015,'muMax 10C'); assert.equal(typeof fieldResourceIntakeKgPerDay,'function');
approx(phytoplanktonMuMax(20),2.86,.02,'muMax 20C'); const edge=interactionEdge('primary-consumer','producer');
approx(phytoplanktonMuMax(30),5.38,.03,'muMax 30C'); assert.ok(fieldResourceIntakeKgPerDay(8,.3,edge)>fieldResourceIntakeKgPerDay(8,.02,edge));
approx(producerGrowthPerDay(FRESHWATER,20,{light:.5,nutrientN:.8,nutrientP:.4,dissolvedOxygen:0}),phytoplanktonMuMax(20)*.4*.5,1e-12,'aquatic producer limitation');
approx(producerGrowthPerDay(TERRESTRIAL,20,{moisture:1,light:.4}),.03*.4,1e-12,'terrestrial light limitation is applied once');
// Static food-web edges: no runtime interaction API, but the ecological graph remains explicit. // 2) User water edits must remain exact.
assert.equal(interactionEdge('primary-consumer','producer').assimilationEfficiency,.45);
assert.equal(interactionEdge('secondary-consumer','primary-consumer').assimilationEfficiency,.85);
assert.equal(interactionEdge('primary-consumer','secondary-consumer'),null);
// Continuous rates must compose independently of timestep.
const daily=rateToProbability(.7,1),hourly=rateToProbability(.7,DT),combined=1-Math.pow(1-hourly,24);
approx(combined,daily,1e-12,'hourly rate composition');
// Handling time is truly connected to Holling denominator; Rall allometric/temperature scaling is live.
{ {
const edge=interactionEdge('secondary-consumer','primary-consumer'); const w=new World(77), edited=w.water.map((v,i)=>({...v,r:i===0?v.r*.98:v.r}));
const p=rallFeedingParameters(edge,5,.1,20,FRESHWATER),pHot=rallFeedingParameters(edge,5,.1,30,FRESHWATER),term={...p,resourceDensity:2,q:1}; assert.equal(w.setWater(edited),true);
const rate=functionalResponseRatePerDay(p,2,[term],1),slower=functionalResponseRatePerDay({...p,handlingTimeDays:p.handlingTimeDays*3},2,[{...term,handlingTimeDays:p.handlingTimeDays*3}],1); assert.deepEqual(w.water,edited);
const competitor={...p,resourceDensity:4,q:1},withCompetition=functionalResponseRatePerDay(p,2,[term,competitor],1);
assert.ok(rate>slower,'handling time is not connected to Holling denominator');
assert.ok(withCompetition<rate,'multi-resource Holling denominator is not connected');
assert.ok(pHot.attackRate>p.attackRate,'Rall temperature scaling does not increase attack rate');
assert.ok(pHot.handlingTimeDays<p.handlingTimeDays,'Rall temperature scaling does not reduce handling time from 20C to 30C');
assert.notEqual(rallFeedingParameters(edge,5,.1,20,FRESHWATER).attackRate,rallFeedingParameters(edge,5,1,20,FRESHWATER).attackRate,'resource mass does not affect attack rate');
const scaled=rallFeedingParameters(edge,10,.2,20,FRESHWATER);
approx(scaled.attackRate/p.attackRate,Math.pow(2,.62),1e-10,'Rall joint mass scaling attack');
approx(scaled.handlingTimeDays/p.handlingTimeDays,Math.pow(2,-.14),1e-10,'Rall joint mass scaling handling');
} }
// The World predation path uses the multi-resource Holling denominator, not just the standalone helper. // 3) Free-movement smoke test: no spawn lock, widespread freezing, or ping-pong reversal.
{const w=new World(913);for(const a of w.animals)a.dead=true;const predator=w.make(w.species.find(s=>s?.trophicRole==='secondary-consumer').g,w.species.find(s=>s?.trophicRole==='secondary-consumer').id,1000,1000,200),p1=w.make(w.species.find(s=>s?.trophicRole==='primary-consumer').g,w.species.find(s=>s?.trophicRole==='primary-consumer').id,1010,1000,100),p2=w.make(w.species.find(s=>s?.trophicRole==='primary-consumer').g,w.species.find(s=>s?.trophicRole==='primary-consumer').id,1020,1000,100);for(const a of [predator,p1,p2]){a.habitat='陸棲';a.dead=false}predator.structuralMassKg=3.5;p1.structuralMassKg=.1;p2.structuralMassKg=.1;w.animals=[predator,p1];w.hash.rebuild(w.animals);const one=w.predationRateForTarget(predator,p1,20).rate;w.animals.push(p2);w.hash.rebuild(w.animals);const two=w.predationRateForTarget(predator,p1,20).rate;assert.ok(one>0&&two>0&&two<one,'World predation path does not apply shared local prey saturation');} const w=new World(481516), start=new Map(w.animals.map(a=>[a.id,{x:a.x,y:a.y}]));
const track=new Map(w.animals.map(a=>[a.id,{x:a.x,y:a.y,still:0,ticks:0,dx:0,dy:0,moves:0,rev:0}]));
for(let step=0;step<3*24;step++){
w.step();
for(const a of w.animals){
if(a.dead||!track.has(a.id))continue;
const t=track.get(a.id),dx=a.x-t.x,dy=a.y-t.y,d=Math.hypot(dx,dy),last=Math.hypot(t.dx,t.dy);
t.ticks++; if(d<1e-6)t.still++;
if(d>.1){t.moves++;if(last>.1&&(dx*t.dx+dy*t.dy)/(d*last)<-.7)t.rev++;t.dx=dx;t.dy=dy}
t.x=a.x;t.y=a.y;
}
}
const alive=w.animals.filter(a=>!a.dead&&start.has(a.id));
const displacement=alive.map(a=>Math.hypot(a.x-start.get(a.id).x,a.y-start.get(a.id).y));
const still=alive.map(a=>track.get(a.id).still/track.get(a.id).ticks);
const reversal=alive.map(a=>track.get(a.id).rev/Math.max(1,track.get(a.id).moves));
assert.ok(quantile(displacement,.1)>100);
assert.ok(quantile(displacement,.5)>350);
assert.ok(quantile(still,.9)<.3);
assert.ok(quantile(reversal,.9)<.22);
// Reconstruct v38 procedural water without the sqrt(2) radius scale, then check only rough doubling. // 4) Short population smoke: births and at least one net-increase day must be possible.
const W=4800; const pop=new World(481516), firstNextId=pop.nextId;let previous=pop.stats().population,sawIncrease=false;
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v)); for(let day=0;day<14;day++){
function baseWater(seed){let value=(seed^0x397b4c63)>>>0;const random=()=>{let t=value=(value+0x6D2B79F5)>>>0;t=Math.imul(t^t>>>15,t|1);t^=t+Math.imul(t^t>>>7,t|61);return((t^t>>>14)>>>0)/4294967296};const edge=Math.floor(random()*4),radius=320+random()*110,along=.14+random()*.72;const start=edge===0?{x:radius*.42,y:H*along}:edge===1?{x:W-radius*.42,y:H*along}:edge===2?{x:W*along,y:radius*.42}:{x:W*along,y:H-radius*.42};const destination=edge===0?{x:W-300,y:H*(.14+random()*.72)}:edge===1?{x:300,y:H*(.14+random()*.72)}:edge===2?{x:W*(.14+random()*.72),y:H-300}:{x:W*(.14+random()*.72),y:300};const water=[{...start,r:radius,seed:random()*Math.PI*2}];for(let i=1;i<7;i++){const last=water.at(-1),r=290+random()*170,heading=Math.atan2(destination.y-last.y,destination.x-last.x)+(random()-.5)*1.25,step=(last.r+r)*(.43+random()*.12);water.push({x:clamp(last.x+Math.cos(heading)*step,r*.42,W-r*.42),y:clamp(last.y+Math.sin(heading)*step,r*.42,H-r*.42),r,seed:random()*Math.PI*2})}for(const index of [2,4]){const parent=water[index],before=water[index-1],r=270+random()*140,heading=Math.atan2(parent.y-before.y,parent.x-before.x)+(random()<.5?-1:1)*(1.05+random()*.65),step=(parent.r+r)*(.42+random()*.13);water.push({x:clamp(parent.x+Math.cos(heading)*step,r*.42,W-r*.42),y:clamp(parent.y+Math.sin(heading)*step,r*.42,H-r*.42),r,seed:random()*Math.PI*2})}return water} for(let i=0;i<24;i++)pop.step();
function wetCells(water){let n=0;for(let j=0;j<GH;j++)for(let i=0;i<GW;i++)if(inWater(water,(i+.5)*CELL,(j+.5)*CELL))n++;return n} const now=pop.stats().population;if(now>previous)sawIncrease=true;previous=now;
const ratios=[]; }
for(const seed of [481516,1,123456789,0xffffffff]){const base=wetCells(baseWater(seed)),world=new World(seed),now=world.wet.reduce((a,b)=>a+b,0),ratio=now/base;ratios.push(ratio);assert.ok(ratio>1.7&&ratio<2.3,`rough water area ratio ${ratio}`)} assert.ok(pop.nextId>firstNextId);
{const w=new World(77),edited=w.water.map((v,i)=>({...v,r:i===0?v.r*.98:v.r}));assert.equal(w.setWater(edited),true);assert.deepEqual(w.water,edited,'user water geometry was altered')} assert.ok(sawIncrease);
assert.ok(pop.animals.every(a=>[a.x,a.y,a.energy,a.age,a.structuralMassKg].every(Number.isFinite)));
// Hirt maximum-speed coefficients and separate routine/home-range/dispersal paths. // 5) Removed constraints/workarounds must not return.
assert.ok(maximumSpeedMPerDay(3,'running')>routineTravelSpeedMPerDay(3,900,'running')); const productionFiles=['engine.js','worker.js','ecology/movement.js'];
assert.ok(maximumSpeedMPerDay(3,'flying')>maximumSpeedMPerDay(3,'running')); const code=(await Promise.all(productionFiles.map(f=>readFile(new URL('../'+f,import.meta.url),'utf8')))).join('\n');
assert.deepEqual((({a,b,h,i})=>({a,b,h,i}))(locomotionCalibration('flying')),{a:142.8,b:.24,h:2.4,i:-.72}); assert.ok(!/homeRange|homeX|homeY|counter-torque|rotationIndex|angularScale/i.test(code));
assert.deepEqual((({a,b,h,i})=>({a,b,h,i}))(locomotionCalibration('running')),{a:25.5,b:.26,h:22,i:-.60}); assert.ok(!/set-interactions|set-climate|run-batch|schedule-disturbance/i.test(code));
assert.deepEqual((({a,b,h,i})=>({a,b,h,i}))(locomotionCalibration('swimming')),{a:11.2,b:.36,h:19.5,i:-.56});
assert.ok(homeRangeRadiusM(10,'swimming')>homeRangeRadiusM(10,'running'));
assert.ok(sampleDispersalDistanceM(()=>.5,3,'running')>0);
// Cohort resource path is active. console.log(JSON.stringify({ok:true,dtDays:DT,animals:pop.stats().population,births:pop.nextId-firstNextId},null,2));
{const z=updateZooplanktonCohort(.1,.02,1);assert.ok(z.producerConsumedKgPerM2>0);assert.notEqual(z.nextZooplanktonKgPerM2,.02)}
// Reproduction must not kill the parent.
{const w=new World(481516),parent=w.animals.find(a=>w.roleOf(a)==='primary-consumer');w.time=100;parent.age=parent.g.maturityAge+1;parent.energy=w.maxEnergy(parent);parent.lastBirth=w.time-reproductionConfig('primary-consumer').reproductionIntervalDays-1;const before=w.animals.length;w.reproduce(parent);assert.equal(parent.dead,false);assert.ok(w.animals.length>before)}
// Juvenile structural growth is energy-limited rather than age-only.
{const birth=initialStructuralMassKg(10,.2,0,100);approx(birth,2,1e-12,'birth structural mass');const rich=energyLimitedGrowth({structuralMassKg:birth,adultMassKg:10,energyKJ:5000,ageDays:10,maturityAgeDays:100,dtDays:1}),poor=energyLimitedGrowth({structuralMassKg:birth,adultMassKg:10,energyKJ:500,ageDays:10,maturityAgeDays:100,dtDays:1});assert.ok(rich.gainKg>0,'surplus energy did not produce structural growth');assert.ok(rich.energyKJ<5000,'growth did not consume energy');assert.equal(poor.gainKg,0,'growth occurred below reserve floor')}
// Profile switching remains an internal UI operation. Climate series support is internal, not a World/Worker experiment API.
{const w=new World(7);assert.throws(()=>w.setAquaticProfile('bogus'));const c=new ClimateProvider();c.setSeries('surfaceTemperatureC',[12,13,14]);c.setSeries('soilMoisture',[.2,.4,.6]);c.setSeries('nutrientN',[.3,.4,.5]);assert.equal(c.temperatureC(1,{aquatic:true}),13);assert.equal(c.soilMoisture(1,.9),.4);assert.equal(c.value('nutrientN',1),.4)}
// Ordinary stepping: finite state, no spontaneous immigration/speciation, daily movement budget enforced.
const bench=new World(481516),startSpeciesId=bench.nextSpeciesId,start=performance.now();
for(let i=0;i<30*24;i++)bench.step();
const elapsedMs=performance.now()-start,stats=bench.stats(),zooMass=sumField(bench.zooplankton);
assert.equal(startSpeciesId,INITIAL_SPECIES);assert.equal(bench.nextSpeciesId,INITIAL_SPECIES);
assert.ok(Number.isFinite(stats.plant)&&stats.plant>=0);assert.ok(Number.isFinite(zooMass)&&zooMass>=0);
assert.ok(bench.plants.every(Number.isFinite));assert.ok(bench.zooplankton.every(Number.isFinite));
assert.ok(bench.animals.every(a=>[a.x,a.y,a.energy,a.age,a.structuralMassKg,a.movedToday].every(Number.isFinite)));
for(const a of bench.animals){if(a.dead)continue;const mode=bench.species[a.sid]?.locomotionMode||'running',budget=dailyMovementBudgetM(bench.biomass(a),a.g.moveSpeed,mode);assert.ok(a.movedToday<=budget+1e-6,`daily movement budget exceeded: ${a.movedToday} > ${budget}`)}
assert.equal(bench.resourceFields[0].representation,'field');assert.equal(bench.resourceFields[1].representation,'cohort');
assert.deepEqual(Object.keys(stats).sort(),['generation','plant','population','species','temp','time'].sort(),'detailed diagnostic output leaked into UI stats');
// Requested omissions and removed experiment API/output stay absent.
const codeFiles=['../engine.js','../worker.js','../ecology/profiles.js','../ecology/resources.js','../ecology/metabolism.js','../ecology/feeding.js','../ecology/demography.js','../ecology/movement.js','../ecology/schema.js'];
const code=(await Promise.all(codeFiles.map(f=>readFile(new URL(f,import.meta.url),'utf8')))).join('\n');
assert.ok(!/DisturbanceEvent|schedule-disturbance|pesticide|harvest|habitat-loss|nutrient-pulse/.test(code),'human disturbance code remains');
assert.ok(!/waterDepth|depthLayer|mixed-layer|deepLayer/.test(code),'water-depth code remains');
assert.ok(!/doubleWaterArea|target=Math\.min\(GW\*GH,base\*2\)|for\(let iter=0;iter<14/.test(code),'exact water-area optimizer remains');
assert.ok(!/nearest=1400;for\(const b of this\.animals\)/.test(code),'omniscient global prey scan remains');
assert.ok(!/set-interactions|set-climate|run-batch|batch-result|batch-progress|validateBatchRequest|setInteractions|setClimate|introduce\(/.test(code),'removed experiment API remains');
assert.ok(!/consumptionFlux|assimilationFlux|respirationLoss|resourceTurnover|occupiedArea|meanBodyMass|biomassByGroup|populationByGroup|trophicFlux/.test(code),'removed detailed output remains');
// Deterministic repeatability after removing diagnostics.
const a=new World(20260929),b=new World(20260929);for(let i=0;i<7*24;i++){a.step();b.step()}assert.deepEqual(a.stats(),b.stats());assert.equal(sumField(a.zooplankton),sumField(b.zooplankton));
console.log(JSON.stringify({ok:true,dtDays:DT,waterAreaRatios:ratios,benchmark30DaysMs:+elapsedMs.toFixed(1),benchmark30DaysPopulation:stats.population,benchmark30DaysSpecies:stats.species.length,producerBiomassKg:+stats.plant.toFixed(2),zooplanktonBiomassKg:+zooMass.toFixed(2)},null,2));

34
tests/README.md Normal file
View file

@ -0,0 +1,34 @@
# Regression tests
通常の回帰確認:
```bash
node tests/regression.mjs
```
`regression.mjs` は **production `index.html` を唯一のsource of truthとして直接読み込みます**。恒久的なtest用HTMLコピーはありません。
高速スイートで固定する項目:
- production JavaScript構文
- 19本の現行WGSL kernel SHA-256
- direct / FAST routing と有限continuation budget
- operation-limitが数値failureやmembership証明として扱われないこと
- 12点adaptive initialIter probe
- 12→必要時+12 operation-limit scoring(winner 1本だけbuild)
- exact interior certificate / integer-grid numeric history reuse
- guided/deep GPU-resident referenceのnearby reuse参加
- idle Workerの確実なterminate/revoke
- correction lazy compileが実使用5 pipelineだけであること
- cancelRenderのexport取消・generation更新
- export metadata共通builder
- reference / FAST reference / precision fallback Workerの小規模数値回帰
- 削除済みlegacy/dead subsystem・write-only stateがbundleへ再混入しないこと
実ブラウザ/WebGPU E2Eが必要な場合のみ:
```bash
node tests/browser_smoke.mjs 9333
```
`browser_smoke.mjs` はproduction `index.html`から一時的な診断HTMLを生成し、終了時に削除します。テスト用アプリ本体のコピーは保持しません。

68
tests/browser_smoke.mjs Normal file
View file

@ -0,0 +1,68 @@
// Optional real-browser WebGPU smoke test.
// Starts from the production index.html and injects a temporary test-only API.
// Usage: node tests/browser_smoke.mjs [port=9333]
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import {fileURLToPath,pathToFileURL} from 'node:url';
import {setTimeout as sleep} from 'node:timers/promises';
const port=Number(process.argv[2]||9333);
const prodUrl=new URL('../index.html',import.meta.url),generatedUrl=new URL('./.browser-smoke.generated.html',import.meta.url);
const prodHtml=await fs.readFile(prodUrl,'utf8');
const marker='// ── boot / teardown';
assert.equal(prodHtml.includes(marker),true,'boot marker missing');
const hook=String.raw`
function testDecimalRequiredBits(s){s=String(s).trim().replace(/^[+-]/,'');const p=s.toLowerCase().split('e'),f=(p[0].split('.')[1]||'').length,e=p[1]?parseInt(p[1],10):0;return Math.max(64,Math.ceil(Math.max(0,f-e)*Math.log2(10))+32)}
function testFromDec(s,bits){s=String(s).trim();let neg=s.startsWith('-');if(neg)s=s.slice(1);if(s.startsWith('+'))s=s.slice(1);const p=s.toLowerCase().split('e'),mant=p[0],exp=p[1]?parseInt(p[1],10):0,a=mant.split('.'),i=a[0]||'0',f=a[1]||'';let digits=(i+f).replace(/^0+(?=\d)/,'')||'0',places=f.length-exp;if(places<0){digits+='0'.repeat(-places);places=0}const den=10n**BigInt(places),v=(BigInt(digits)*(1n<<BigInt(bits))+den/2n)/den;return neg?-v:v}
async function waitForTestState(predicate,label,timeout=120000){const start=performance.now();for(;;){if(state.drawState==='ERROR')throw new Error(state.gpuError||'render failed');if(predicate())return true;if(performance.now()-start>=timeout)throw new Error(label+' timeout');if(state.dirty&&!state.rendering)schedule();await new Promise(resolve=>setTimeout(resolve,20))}}
globalThis.__MANDEL_TEST__={
async ensureGpuReady(){const r=renderer||await initRenderer();return{ready:!!r,navigatorGpu:!!navigator.gpu,renderer:!!renderer,gpuInitFailed:state.gpuInitFailed,gpuError:state.gpuError}},
async setView({re,im,span,bits,baseIter=350,adaptive=false,continuationBudget=0,quality=null,fixed=false}){const ready=await this.ensureGpuReady();if(!ready.ready)throw new Error('WebGPU initialization failed: '+JSON.stringify(ready));cancelRender();state.bits=bits||Math.max(256,testDecimalRequiredBits(re),testDecimalRequiredBits(im),testDecimalRequiredBits(span));state.re=fixed?BigInt(re):testFromDec(re,state.bits);state.im=fixed?BigInt(im):testFromDec(im,state.bits);state.span=fixed?BigInt(span):testFromDec(span,state.bits);state.baseIter=baseIter;state.adaptive=adaptive;state.continuationBudget=Math.max(0,Math.min(150000,Math.floor(continuationBudget)));state.quality='standard';if(quality)applyQuality(quality);else if(!state.continuationBudget)applyQuality('standard');ensurePrecision();resize();markDirty(false);const start=performance.now();while((state.dirty||state.rendering)&&performance.now()-start<120000){schedule();await new Promise(r=>setTimeout(r,20))}if(state.dirty||state.rendering)throw new Error('test render timeout');return this.state()},
async waitForFinal({timeout=120000}={}){await waitForTestState(()=>!state.dirty&&!state.rendering&&state.fieldView?.complete===true&&state.renderClock?.status==='complete','final',timeout);return this.state()},
async waitForNumericComplete({timeout=120000}={}){const result=await this.waitForFinal({timeout});if(result.numericalFailures)throw new Error('numeric recovery ended with '+result.numericalFailures+' unresolved failures');return result},
async sampleMeta(points){if(!renderer?.frame)throw new Error('GPU field unavailable');const idx=points.map(([x,y])=>y*renderer.frame.w+x);return Array.from(await renderer.readMeta(idx))},
state:()=>{const reasons=state.unknownReasons||{};return{bits:state.bits,re:state.re.toString(),im:state.im.toString(),span:state.span.toString(),width:canvas.width,height:canvas.height,iter:maxIter(),effectiveIter:state.fieldView?.iter??maxIter(),continuationBudget:state.continuationBudget,quality:state.quality,rendering:state.rendering,renderClock:state.renderClock?{...state.renderClock}:null,backend:state.fieldView?.backend||null,fastExtended:!!state.fieldView?.fastExtended,referenceReused:!!state.fieldView?.referenceReused,finiteBudgetComplete:!!state.fieldView?.frontier?.finiteBudgetComplete,membershipCertified:!!state.fieldView?.membershipCertified,numericalFailures:numericalFailureCount({reasons}),unknownReasons:state.unknownReasons,kernelVersion:G.version}},
async panPixels(dx,dy){pan(dx,dy);const start=performance.now();while((state.dirty||state.rendering)&&performance.now()-start<120000){schedule();await new Promise(r=>setTimeout(r,20))}if(state.dirty||state.rendering)throw new Error('test pan timeout');return this.state()},
async smokeExportTile({w=48,h=32,ss=1}={}){if(!renderer)throw new Error('WebGPU renderer unavailable');const snap=snapshot(),iter=maxIter(),fastExtended=fastNeedsExtended(snap,w),fastContext=fastExtended?await fastRefs.request(snap,iter,w,h):null,args={snap,iter,fastContext,fullW:w,fullH:h,tileX:0,tileY:0,w,h},result=ss===2?await renderer.renderTileRGBA2x(args):await renderer.renderTileRGBA({...args,sampleX:.5,sampleY:.5,edgeAA:false}),data=result.rgba;let checksum=2166136261>>>0;for(const v of data){checksum^=v;checksum=Math.imul(checksum,16777619)>>>0}return{length:data.length,expected:w*h*4,checksum,ss,unresolved:result.unresolved||0}}
};
`;
const generatedHtml=prodHtml.replace(marker,hook+'\n'+marker);
await fs.writeFile(generatedUrl,generatedHtml);
const viewerUrl=pathToFileURL(fileURLToPath(generatedUrl)).href;
let ws=null;
try{
const response=await fetch(`http://127.0.0.1:${port}/json/list`);
assert.ok(response.ok,'Remote debugging endpoint unavailable');
const pages=(await response.json()).filter(t=>t.type==='page');
assert.equal(pages.length,1,'Use one dedicated test tab');
ws=new WebSocket(pages[0].webSocketDebuggerUrl);
await new Promise((resolve,reject)=>{ws.addEventListener('open',resolve,{once:true});ws.addEventListener('error',reject,{once:true})});
let serial=0;const pending=new Map(),exceptions=[];
ws.addEventListener('message',event=>{
const m=JSON.parse(event.data);
if(m.method==='Runtime.exceptionThrown')exceptions.push(m.params.exceptionDetails.exception?.description||m.params.exceptionDetails.text);
const p=pending.get(m.id);if(!p)return;pending.delete(m.id);clearTimeout(p.timer);
if(m.error)p.reject(new Error(JSON.stringify(m.error)));else p.resolve(m.result);
});
function command(method,params={}){const id=++serial;return new Promise((resolve,reject)=>{const timer=setTimeout(()=>{pending.delete(id);reject(new Error(method+' timeout'))},180000);pending.set(id,{resolve,reject,timer});ws.send(JSON.stringify({id,method,params}))})}
async function evaluate(expression){const r=await command('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});if(r.exceptionDetails)throw new Error(r.exceptionDetails.exception?.description||r.exceptionDetails.text);return r.result.value}
await command('Runtime.enable');await command('Page.enable');await command('Page.navigate',{url:viewerUrl});
for(let i=0;i<200;i++){if(await evaluate('typeof globalThis.__MANDEL_TEST__ === "object"'))break;if(i===199)throw new Error('__MANDEL_TEST__ timeout');await sleep(50)}
const ready=await evaluate('__MANDEL_TEST__.ensureGpuReady()');assert.equal(ready.ready,true,JSON.stringify(ready));
const reset=await evaluate(`__MANDEL_TEST__.setView({re:'-0.5',im:'0',span:'3.4',bits:256,baseIter:350,adaptive:true,quality:'fast'})`);
assert.equal(reset.continuationBudget,4096);assert.equal(reset.numericalFailures,0);
const finalReset=await evaluate('__MANDEL_TEST__.waitForNumericComplete()');assert.equal(finalReset.finiteBudgetComplete,true);
const deep=await evaluate(`__MANDEL_TEST__.setView({re:'-0.743643887037151',im:'0.13182590420533',span:'0.00000000000034',bits:256,baseIter:350,adaptive:true,quality:'standard'})`);
assert.equal(deep.continuationBudget,16384);assert.equal(deep.fastExtended,true);
const finalDeep=await evaluate('__MANDEL_TEST__.waitForNumericComplete()');assert.equal(finalDeep.numericalFailures,0);
const samples=await evaluate(`__MANDEL_TEST__.sampleMeta([[0,0],[Math.floor(innerWidth/2),Math.floor(innerHeight/2)]])`);assert.equal(samples.length,2);
const ss1=await evaluate('__MANDEL_TEST__.smokeExportTile({w:24,h:16,ss:1})');assert.equal(ss1.length,ss1.expected);assert.ok(ss1.checksum>0);
const ss2=await evaluate('__MANDEL_TEST__.smokeExportTile({w:16,h:12,ss:2})');assert.equal(ss2.length,ss2.expected);assert.ok(ss2.checksum>0);
const panned=await evaluate('__MANDEL_TEST__.panPixels(24,0)');assert.equal(panned.numericalFailures,0);
assert.deepEqual(exceptions,[]);
console.log('PASS: optional Chromium WebGPU smoke (generated from production source)');
}finally{
ws?.close();
await fs.rm(generatedUrl,{force:true});
}

22
tests/kernel_hashes.json Normal file
View file

@ -0,0 +1,22 @@
{
"version": "7980409b2607c884489f4551f410a57de46b1361a061ac49b27dc0a2259d3e02",
"INTERIOR_MASK_WGSL": "00e8a502c23fe3468abab23365bc13cae3e35ecf401dcfe6e9a2f92f810d1eeb",
"PRECISION_SCATTER_WGSL": "428ff66314ed0d69bc3bef83d5d0adc554bd89eb4a7137a4a8910f2d1016b03a",
"DIRECT_F32_WGSL": "d695042d016fb7060923184db20df42e480af4b223714c5eaca195a8b9b2f18b",
"ACTIVE_PREPARE_WGSL": "27bb3af8310bd0784888f2a648896e1fcf5872b4a9a3ade883ebe95a4868fa6e",
"OPERATION_LIMIT_QUEUE_WGSL": "ca83fa6e239a4b429431d65b79f3c2d1b7db840885dbf7c60aa8faa8f07c9026",
"FAST_PERTURB_WGSL": "c0dcbc5139c2e57e5cee6340024c841d379ffe57d7035cb3dc2a89c8cac50f65",
"FAST_PERTURB_POSTSTATS_WGSL": "92d99eaaa928da7920b60700c0ba5520fbf86bb783373ed7545e3d8c2dcffd45",
"DEEP_ACTIVE_RESUME_INIT_WGSL": "fc50c00738705568a76bafc10e092e2500977387858839b5f46c83cb870822cf",
"DEEP_ACTIVE_CONTINUE_WGSL": "a78d21e31220b3740a0ad33506594d4c79bed1c0d95a1662c79621d471aefcaf",
"DEEP_BUCKET_HIST_WGSL": "b4237a51bd5200cc9c95efe086123c22a1f0a27fd82dcde5786c4628b2b50f9d",
"DEEP_BUCKET_PREFIX_WGSL": "e8fb3f566d69ef87a61f1aa61f4c56ec26614711d7897afa2f8bfdcf5088c3da",
"DEEP_BUCKET_SCATTER_WGSL": "9f3c75251ddd34d5a0e1ed4088f2801fd675010c73121d9b86393a9ab55e1389",
"DEEP_CORRECT_WGSL": "48f01249578b582fc7f207e44875ba8a18fc4f9c48300209648da85e50ac5d90",
"DEEP_CORRECT_QUEUE_WGSL": "b15fce3347f0b82689f4a0d0a712bda2dfa58a808e6a6972f49929c1782da616",
"UNKNOWN_STATS_WGSL": "c145312a819014dd3b2710cfa1c8f1d6ca4210d9c0a8f1f661c713013c69f8d2",
"SYMMETRY_COPY_WGSL": "658e1cf2b9f40d8e759abe2269c041f582a80727cb24e2f48a405cb8676d70e1",
"COLOR_WGSL": "21c5485e14905305124450c7909ac355ff5aa17b330e35d68517524498813323",
"AA_RESOLVE_WGSL": "f353adafc837c170da1627f8b0cfd924d9748954376e4a427190917da4c92342",
"PRESENT_WGSL": "651bf13de25b023c3f0ab0d0287b1d27cfd8e11677aca53629ce0a8526c7c5bc"
}

201
tests/regression.mjs Normal file
View file

@ -0,0 +1,201 @@
import assert from 'node:assert/strict';
import fsSync from 'node:fs';
import fs from 'node:fs/promises';
import vm from 'node:vm';
import {createHash} from 'node:crypto';
const prodUrl=new URL('../index.html',import.meta.url);
const prodHtml=await fs.readFile(prodUrl,'utf8');
const extract=html=>[...html.matchAll(/<script[^>]*>([\s\S]*?)<\/script>/g)].map(m=>m[1]);
const prodScripts=extract(prodHtml);
assert.equal(prodScripts.length,2);
for(const script of prodScripts)new vm.Script(script);
// Production is the single source of truth. No copied test HTML is allowed.
assert.equal(fsSync.existsSync(new URL('./index.test.html',import.meta.url)),false);
assert.doesNotMatch(prodHtml,/TEST_MODE|__MANDEL_TEST__|\bruntime\b|generationMetric|activeBatchLimit/);
assert.doesNotMatch(prodHtml,/truestate|returnfinishRenderClock/);
// Removed legacy/dead subsystems and stale diagnostics must not creep back.
for(const pattern of [
/DEEP_PERTURB/,/FAILURE_TILE_MAP_WGSL/,/DEEP_COMPACT_(?:INIT|CONTINUE)_WGSL/,
/referenceCandidates\(/,/refinePixelFrontierLegacy/,/refinePixelFrontierFinite/,
/pixelFrontierIterationFloor/,/prefetchFinalReference/,/readMetaAll/,/readFieldAll/,
/renderTileMeta/,/isOperationLimitMeta/,/readUnresolved\(/,
/numericRecoveryPending|numericRecoveryRunning|qualityFinalIter|qualityStage/,
/adaptiveProbeScorer|guidedReferenceScorer/,
/\bFIELD_INTERIOR_LIKELY\b/,/function fromDec\(/,/function decimalRequiredBits\(/,
/state\.(?:lastEngine|frontierRounds|frontierPixels|frontierEscaped|frontierIter|frontierMinimumIter|frontierConverged|gpuUnavailable|qualityIter|refineMs|lastRender|gpuStage)/,
/this\.(?:adapterInfo|compilation|uncapturedErrors)/,
/cpuFrameRgba/,/fast-coverage-repair/,/mode 2 repairs only untouched coverage holes/
]) assert.doesNotMatch(prodHtml,pattern,String(pattern));
// Pin the surviving kernels.
const kc={};vm.runInNewContext(prodScripts[0],kc);const kernels=kc.MANDEL_WEBGPU_KERNELS;
assert.equal(Object.keys(kernels).length,20,'19 WGSL kernels + version');
const expected=JSON.parse(await fs.readFile(new URL('./kernel_hashes.json',import.meta.url),'utf8'));
const hashes=Object.fromEntries(Object.entries(kernels).map(([k,v])=>[k,createHash('sha256').update(v).digest('hex')]));
assert.deepEqual(hashes,expected);
assert.doesNotMatch(kernels.COLOR_WGSL,/reason\s*==\s*6u[^}]*interior_color/s);
assert.match(kernels.COLOR_WGSL,/Finite-budget survivors are unresolved membership/);
assert.match(kernels.COLOR_WGSL,/textureStore\(outTex,vec2<i32>\(gid\.xy\),vec4<f32>\(0\.0\)\)/);
assert.match(kernels.DIRECT_F32_WGSL,/candPeriod/);
assert.match(kernels.DIRECT_F32_WGSL,/FIELD_INTERIOR_HEURISTIC/);
assert.doesNotMatch(kernels.FAST_PERTURB_WGSL,/pack_meta\(n,FIELD_INTERIOR_HEURISTIC\)/);
assert.match(kernels.FAST_PERTURB_WGSL,/series_start/);
assert.match(prodHtml,/SERIES_APPROXIMATION_ENABLED=false/);
assert.match(prodHtml,/deferPrimaryColor=deferColor\|\|fastExtended/);
assert.doesNotMatch(kernels.FAST_PERTURB_WGSL,/unknownOnly==2u/);
assert.match(prodHtml,/knownInteriorViewportMayOverlap/);
assert.match(prodHtml,/numericHistoryScaleEligible/);
assert.match(prodHtml,/ensureExportAaSamples/);
assert.match(prodHtml,/exportWorkspaceDestroy\(\);exportJob\.active=false/);
assert.match(kernels.DEEP_ACTIVE_CONTINUE_WGSL,/pixelMap/);
assert.match(kernels.DEEP_ACTIVE_CONTINUE_WGSL,/states\[slot\]/);
function appContext(){
const element={width:800,height:600,clientWidth:800,clientHeight:600,style:{},hidden:false,value:'',textContent:'',disabled:false,addEventListener(){},classList:{toggle(){}},setAttribute(){},removeAttribute(){},showModal(){},close(){},toBlob(){},getBoundingClientRect(){return{left:0,top:0,width:800,height:600}}};
let revoked=0;
const context={
MANDEL_WEBGPU_KERNELS:kernels,document:{querySelector:()=>element,getElementById:()=>element,documentElement:{classList:{toggle(){}}},body:{classList:{toggle(){}},appendChild(){}}},
navigator:{hardwareConcurrency:4,deviceMemory:8},location:{search:'',hash:''},history:{replaceState(){}},performance,
URLSearchParams,URL:{createObjectURL:()=>`blob:test-${Math.random()}`,revokeObjectURL:()=>{revoked++}},
TextEncoder,Blob:class{},console,addEventListener(){},requestAnimationFrame:()=>1,cancelAnimationFrame(){},
setTimeout,clearTimeout,setInterval,clearInterval,innerWidth:800,innerHeight:600,devicePixelRatio:1,
localStorage:{getItem(){return null},setItem(){},removeItem(){}},
};
const cut=prodScripts[1].indexOf('// ── boot / teardown');
assert.ok(cut>0,'boot marker missing');
const code=prodScripts[1].slice(0,cut)+`\n`+
`globalThis.internal={state,refs,referenceScorer,fastRefs,precisionFallbackPool,ReferenceService,FastReferenceService,PrecisionFallbackService,WebGpuRenderer,chooseBackend,numericalFailureCount,pixelPrecisionBits,certifiedInteriorTiles,referenceWorkerSource,fastReferenceWorkerSource,precisionFallbackWorkerSource,adaptiveProbeChoice,adaptiveProbeSources,operationLimitReferenceEligible,failureGuidedReferenceEligible,tryOperationLimitGuidedReference,integerGridReuseShift,nearbyReferenceCandidates,refinePixelFrontier,fixedSourceForPixel,applyQuality,maxIter,buildExportMetadata,cancelRender,exportJob,renderFrame,cpuFallbackWorkerSource,recoverNumericalGuided};})();`;
vm.runInNewContext(code,context);
context.revoked=()=>revoked;
return context;
}
function fromDec(s,bits=256){
s=String(s).trim();let neg=s.startsWith('-');if(neg)s=s.slice(1);if(s.startsWith('+'))s=s.slice(1);
const [mant,es='0']=s.toLowerCase().split('e'),exp=parseInt(es,10)||0,[i='0',f='']=mant.split('.');
let digits=(i+f).replace(/^0+(?=\d)/,'')||'0',places=f.length-exp;if(places<0){digits+='0'.repeat(-places);places=0}
const den=10n**BigInt(places),v=(BigInt(digits)*(1n<<BigInt(bits))+den/2n)/den;return neg?-v:v;
}
const c=appContext(),a=c.internal;
// Finite quality presets remain explicit.
for(const [q,budget] of [['fast',4096],['standard',16384],['high',32768]]){a.applyQuality(q);assert.equal(a.state.continuationBudget,budget)}
for(const old of ['fine','validate']){a.applyQuality(old);assert.equal(a.state.continuationBudget,32768)}
// Backend selection remains direct vs FAST only; primary deep routing is gone.
for(const [span,expectedBackend] of [['3.4','direct'],['0.00000000000034','fast-extended']]){
const snap={bits:256,re:fromDec('-0.743643887037151'),im:fromDec('0.13182590420533'),span:fromDec(span)};
assert.equal(a.chooseBackend(snap,800).backend,expectedBackend);
}
assert.doesNotMatch(a.chooseBackend.toString(),/deep/);
// Finite operation limit is not a numerical failure or a membership proof.
assert.equal(a.numericalFailureCount({reasons:{errorBound:1,escapeUncertain:1,referenceEnd:1,rebaseGap:1,range:1,operationLimit:99}}),5);
{
Object.assign(a.state,{continuationBudget:1024,token:7});const snap={bits:256,re:0n,im:0n,span:fromDec('3.4')};
const r={beginGpuOperationLimitContinuation:async()=>({active:5}),readUnresolvedStats:async()=>({total:5,reasons:{operationLimit:5}})};
const result=await a.refinePixelFrontier(r,snap,1024,7,{total:5,reasons:{operationLimit:5}});
assert.equal(result.finiteBudgetComplete,true);assert.equal(result.membershipCertified,false);
}
{
Object.assign(a.state,{continuationBudget:1024,token:8});const snap={bits:256,re:0n,im:0n,span:fromDec('3.4')};
const r={beginGpuOperationLimitContinuation:async()=>({active:0}),readUnresolvedStats:async()=>({total:0,reasons:{operationLimit:0}})};
const result=await a.refinePixelFrontier(r,snap,1024,8,{total:0,reasons:{operationLimit:0}});
assert.equal(result.membershipCertified,true);
}
{
Object.assign(a.state,{continuationBudget:1024,token:9});const snap={bits:256,re:0n,im:0n,span:fromDec('3.4')};
const r={beginGpuOperationLimitContinuation:async()=>({active:0}),readUnresolvedStats:async()=>({total:0,heuristicInterior:1,reasons:{operationLimit:0}})};
const result=await a.refinePixelFrontier(r,snap,1024,9,{total:0,heuristicInterior:1,reasons:{operationLimit:0}});
assert.equal(result.membershipCertified,false);
}
// The 12-point adaptive probe policy stays intact.
{
const snap={bits:256,re:0n,im:0n,span:fromDec('3.4')};
assert.equal(a.adaptiveProbeSources(snap,800,600).length,12);
assert.equal(a.adaptiveProbeChoice(Array(12).fill(3000),3870,350).iter,3870);
assert.equal(a.adaptiveProbeChoice(Array(12).fill(7000),3870,350).iter,350);
}
// Operation-limit scoring is staged: first 12, optionally second 12, one build.
{
Object.assign(a.state,{token:20,bits:256});const snap={bits:256,re:0n,im:0n,span:fromDec('3.4')},stats={reasons:{operationLimit:1000}},current={refLen:100};
const r={sampleActiveIndices:async()=>Uint32Array.from({length:24},(_,i)=>i)};
const oldScore=a.referenceScorer.scoreFixedCandidates,oldBuild=a.referenceScorer.requestFixed;
let scores=0,builds=0;
a.referenceScorer.scoreFixedCandidates=async()=>{scores++;return{winner:0,bestScore:300,precisionBits:160,scoreMs:1}};
a.referenceScorer.requestFixed=async(_s,_i,_w,_h,src)=>{builds++;return{refLen:300,checkpointMismatch:false,source:src,buildMs:1}};
let got=await a.tryOperationLimitGuidedReference(r,{},snap,300,20,stats,current,new Set());
assert.equal(got.used,true);assert.equal(got.usedSecond,false);assert.equal(scores,1);assert.equal(builds,1);
scores=0;builds=0;
a.referenceScorer.scoreFixedCandidates=async()=>{scores++;return scores===1?{winner:0,bestScore:180,precisionBits:160,scoreMs:1}:{winner:0,bestScore:250,precisionBits:160,scoreMs:1}};
a.referenceScorer.requestFixed=async(_s,_i,_w,_h,src)=>{builds++;return{refLen:250,checkpointMismatch:false,source:src,buildMs:1}};
got=await a.tryOperationLimitGuidedReference(r,{},snap,300,20,stats,current,new Set());
assert.equal(got.used,true);assert.equal(got.usedSecond,true);assert.equal(scores,2);assert.equal(builds,1);
a.referenceScorer.scoreFixedCandidates=oldScore;a.referenceScorer.requestFixed=oldBuild;
}
// Exact certificates remain strict at cardioid/bulb boundaries.
{
const one=1n<<256n;
for(const [re,expectedInside] of [[0n,true],[-one,true],[one/4n,false],[one/4n-1n,true],[-5n*one/4n,false],[-5n*one/4n+1n,true]]){
assert.equal(a.certifiedInteriorTiles({bits:256,re,im:0n,span:0n},1,1).pixels===1,expectedInside,re.toString());
}
}
// Stable integer-grid history reuse remains exact.
{
const bits=256,span=(1n<<240n)+12345n,old={bits,re:0n,im:0n,span,w:800,h:600},next={bits,re:-(span*40n)/800n,im:0n,span,w:800,h:600};
const shift=a.integerGridReuseShift(old,next,800,600);assert.ok(shift);assert.equal(shift.pixels,456000);
assert.equal(a.integerGridReuseShift(old,{...next,re:-(span*81n)/1600n},800,600),null);
}
assert.match(a.nearbyReferenceCandidates.toString(),/deepContexts/,'GPU-resident guided refs participate in nearby reuse');
// Worker lifecycle: idle destroy still terminates/revokes resources.
for(const Service of [a.FastReferenceService,a.PrecisionFallbackService]){
const s=new Service();let terminated=0;s.worker={terminate(){terminated++}};s.url='blob:idle';s.destroy();assert.equal(terminated,1);assert.equal(s.worker,null);assert.equal(s.url,'');
}
// Correction lazy compile contains only the five actually used pipelines.
{
const src=a.WebGpuRenderer.prototype.ensureCorrectionPipelines.toString();
for(const name of ['DEEP_BUCKET_HIST_WGSL','DEEP_BUCKET_PREFIX_WGSL','DEEP_BUCKET_SCATTER_WGSL','DEEP_CORRECT_WGSL','DEEP_CORRECT_QUEUE_WGSL'])assert.match(src,new RegExp(name));
assert.doesNotMatch(src,/DEEP_PERTURB/);assert.equal((src.match(/this\.module\(/g)||[]).length,5);
}
// Render cancellation must cancel export and advance the generation token.
{
Object.assign(a.state,{token:40,rendering:true,recolorPending:true,deferNumericPublish:true,renderClock:null});a.exportJob.active=true;a.exportJob.cancelled=false;
a.cancelRender();assert.equal(a.exportJob.cancelled,true);assert.equal(a.state.token,41);assert.equal(a.state.rendering,false);
a.exportJob.active=false;
}
// Both export paths share one metadata schema builder.
{
Object.assign(a.state,{adaptive:true,baseIter:350,unknownReasons:{operationLimit:7},fieldView:{backend:'pixel-frontier-fast-extended',membershipCertified:false,frontier:{finiteBudgetComplete:true,policy:'explicit-gpu-resident-finite-budget'}}});
const snap={bits:256,re:1n,im:2n,span:3n},palette={id:0,cycle:.008,shift:.18,edgeAA:false};
const completed=a.buildExportMetadata({snap,iter:4096,w:800,h:600,palette,completedField:true,exportPipeline:'completed-numeric-field'});
const rerender=a.buildExportMetadata({snap,iter:4096,w:1600,h:1200,ss:2,palette:{...palette,edgeAA:true},fastExtended:true,strict:true,unresolvedSamples:3,exportPipeline:'tiles',exportTileSize:128,exportRingDepth:1});
for(const key of ['format','rendererVersion','backend','numericEngine','finiteBudgetComplete','membershipCertified','precisionPolicy','pixelContract','width','height','supersampling','numericSamples','unresolvedSamples','iterationPolicy','view','palette','exportPipeline','shaderVersion']){assert.ok(key in completed,key);assert.ok(key in rerender,key)}
assert.equal(completed.operationLimit,7);assert.equal(rerender.exportTileSize,128);assert.equal(rerender.exportRingDepth,1);assert.equal(rerender.membershipCertified,false);
}
// Run surviving CPU workers at tiny budgets.
function worker(source,message){let result;const wc={self:{},postMessage:r=>{result=r},performance};vm.runInNewContext(source,wc);wc.self.onmessage({data:message});assert.ok(result);assert.notEqual(result.type,'error',result.error);return result}
{
const scale=1n<<256n,message={type:'build',id:1,key:'tiny',bits:256,sourceBits:256,targetBits:128,re:'0',im:'0',span:'0',width:1,height:1,iter:64};
const guarded=worker(a.referenceWorkerSource(),message),fast=worker(a.fastReferenceWorkerSource(),message);
assert.equal(guarded.checkpointMismatch,false);assert.equal(guarded.refLen,64);assert.equal(fast.refLen,64);assert.deepEqual(Buffer.from(guarded.refs),Buffer.from(fast.refs));
assert.equal(fast.series.jump,0);assert.equal(fast.series.coeffs.length,8);
const escaped=worker(a.precisionFallbackWorkerSource(),{type:'solve',id:2,bits:256,targetBits:128,re:(3n*scale).toString(),im:'0',span:'0',width:1,height:1,iter:64,terminalClass:-1,indices:Uint32Array.of(0).buffer});
assert.equal(new Uint8Array(escaped.accepted)[0],1);assert.equal(new Uint32Array(escaped.meta)[0]>>>28,1);
const cpu=worker(a.cpuFallbackWorkerSource(),{type:'render',id:3,w:4,h:4,y0:0,y1:4,maxIter:64,re:-.5,im:0,span:3.4,style:{palette:0,cycle:1,shift:0}});
assert.equal(cpu.type,'done');assert.equal(new Uint8ClampedArray(cpu.rgba).length,4*4*4);assert.ok(cpu.work>0);
}
assert.match(a.recoverNumericalGuided.toString(),/FAILURE_GUIDED_REF_MAX_PASSES/);
console.log('PASS: single-source finite viewer regression (production JS, 19 WGSL kernels, staged scoring, lifecycle, export metadata)');

View file

@ -1,3 +1,4 @@
export const NORMAL_PLAYBACK_DAYS_PER_SECOND=1; // x1 playback target: 1 simulated day per real second.
// Simulation time is measured in days. Display day + hour/minute without implying wall-clock time. // Simulation time is measured in days. Display day + hour/minute without implying wall-clock time.
export function formatElapsed(days){ export function formatElapsed(days){
const totalMinutes=Math.max(0,Math.floor(days*24*60+1e-8)),d=Math.floor(totalMinutes/1440),h=Math.floor(totalMinutes%1440/60),m=totalMinutes%60; const totalMinutes=Math.max(0,Math.floor(days*24*60+1e-8)),d=Math.floor(totalMinutes/1440),h=Math.floor(totalMinutes%1440/60),m=totalMinutes%60;

View file

@ -1,6 +1,7 @@
import {World,DT} from './engine.js?v=41'; import {World,DT} from './engine.js?v=45';
import {roleCode} from './ecology/feeding.js'; import {roleCode} from './ecology/feeding.js?v=45';
const seedBuffer=new Uint32Array(1);crypto.getRandomValues(seedBuffer);const world=new World(seedBuffer[0]);let speed=1,statMode=false,last=performance.now(),debt=0,lastSend=0,lastMeasure=last,steps=0,actual=0,selected=null,limited=false,lastFieldEpoch=-1,lastSummary=0,forceSnapshot=true,lastHistoryTime=-1,lastEvent=null,awaitingAck=false,waterDirty=true,rocksDirty=true,moistureDirty=true; import {NORMAL_PLAYBACK_DAYS_PER_SECOND} from './time.js?v=45';
const seedBuffer=new Uint32Array(1);crypto.getRandomValues(seedBuffer);const world=new World(seedBuffer[0]);let speed=1,statMode=false,last=performance.now(),debt=0,lastSend=0,lastMeasure=last,steps=0,actual=0,selected=null,limited=false,lastSummary=0,forceSnapshot=true,lastHistoryTime=-1,lastEvent=null,awaitingAck=false,waterDirty=true,rocksDirty=true,moistureDirty=true,lastFieldSend=0;
onmessage=({data:d})=>{ onmessage=({data:d})=>{
if(d.type==='ack'){awaitingAck=false;return} if(d.type==='ack'){awaitingAck=false;return}
@ -13,9 +14,9 @@ onmessage=({data:d})=>{
function send(){ function send(){
if(awaitingAck)return;const now=performance.now(),summary=forceSnapshot||now-lastSummary>850,field=!statMode&&(forceSnapshot||Math.floor(world.tick/20)!==lastFieldEpoch),a=world.animals.find(a=>a.id===selected&&!a.dead);const packet={type:'state',actual,limited,statMode};const alive=statMode?[]:world.animals.filter(a=>!a.dead),data=new Float32Array(alive.length*7),ids=new Uint32Array(alive.length),sids=new Uint32Array(alive.length); if(awaitingAck)return;const now=performance.now(),summary=forceSnapshot||now-lastSummary>650,field=!statMode&&(forceSnapshot||waterDirty||rocksDirty||moistureDirty||now-lastFieldSend>750),a=world.animals.find(a=>a.id===selected&&!a.dead);const packet={type:'state',actual,limited,statMode};const alive=statMode?[]:world.animals.filter(a=>!a.dead),data=new Float32Array(alive.length*7),ids=new Uint32Array(alive.length),sids=new Uint32Array(alive.length);
for(let i=0;i<alive.length;i++){const a=alive[i],j=i*7;ids[i]=a.id;sids[i]=a.sid;data[j]=a.x;data[j+1]=a.y;data[j+2]=world.radius(a);data[j+3]=a.vx;data[j+4]=a.vy;data[j+5]=roleCode(world.species[a.sid]?.trophicRole);data[j+6]=a.g.waterAffinity}packet.animalData=data;packet.animalIds=ids;packet.animalSids=sids; for(let i=0;i<alive.length;i++){const a=alive[i],j=i*7;ids[i]=a.id;sids[i]=a.sid;data[j]=a.x;data[j+1]=a.y;data[j+2]=world.radius(a);data[j+3]=a.vx;data[j+4]=a.vy;data[j+5]=roleCode(world.species[a.sid]?.trophicRole);data[j+6]=a.g.waterAffinity}packet.animalData=data;packet.animalIds=ids;packet.animalSids=sids;
if(summary){packet.stats=world.stats();const newest=world.history.at(-1)?.time;if(forceSnapshot||newest!==lastHistoryTime){packet.history=world.history;lastHistoryTime=newest}if(forceSnapshot||world.events[0]!==lastEvent){packet.events=world.events;lastEvent=world.events[0]}packet.selected=a?{id:a.id,sid:a.sid,g:a.g,age:a.age,energy:a.energy,stamina:a.stamina,maxStamina:world.staminaCapacity(a),concealment:world.concealmentAt(a.x,a.y),action:a.action,parents:a.parents,generation:a.generation,mass:world.biomass(a),maxEnergy:world.maxEnergy(a)}:null;lastSummary=now} if(summary){packet.stats=world.stats();const newest=world.history.at(-1)?.time;if(forceSnapshot||newest!==lastHistoryTime){packet.history=world.history;lastHistoryTime=newest}if(forceSnapshot||world.events[0]!==lastEvent){packet.events=world.events;lastEvent=world.events[0]}packet.selected=a?{id:a.id,sid:a.sid,g:a.g,age:a.age,energy:a.energy,stamina:a.stamina,maxStamina:world.staminaCapacity(a),concealment:world.concealmentAt(a.x,a.y),action:a.action,parents:a.parents,generation:a.generation,mass:world.biomass(a),maxEnergy:world.maxEnergy(a)}:null;lastSummary=now}
if(field){packet.carcasses=world.carcasses;if(waterDirty){packet.water=world.water;waterDirty=false}if(rocksDirty){packet.rocks=world.rocks;rocksDirty=false}packet.trees=world.trees.map(({x,y,r,trunk,leaves,maxLeaves,variant})=>({x,y,r,trunk,leaves,maxLeaves,variant}));packet.fallen=world.fallen;packet.plants=world.plants;if(moistureDirty){packet.moisture=world.moisture;moistureDirty=false}lastFieldEpoch=Math.floor(world.tick/20)}if(statMode)packet.carcasses=[];awaitingAck=true;postMessage(packet,[data.buffer,ids.buffer,sids.buffer]);forceSnapshot=false;lastSend=now; if(field){packet.carcasses=world.carcasses;if(waterDirty){packet.water=world.water;waterDirty=false}if(rocksDirty){packet.rocks=world.rocks;rocksDirty=false}packet.trees=world.trees.map(({x,y,r,trunk,leaves,maxLeaves,variant})=>({x,y,r,trunk,leaves,maxLeaves,variant}));packet.fallen=world.fallen;packet.plants=world.plants;if(moistureDirty){packet.moisture=world.moisture;moistureDirty=false}lastFieldSend=now}if(statMode)packet.carcasses=[];awaitingAck=true;postMessage(packet,[data.buffer,ids.buffer,sids.buffer]);forceSnapshot=false;lastSend=now;
} }
function loop(){const now=performance.now(),elapsed=Math.min(.2,(now-last)/1000);last=now;if(speed){debt=Math.min(debt+elapsed*speed,DT*300);const start=performance.now();while(debt>=DT&&performance.now()-start<22){world.step();debt-=DT;steps++;if(world.animals.length>18000){speed=0;limited=true;world.log('計算保護:18,000個体を超えたため一時停止');break}}}if(now-lastMeasure>1000){actual=steps*DT/((now-lastMeasure)/1000);steps=0;lastMeasure=now}if(now-lastSend>(statMode?900:120))send();setTimeout(loop,4)}send();loop(); function loop(){const now=performance.now(),elapsed=Math.min(.2,(now-last)/1000);last=now;if(speed){debt=Math.min(debt+elapsed*speed*NORMAL_PLAYBACK_DAYS_PER_SECOND,DT*300);const start=performance.now();while(debt>=DT&&performance.now()-start<22){world.step();debt-=DT;steps++;if(world.animals.length>18000){speed=0;limited=true;world.log('計算保護:18,000個体を超えたため一時停止');break}}}if(now-lastMeasure>1000){actual=steps*DT/((now-lastMeasure)/1000);steps=0;lastMeasure=now}const sendInterval=statMode?900:speed>=100?250:speed>=20?160:100;if(now-lastSend>sendInterval)send();setTimeout(loop,4)}send();loop();