Spaces:
Running
Running
Mirror ZJU4EmbodiedAI/EvolvingNav at dfa6872
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +16 -0
- .nojekyll +0 -0
- README.md +81 -4
- academic.css +1 -0
- assets/explorer/deeprobot.pdf +0 -0
- assets/explorer/hssd_104348511_topdown.png +3 -0
- assets/explorer/scene-provenance.json +337 -0
- assets/explorer/zju.pdf +3 -0
- code/.gitignore +7 -0
- code/README.md +101 -0
- code/configs/perception.yaml +11 -0
- code/evolvingnav_paper/__init__.py +1 -0
- code/evolvingnav_paper/agent.py +249 -0
- code/evolvingnav_paper/backend.py +188 -0
- code/evolvingnav_paper/calibration.py +66 -0
- code/evolvingnav_paper/controller.py +65 -0
- code/evolvingnav_paper/coverage.py +81 -0
- code/evolvingnav_paper/evaluate.py +67 -0
- code/evolvingnav_paper/filter.py +101 -0
- code/evolvingnav_paper/habitat_utils.py +67 -0
- code/evolvingnav_paper/memory.py +106 -0
- code/evolvingnav_paper/perception.py +120 -0
- code/evolvingnav_paper/policy.py +126 -0
- code/evolvingnav_paper/run.py +272 -0
- code/evolvingnav_paper/transition.py +32 -0
- code/evolvingnav_paper/transition_model.py +177 -0
- code/evolvingnav_paper/verify_visual.py +77 -0
- code/evolvingnav_paper/world.py +202 -0
- code/requirements.txt +8 -0
- code/scripts/collect_calibration.py +125 -0
- code/scripts/fit_calibration.py +27 -0
- code/scripts/pack_p4d_hssd_records.py +334 -0
- code/scripts/train_p4d_belief.py +269 -0
- code/scripts/train_transition.py +169 -0
- code/src/readyagent/__init__.py +1 -0
- code/src/readyagent/p4d_belief/__init__.py +15 -0
- code/src/readyagent/p4d_belief/baselines.py +74 -0
- code/src/readyagent/p4d_belief/data.py +294 -0
- code/src/readyagent/p4d_belief/metrics.py +119 -0
- code/src/readyagent/p4d_belief/models.py +457 -0
- code/src/readyagent/p4d_belief/training.py +422 -0
- code/tests/test_agent_core.py +286 -0
- code/tests/test_package_layout.py +55 -0
- code/tests/test_paper_code.py +361 -0
- editorial.css +32 -0
- editorial.js +37 -0
- figures/P4D_Benchmark.pdf +3 -0
- figures/P4D_CaseStudy.pdf +3 -0
- figures/Real_World_Case_third_1.pdf +3 -0
- figures/Real_World_Case_third_2.pdf +3 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,19 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
assets/explorer/hssd_104348511_topdown.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
assets/explorer/zju.pdf filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
figures/P4D_Benchmark.pdf filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
figures/P4D_CaseStudy.pdf filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
figures/Real_World_Case_third_1.pdf filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
figures/Real_World_Case_third_2.pdf filter=lfs diff=lfs merge=lfs -text
|
| 42 |
+
figures/benchmark_figure3_web.png filter=lfs diff=lfs merge=lfs -text
|
| 43 |
+
figures/benchmark_figure4_web.png filter=lfs diff=lfs merge=lfs -text
|
| 44 |
+
figures/case_study_web_latest.png filter=lfs diff=lfs merge=lfs -text
|
| 45 |
+
figures/dataset-generate.pdf filter=lfs diff=lfs merge=lfs -text
|
| 46 |
+
figures/method.pdf filter=lfs diff=lfs merge=lfs -text
|
| 47 |
+
figures/method_web_latest.png filter=lfs diff=lfs merge=lfs -text
|
| 48 |
+
figures/overview_web_latest.png filter=lfs diff=lfs merge=lfs -text
|
| 49 |
+
figures/real_indoor_sequence_web.png filter=lfs diff=lfs merge=lfs -text
|
| 50 |
+
figures/real_outdoor_sequence_web.png filter=lfs diff=lfs merge=lfs -text
|
| 51 |
+
main.pdf filter=lfs diff=lfs merge=lfs -text
|
.nojekyll
ADDED
|
File without changes
|
README.md
CHANGED
|
@@ -1,10 +1,87 @@
|
|
| 1 |
---
|
| 2 |
title: EvolvingNav
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: EvolvingNav
|
| 3 |
+
emoji: 🧭
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: static
|
| 7 |
+
app_file: index.html
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# EvolvingNav
|
| 12 |
+
|
| 13 |
+
## Beyond the Remembered World
|
| 14 |
+
|
| 15 |
+
**Predictive 4D Belief for Persistent Navigation in Evolving Worlds**
|
| 16 |
+
|
| 17 |
+
EvolvingNav is an embodied navigation framework for settings where an agent's remembered scene can become stale while it is away or moving. It forecasts whether an observed state will persist, reasons about plausible relocation, and uses visibility-aware RGB-D evidence to revise its belief and replan.
|
| 18 |
+
|
| 19 |
+
**Mingjian Gao**<sup>1,*</sup>, **Zhaocheng Li**<sup>1,*</sup>, **Haoyang Huang**<sup>2,*</sup>, **Wenqiao Zhang**<sup>1,‡,†</sup>, **Yingjie NIU**<sup>3,4,‡,†</sup>, **Hao Zhou**<sup>1</sup>, **Chao LI**<sup>3</sup>, **Juncheng LI**<sup>1</sup>, **Siliang Tang**<sup>1</sup>, **Yueting Zhuang**<sup>1</sup>
|
| 20 |
+
|
| 21 |
+
<sup>1</sup> Zhejiang University · <sup>2</sup> University of California, San Diego · <sup>3</sup> Deeprobotics · <sup>4</sup> The Chinese University of Hong Kong<br />
|
| 22 |
+
<sup>*</sup> Equal contribution · <sup>‡</sup> Corresponding authors · <sup>†</sup> Project lead
|
| 23 |
+
|
| 24 |
+
## At a glance
|
| 25 |
+
|
| 26 |
+
| 54 scenes | 803.68K tasks | 61.32% first-inspection SR | 86.18% search SR |
|
| 27 |
+
|:--:|:--:|:--:|:--:|
|
| 28 |
+
| EvoWorld-Bench | executable instances | stale-memory decisions | budgeted search |
|
| 29 |
+
|
| 30 |
+
## Overview
|
| 31 |
+
|
| 32 |
+
<p align="center"><img src="figures/overview_web_latest.png" alt="EvolvingNav overview" width="960" /></p>
|
| 33 |
+
|
| 34 |
+
EvolvingNav turns timestamped 3D histories into an arrival-time belief over the current world. The agent predicts, acts for a short horizon, checks whether newly visible evidence should have been observable, and updates its memory before continuing.
|
| 35 |
+
|
| 36 |
+
## Method
|
| 37 |
+
|
| 38 |
+
<p align="center"><img src="figures/method_web_latest.png" alt="EvolvingNav method" width="960" /></p>
|
| 39 |
+
|
| 40 |
+
The framework combines memory construction, state prediction, visibility-aware evidence filtering, and belief-guided route planning. Candidate states are forecast at their estimated arrival times instead of only at the instant of the query.
|
| 41 |
+
|
| 42 |
+
## EvoWorld-Bench
|
| 43 |
+
|
| 44 |
+
<p align="center"><img src="figures/benchmark_figure4_web.png" alt="EvoWorld-Bench construction" width="620" /> <img src="figures/benchmark_figure3_web.png" alt="EvoWorld-Bench protocols" width="620" /></p>
|
| 45 |
+
|
| 46 |
+
The benchmark grounds human traces into executable evolving worlds and evaluates predictive navigation, belief-guided search, evidence-aware replanning, online dynamics, held-out transfer, and embodied question answering.
|
| 47 |
+
|
| 48 |
+
## Results
|
| 49 |
+
|
| 50 |
+
| Method | FindingDory HL-SR | GOAT-Bench SR | First-Inspection SR | Search SR | SPL |
|
| 51 |
+
|:--|--:|--:|--:|--:|--:|
|
| 52 |
+
| DynaMem | 30.30 | 14.54 | 45.33 | 71.94 | 56.83 |
|
| 53 |
+
| **EvolvingNav (ours)** | **53.22 ± 3.87** | **35.43 ± 2.16** | **61.32 ± 3.24** | **86.18 ± 2.07** | **70.15 ± 1.74** |
|
| 54 |
+
|
| 55 |
+
<p align="center"><img src="figures/case_study_web_latest.png" alt="Qualitative cases" width="960" /></p>
|
| 56 |
+
|
| 57 |
+
## Real-world validation
|
| 58 |
+
|
| 59 |
+
Across 64 matched LYNX M20 search episodes, EvolvingNav reaches 34.4% first-inspection success, 48.4% search success, and 24.3% recovery success, with 43.8 m mean travel.
|
| 60 |
+
|
| 61 |
+
## Code
|
| 62 |
+
|
| 63 |
+
Runnable implementation, Habitat runners, training scripts, and tests are in [`code/`](code/README.md).
|
| 64 |
+
|
| 65 |
+
```bash
|
| 66 |
+
cd code
|
| 67 |
+
python -m pip install -r requirements.txt
|
| 68 |
+
export PYTHONPATH=.:src:scripts
|
| 69 |
+
python -m pytest tests -q
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
For dataset paths, model training, and benchmark commands, see [`code/README.md`](code/README.md).
|
| 73 |
+
|
| 74 |
+
## Project page
|
| 75 |
+
|
| 76 |
+
The complete paper website is available at [zju4embodiedai.github.io/EvolvingNav](https://zju4embodiedai.github.io/EvolvingNav/).
|
| 77 |
+
|
| 78 |
+
## Citation
|
| 79 |
+
|
| 80 |
+
```bibtex
|
| 81 |
+
@inproceedings{evolvingnav2027,
|
| 82 |
+
title = {Beyond the Remembered World: Predictive 4D Belief for Persistent Navigation in Evolving Worlds},
|
| 83 |
+
author = {Gao, Mingjian and Li, Zhaocheng and Huang, Haoyang and Zhang, Wenqiao and Niu, Yingjie and Zhou, Hao and Li, Chao and Li, Juncheng and Tang, Siliang and Zhuang, Yueting},
|
| 84 |
+
booktitle = {International Conference on Learning Representations},
|
| 85 |
+
year = {2027}
|
| 86 |
+
}
|
| 87 |
+
```
|
academic.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
:root{--navy:#193453;--teal:#1599ad;--orange:#ed7f3a;--ink:#27313d;--muted:#5f6d7b;--line:#dce2e7;--paper:#fff;--page:#f3f5f6}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--page);color:var(--ink);font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:1.62}.page-width{width:min(1080px,calc(100% - 40px));margin:0 auto}.site-header{height:52px;background:#fff;border-bottom:1px solid var(--line);font-size:13px}.header-inner{height:100%;display:flex;align-items:center;justify-content:space-between}.site-name{font-weight:700;color:var(--navy);text-decoration:none;letter-spacing:.01em}.site-header nav{display:flex;gap:24px}.site-header nav a{color:#617080;text-decoration:none}.site-header nav a:hover{color:var(--teal)}.paper-heading{text-align:center;padding:68px 0 30px}.paper-heading h1{font-size:clamp(27px,4vw,42px);line-height:1.18;font-weight:600;letter-spacing:-.025em;color:var(--navy);margin:0 auto 22px;max-width:920px}.authors{font-size:17px;margin:0;color:var(--ink)}.affiliation{font-size:14px;color:var(--muted);margin:2px 0 20px}.paper-links{display:flex;justify-content:center;gap:10px}.paper-links a{display:inline-block;border:1px solid #b8c5d0;border-radius:5px;padding:6px 18px;color:var(--navy);background:#fff;text-decoration:none;font-size:13px}.paper-links a:hover{border-color:var(--teal);color:var(--teal)}figure{margin:0}.main-figure{background:#fff;border:1px solid var(--line);padding:17px 17px 13px;margin:0 auto 55px}.main-figure img{display:block;width:100%;height:auto}.main-figure figcaption,.paper-figure figcaption{color:#687582;font-size:13px;line-height:1.5;margin:12px 4px 0}.main-figure figcaption b,.paper-figure figcaption b{color:var(--navy)}.paper-section{padding:0 0 54px;margin-bottom:12px;border-bottom:1px solid var(--line)}.paper-section h2{font-size:28px;color:var(--navy);font-weight:600;margin:0 0 17px;letter-spacing:-.02em}.paper-section h3{font-size:21px;color:var(--navy);margin:34px 0 11px}.paper-section p{max-width:940px;margin:0 0 15px}.abstract-section p{max-width:980px}.figure-grid{display:grid;grid-template-columns:1fr 1fr;gap:25px;margin:28px 0 17px}.paper-figure{background:#fff;border:1px solid var(--line);padding:12px}.paper-figure img{display:block;width:100%;height:auto}.paper-figure.full{margin:28px 0 25px;padding:16px}.paper-figure.full img{max-height:600px;object-fit:contain}.equation-note{border-left:3px solid var(--teal);background:#eef7f8;color:var(--navy);padding:13px 16px;font-family:Georgia,serif;font-size:18px;word-spacing:5px}.equation-note span{font-weight:600}.protocol-table{display:grid;grid-template-columns:repeat(5,1fr);border:1px solid var(--line);background:#fff;margin-top:24px}.protocol-table>div{padding:14px 12px;border-right:1px solid var(--line);display:flex;flex-direction:column;gap:2px}.protocol-table>div:last-child{border-right:0}.protocol-table b{color:var(--teal);font-family:Georgia,serif;font-size:19px}.protocol-table span{font-size:13px;color:var(--navy);font-weight:600;line-height:1.3}.protocol-table small{font-size:11px;color:var(--muted)}.result-row{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;margin:28px 0;background:#fff;border:1px solid var(--line);padding:16px}.result{padding:8px 9px;border-right:1px solid var(--line);display:flex;flex-direction:column}.result:last-child{border-right:0}.result strong{font-size:24px;line-height:1.15;color:var(--teal);font-weight:600}.result span{font-size:11px;color:var(--muted);line-height:1.35;margin-top:5px}.results-table{width:100%;border-collapse:collapse;background:#fff;border:1px solid var(--line);font-size:13px;margin:14px 0 0}.results-table th{background:#f0f4f6;color:var(--navy);font-weight:600;text-align:center;padding:11px 10px;border-bottom:1px solid var(--line);line-height:1.3}.results-table th:first-child,.results-table td:first-child{text-align:left}.results-table td{padding:12px 10px;border-bottom:1px solid #e9edf0;text-align:center}.results-table tr:last-child td{border-bottom:0}.results-table tr.ours{background:#eff8f8}.results-table tr.ours td{color:var(--navy)}.real-world{margin-top:20px}.real-world .paper-figure:first-child{grid-column:span 1}.resources{border-bottom:0;padding-bottom:30px}.resource-links{display:flex;flex-wrap:wrap;gap:10px}.resource-links a{color:var(--navy);border:1px solid #c4ced6;background:#fff;border-radius:5px;padding:9px 14px;text-decoration:none;font-size:13px}.resource-links a:hover{color:var(--teal);border-color:var(--teal)}.resource-links span{color:var(--orange)}.bibtex{border-bottom:0;padding-bottom:60px}.bibtex pre{overflow:auto;background:#f0f3f5;border:1px solid var(--line);padding:17px;font:13px/1.55 "Courier New",monospace;color:#334b62;margin:0}footer{background:#fff;border-top:1px solid var(--line);padding:22px 0;color:#788592;font-size:12px}.desktop{display:block}@media(max-width:760px){.page-width{width:calc(100% - 28px)}.site-header{height:auto;padding:12px 0}.header-inner{gap:10px;align-items:flex-start}.site-header nav{gap:11px;flex-wrap:wrap;justify-content:flex-end;font-size:11px}.paper-heading{padding:42px 0 25px}.paper-heading h1{font-size:28px}.desktop{display:none}.main-figure{padding:9px;margin-bottom:40px}.figure-grid{grid-template-columns:1fr;gap:17px}.protocol-table{grid-template-columns:1fr 1fr}.protocol-table>div:nth-child(2n){border-right:0}.protocol-table>div{border-bottom:1px solid var(--line)}.protocol-table>div:last-child{grid-column:span 2}.result-row{grid-template-columns:repeat(2,1fr);gap:0}.result{border-bottom:1px solid var(--line);padding:11px 8px}.result:nth-child(2n){border-right:0}.result:last-child{grid-column:span 2;border-bottom:0}.results-table{font-size:11px;min-width:650px}.results-table{display:block;overflow-x:auto}.paper-section{padding-bottom:40px}}
|
assets/explorer/deeprobot.pdf
ADDED
|
Binary file (35.6 kB). View file
|
|
|
assets/explorer/hssd_104348511_topdown.png
ADDED
|
Git LFS Details
|
assets/explorer/scene-provenance.json
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"purpose": "Genuine HSSD scene backgrounds for an illustrative website interaction",
|
| 3 |
+
"annotations": "Object hypotheses, time changes, belief values and quadruped animation are authored demonstrations, not benchmark outputs or learned-policy rollouts.",
|
| 4 |
+
"projection_note": "Downward-facing perspective render. Routes are projected at NavMesh floor elevation; furniture anchor pixels are visually selected image positions.",
|
| 5 |
+
"scenes": [
|
| 6 |
+
{
|
| 7 |
+
"scene_id": "104348511_171513654",
|
| 8 |
+
"image": "assets/explorer/hssd_104348511_topdown.png",
|
| 9 |
+
"image_size": [
|
| 10 |
+
1300,
|
| 11 |
+
1500
|
| 12 |
+
],
|
| 13 |
+
"recommended_viewbox": [
|
| 14 |
+
120,
|
| 15 |
+
100,
|
| 16 |
+
1080,
|
| 17 |
+
1080
|
| 18 |
+
],
|
| 19 |
+
"capture_script": "reports/agent_evidence_update_20260914/capture_topdown_v2.py",
|
| 20 |
+
"capture_manifest": "reports/agent_evidence_update_20260914/assets/capture_manifest.json",
|
| 21 |
+
"source_notes": "reports/agent_evidence_update_20260914/README_v2.md",
|
| 22 |
+
"render_engine": "Habitat-Sim",
|
| 23 |
+
"task_target_added": false,
|
| 24 |
+
"camera": {
|
| 25 |
+
"eye": [
|
| 26 |
+
-8.34,
|
| 27 |
+
11,
|
| 28 |
+
-3.38
|
| 29 |
+
],
|
| 30 |
+
"look_at": [
|
| 31 |
+
-8.34,
|
| 32 |
+
0,
|
| 33 |
+
-3.38
|
| 34 |
+
],
|
| 35 |
+
"hfov_degrees": 43
|
| 36 |
+
},
|
| 37 |
+
"anchors_px": {
|
| 38 |
+
"coffee_table": [
|
| 39 |
+
686,
|
| 40 |
+
408
|
| 41 |
+
],
|
| 42 |
+
"kitchen_island": [
|
| 43 |
+
487,
|
| 44 |
+
985
|
| 45 |
+
],
|
| 46 |
+
"dining_table": [
|
| 47 |
+
1030,
|
| 48 |
+
870
|
| 49 |
+
]
|
| 50 |
+
},
|
| 51 |
+
"robot_points_px": {
|
| 52 |
+
"start": [
|
| 53 |
+
361,
|
| 54 |
+
661
|
| 55 |
+
],
|
| 56 |
+
"coffee_table": [
|
| 57 |
+
468,
|
| 58 |
+
457
|
| 59 |
+
],
|
| 60 |
+
"kitchen_island": [
|
| 61 |
+
788,
|
| 62 |
+
1007
|
| 63 |
+
],
|
| 64 |
+
"dining_table": [
|
| 65 |
+
817,
|
| 66 |
+
850
|
| 67 |
+
]
|
| 68 |
+
},
|
| 69 |
+
"navigation_check": {
|
| 70 |
+
"navmesh": "data/4d_dynnav/hssd/multiscene/104348511_171513654/navmeshes/104348511_171513654.navmesh",
|
| 71 |
+
"floor_y": 0.17658868432044983,
|
| 72 |
+
"all_points_navigable": true,
|
| 73 |
+
"paths": [
|
| 74 |
+
{
|
| 75 |
+
"from": "start",
|
| 76 |
+
"to": "coffee_table",
|
| 77 |
+
"found": true,
|
| 78 |
+
"length_m": 1.5109581947,
|
| 79 |
+
"points_px": [
|
| 80 |
+
[
|
| 81 |
+
361,
|
| 82 |
+
661
|
| 83 |
+
],
|
| 84 |
+
[
|
| 85 |
+
468,
|
| 86 |
+
457
|
| 87 |
+
]
|
| 88 |
+
]
|
| 89 |
+
},
|
| 90 |
+
{
|
| 91 |
+
"from": "coffee_table",
|
| 92 |
+
"to": "kitchen_island",
|
| 93 |
+
"found": true,
|
| 94 |
+
"length_m": 4.3134069443,
|
| 95 |
+
"points_px": [
|
| 96 |
+
[
|
| 97 |
+
468,
|
| 98 |
+
457
|
| 99 |
+
],
|
| 100 |
+
[
|
| 101 |
+
528.5,
|
| 102 |
+
695.3
|
| 103 |
+
],
|
| 104 |
+
[
|
| 105 |
+
719.1,
|
| 106 |
+
870.6
|
| 107 |
+
],
|
| 108 |
+
[
|
| 109 |
+
788,
|
| 110 |
+
1007
|
| 111 |
+
]
|
| 112 |
+
]
|
| 113 |
+
},
|
| 114 |
+
{
|
| 115 |
+
"from": "kitchen_island",
|
| 116 |
+
"to": "dining_table",
|
| 117 |
+
"found": true,
|
| 118 |
+
"length_m": 1.0472089052,
|
| 119 |
+
"points_px": [
|
| 120 |
+
[
|
| 121 |
+
788,
|
| 122 |
+
1007
|
| 123 |
+
],
|
| 124 |
+
[
|
| 125 |
+
817,
|
| 126 |
+
850
|
| 127 |
+
]
|
| 128 |
+
]
|
| 129 |
+
}
|
| 130 |
+
],
|
| 131 |
+
"method": "Habitat-Sim PathFinder.load_nav_mesh; is_navigable; find_path. Projection derived from recorded camera geometry.",
|
| 132 |
+
"limit": "Does not establish quadruped footprint clearance, dynamics, sensing or policy performance; route smoothing must not cut obstacle corners."
|
| 133 |
+
},
|
| 134 |
+
"anchors_crop_normalized": {
|
| 135 |
+
"coffee_table": [
|
| 136 |
+
0.52407,
|
| 137 |
+
0.28519
|
| 138 |
+
],
|
| 139 |
+
"kitchen_island": [
|
| 140 |
+
0.33981,
|
| 141 |
+
0.81944
|
| 142 |
+
],
|
| 143 |
+
"dining_table": [
|
| 144 |
+
0.84259,
|
| 145 |
+
0.71296
|
| 146 |
+
]
|
| 147 |
+
},
|
| 148 |
+
"robot_points_crop_normalized": {
|
| 149 |
+
"start": [
|
| 150 |
+
0.22315,
|
| 151 |
+
0.51944
|
| 152 |
+
],
|
| 153 |
+
"coffee_table": [
|
| 154 |
+
0.32222,
|
| 155 |
+
0.33056
|
| 156 |
+
],
|
| 157 |
+
"kitchen_island": [
|
| 158 |
+
0.61852,
|
| 159 |
+
0.83981
|
| 160 |
+
],
|
| 161 |
+
"dining_table": [
|
| 162 |
+
0.64537,
|
| 163 |
+
0.69444
|
| 164 |
+
]
|
| 165 |
+
},
|
| 166 |
+
"original_source": "reports/agent_evidence_update_20260914/assets/hssd_104348511_topdown.png",
|
| 167 |
+
"sha256": "a53944b1c072121c0dde1f1629193e33249fddcef6ee4fa6a084493916227a83"
|
| 168 |
+
},
|
| 169 |
+
{
|
| 170 |
+
"scene_id": "102344280",
|
| 171 |
+
"image": "assets/explorer/hssd_102344280_topdown.png",
|
| 172 |
+
"image_size": [
|
| 173 |
+
1400,
|
| 174 |
+
900
|
| 175 |
+
],
|
| 176 |
+
"recommended_viewbox": [
|
| 177 |
+
240,
|
| 178 |
+
170,
|
| 179 |
+
1150,
|
| 180 |
+
585
|
| 181 |
+
],
|
| 182 |
+
"capture_script": "reports/agent_evidence_update_20260914/capture_topdown.py",
|
| 183 |
+
"render_engine": "Habitat-Sim",
|
| 184 |
+
"task_target_added": false,
|
| 185 |
+
"camera": {
|
| 186 |
+
"eye": [
|
| 187 |
+
-7.3,
|
| 188 |
+
9,
|
| 189 |
+
2.7
|
| 190 |
+
],
|
| 191 |
+
"look_at": [
|
| 192 |
+
-7.3,
|
| 193 |
+
0,
|
| 194 |
+
2.7
|
| 195 |
+
],
|
| 196 |
+
"hfov_degrees": 75
|
| 197 |
+
},
|
| 198 |
+
"anchors_px": {
|
| 199 |
+
"dining_table": [
|
| 200 |
+
493,
|
| 201 |
+
423
|
| 202 |
+
],
|
| 203 |
+
"center_table": [
|
| 204 |
+
1040,
|
| 205 |
+
470
|
| 206 |
+
],
|
| 207 |
+
"kitchen_sink": [
|
| 208 |
+
945.4,
|
| 209 |
+
644.1
|
| 210 |
+
]
|
| 211 |
+
},
|
| 212 |
+
"robot_points_px": {
|
| 213 |
+
"start": [
|
| 214 |
+
720,
|
| 215 |
+
310
|
| 216 |
+
],
|
| 217 |
+
"dining_table": [
|
| 218 |
+
650,
|
| 219 |
+
429
|
| 220 |
+
],
|
| 221 |
+
"center_table": [
|
| 222 |
+
1010,
|
| 223 |
+
295
|
| 224 |
+
],
|
| 225 |
+
"kitchen_sink": [
|
| 226 |
+
926.9,
|
| 227 |
+
558.3
|
| 228 |
+
]
|
| 229 |
+
},
|
| 230 |
+
"navigation_check": {
|
| 231 |
+
"navmesh": "data/4d_dynnav/hssd/hssd-hab-subset/navmeshes/102344280.navmesh",
|
| 232 |
+
"floor_y": 0.15372097492218018,
|
| 233 |
+
"all_points_navigable": true,
|
| 234 |
+
"paths": [
|
| 235 |
+
{
|
| 236 |
+
"from": "start",
|
| 237 |
+
"to": "dining_table",
|
| 238 |
+
"found": true,
|
| 239 |
+
"length_m": 1.3388005495,
|
| 240 |
+
"points_px": [
|
| 241 |
+
[
|
| 242 |
+
720,
|
| 243 |
+
310
|
| 244 |
+
],
|
| 245 |
+
[
|
| 246 |
+
650,
|
| 247 |
+
429
|
| 248 |
+
]
|
| 249 |
+
]
|
| 250 |
+
},
|
| 251 |
+
{
|
| 252 |
+
"from": "dining_table",
|
| 253 |
+
"to": "kitchen_sink",
|
| 254 |
+
"found": true,
|
| 255 |
+
"length_m": 2.9634537697,
|
| 256 |
+
"points_px": [
|
| 257 |
+
[
|
| 258 |
+
650,
|
| 259 |
+
429
|
| 260 |
+
],
|
| 261 |
+
[
|
| 262 |
+
926.9,
|
| 263 |
+
558.3
|
| 264 |
+
]
|
| 265 |
+
]
|
| 266 |
+
},
|
| 267 |
+
{
|
| 268 |
+
"from": "kitchen_sink",
|
| 269 |
+
"to": "center_table",
|
| 270 |
+
"found": true,
|
| 271 |
+
"length_m": 3.4616861343,
|
| 272 |
+
"points_px": [
|
| 273 |
+
[
|
| 274 |
+
926.9,
|
| 275 |
+
558.3
|
| 276 |
+
],
|
| 277 |
+
[
|
| 278 |
+
869.8,
|
| 279 |
+
528.2
|
| 280 |
+
],
|
| 281 |
+
[
|
| 282 |
+
859.5,
|
| 283 |
+
512.7
|
| 284 |
+
],
|
| 285 |
+
[
|
| 286 |
+
864.7,
|
| 287 |
+
456
|
| 288 |
+
],
|
| 289 |
+
[
|
| 290 |
+
1010,
|
| 291 |
+
295
|
| 292 |
+
]
|
| 293 |
+
]
|
| 294 |
+
}
|
| 295 |
+
],
|
| 296 |
+
"method": "Habitat-Sim PathFinder.load_nav_mesh; is_navigable; find_path. Projection derived from recorded camera geometry.",
|
| 297 |
+
"limit": "Does not establish quadruped footprint clearance, dynamics, sensing or policy performance; route smoothing must not cut obstacle corners."
|
| 298 |
+
},
|
| 299 |
+
"related_verified_replay": "reports/4d_dynnav/case_study_102344280_20260908/capture_manifest.json",
|
| 300 |
+
"related_replay_note": "Related source is a separately documented qualitative bowl tabletop-to-sink replay, not the website interaction.",
|
| 301 |
+
"anchors_crop_normalized": {
|
| 302 |
+
"dining_table": [
|
| 303 |
+
0.22,
|
| 304 |
+
0.43248
|
| 305 |
+
],
|
| 306 |
+
"center_table": [
|
| 307 |
+
0.69565,
|
| 308 |
+
0.51282
|
| 309 |
+
],
|
| 310 |
+
"kitchen_sink": [
|
| 311 |
+
0.61339,
|
| 312 |
+
0.81043
|
| 313 |
+
]
|
| 314 |
+
},
|
| 315 |
+
"robot_points_crop_normalized": {
|
| 316 |
+
"start": [
|
| 317 |
+
0.41739,
|
| 318 |
+
0.23932
|
| 319 |
+
],
|
| 320 |
+
"dining_table": [
|
| 321 |
+
0.35652,
|
| 322 |
+
0.44274
|
| 323 |
+
],
|
| 324 |
+
"center_table": [
|
| 325 |
+
0.66957,
|
| 326 |
+
0.21368
|
| 327 |
+
],
|
| 328 |
+
"kitchen_sink": [
|
| 329 |
+
0.5973,
|
| 330 |
+
0.66376
|
| 331 |
+
]
|
| 332 |
+
},
|
| 333 |
+
"original_source": "reports/agent_evidence_update_20260914/assets/hssd_102344280_topdown.png",
|
| 334 |
+
"sha256": "2d48e3021ddc215beaa91597a240bdac9b528e9b7ada56a96bc10c435591748e"
|
| 335 |
+
}
|
| 336 |
+
]
|
| 337 |
+
}
|
assets/explorer/zju.pdf
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:853898386fd82f5693072bed70fd5d66567f5be06248ed017982f421620ec8db
|
| 3 |
+
size 238785
|
code/.gitignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
.pytest_cache/
|
| 4 |
+
.ruff_cache/
|
| 5 |
+
.venv/
|
| 6 |
+
.env
|
| 7 |
+
runs/
|
code/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# EvolvingNav Agent
|
| 2 |
+
|
| 3 |
+
## Code
|
| 4 |
+
|
| 5 |
+
| Path | Function |
|
| 6 |
+
| --- | --- |
|
| 7 |
+
| `src/readyagent/p4d_belief/` | Continuous-time history encoder and persistence–relocation belief |
|
| 8 |
+
| `evolvingnav_paper/memory.py` | Causal entity versions, RGB-D backprojection and evidence provenance |
|
| 9 |
+
| `evolvingnav_paper/transition_model.py` | Row-normalized chronological transition head |
|
| 10 |
+
| `evolvingnav_paper/filter.py` | Current-time belief, arrival forecasts and evidence rounds |
|
| 11 |
+
| `evolvingnav_paper/coverage.py`, `calibration.py` | Online depth coverage and validation-fitted detection probability |
|
| 12 |
+
| `evolvingnav_paper/agent.py`, `controller.py` | Event-driven actions and frozen VLM tool selection |
|
| 13 |
+
| `evolvingnav_paper/world.py`, `backend.py`, `run.py` | Habitat action adapter and benchmark runner |
|
| 14 |
+
| `scripts/` | Dataset packing, belief/transition training and calibration |
|
| 15 |
+
| `tests/` | Unit tests |
|
| 16 |
+
|
| 17 |
+
## Environment
|
| 18 |
+
|
| 19 |
+
Use Python 3.11 with Habitat-Sim 0.3.3 installed.
|
| 20 |
+
|
| 21 |
+
```bash
|
| 22 |
+
cd code
|
| 23 |
+
python -m pip install -r requirements.txt
|
| 24 |
+
export PYTHONPATH=.:src:scripts
|
| 25 |
+
export MAGNUM_LOG=quiet HABITAT_SIM_LOG=quiet
|
| 26 |
+
export P4D_DATASET=/absolute/path/to/p4d_hssd_30d_v0.6.0
|
| 27 |
+
export NAV_TASKS=/absolute/path/to/p4d_navigation_107734254_v1.0
|
| 28 |
+
export HSSD_ROOT=/absolute/path/to/hssd-hab
|
| 29 |
+
export NAVMESH_ROOT=/absolute/path/to/hssd-hab/navmeshes
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
## Train
|
| 33 |
+
|
| 34 |
+
If `records/packed/{train,val,test}.npz` are absent:
|
| 35 |
+
|
| 36 |
+
```bash
|
| 37 |
+
python scripts/pack_p4d_hssd_records.py --root "$P4D_DATASET"
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
Train the query-time belief and the chronological transition head:
|
| 41 |
+
|
| 42 |
+
```bash
|
| 43 |
+
python scripts/train_p4d_belief.py \
|
| 44 |
+
--dataset-root "$P4D_DATASET" --output runs/p4d_seed0 \
|
| 45 |
+
--seeds 0 --skip-classical
|
| 46 |
+
python scripts/train_transition.py \
|
| 47 |
+
--dataset "$P4D_DATASET" \
|
| 48 |
+
--belief-checkpoint runs/p4d_seed0/checkpoints/p4d/seed_0/best.pt \
|
| 49 |
+
--output runs/transition_seed0
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
Collect held-out RGB-D validation observations and fit detector calibration:
|
| 53 |
+
|
| 54 |
+
```bash
|
| 55 |
+
python scripts/collect_calibration.py \
|
| 56 |
+
--dataset "$P4D_DATASET" --tasks "$NAV_TASKS" \
|
| 57 |
+
--hssd-root "$HSSD_ROOT" --navmesh-root "$NAVMESH_ROOT" \
|
| 58 |
+
--limit 8 --output runs/calibration_val.jsonl
|
| 59 |
+
python scripts/fit_calibration.py \
|
| 60 |
+
--validation-jsonl runs/calibration_val.jsonl \
|
| 61 |
+
--output runs/detection_calibration.json
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
## Run
|
| 65 |
+
|
| 66 |
+
Run the event-driven N3 Agent with Grounding DINO + SAM2:
|
| 67 |
+
|
| 68 |
+
```bash
|
| 69 |
+
python -m evolvingnav_paper.run \
|
| 70 |
+
--task n3 --world static --limit 10 \
|
| 71 |
+
--dataset "$P4D_DATASET" --tasks "$NAV_TASKS" \
|
| 72 |
+
--hssd-root "$HSSD_ROOT" --navmesh-root "$NAVMESH_ROOT" \
|
| 73 |
+
--checkpoint runs/p4d_seed0/checkpoints/p4d/seed_0/best.pt \
|
| 74 |
+
--calibration runs/detection_calibration.json \
|
| 75 |
+
--output runs/n3_static_10
|
| 76 |
+
python -m evolvingnav_paper.verify_visual runs/n3_static_10 \
|
| 77 |
+
--tasks "$NAV_TASKS" --hssd-root "$HSSD_ROOT" \
|
| 78 |
+
--navmesh-root "$NAVMESH_ROOT"
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
Add `--controller luna` and set `OPENAI_API_KEY` to use the frozen GPT-5.6-Luna tool controller. Model IDs and revisions for Grounding DINO and SAM2 are in `configs/perception.yaml`.
|
| 82 |
+
|
| 83 |
+
For an N4 task directory with `public/episodes_n4.jsonl`, each private `target_motion_schedule` event supplies seconds after query (`time_s`), `target_position_xyz`, `current_state_id`, and `valid_goal_viewpoints`:
|
| 84 |
+
|
| 85 |
+
```bash
|
| 86 |
+
python -m evolvingnav_paper.run \
|
| 87 |
+
--task n4 --world routine --limit 2 \
|
| 88 |
+
--dataset "$P4D_DATASET" --tasks /absolute/path/to/n4_tasks \
|
| 89 |
+
--hssd-root "$HSSD_ROOT" --navmesh-root "$NAVMESH_ROOT" \
|
| 90 |
+
--checkpoint runs/p4d_seed0/checkpoints/p4d/seed_0/best.pt \
|
| 91 |
+
--transition-checkpoint runs/transition_seed0/best.pt \
|
| 92 |
+
--output runs/n4_routine_2
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
Every run writes `policy.jsonl`, `scores.jsonl` and `summary.json` to a new output directory.
|
| 96 |
+
|
| 97 |
+
## Tests
|
| 98 |
+
|
| 99 |
+
```bash
|
| 100 |
+
python -m pytest tests -q
|
| 101 |
+
```
|
code/configs/perception.yaml
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: evolvingnav_paper_perception_v1
|
| 2 |
+
device: cuda
|
| 3 |
+
grounding_dino:
|
| 4 |
+
model_id: IDEA-Research/grounding-dino-tiny
|
| 5 |
+
revision: a2bb814dd30d776dcf7e30523b00659f4f141c71
|
| 6 |
+
box_threshold: 0.35
|
| 7 |
+
text_threshold: 0.25
|
| 8 |
+
sam:
|
| 9 |
+
model_id: facebook/sam2.1-hiera-tiny
|
| 10 |
+
revision: de431c4043854a71d8101e17995dfe596bf101a5
|
| 11 |
+
palette_version: cielab_css11_v1
|
code/evolvingnav_paper/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""EvolvingNav predictive belief and event-driven embodied Agent."""
|
code/evolvingnav_paper/agent.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Event-driven EvolvingNav controller (paper Equations 11–15, 20–22)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import math
|
| 6 |
+
from dataclasses import dataclass, field
|
| 7 |
+
from typing import Protocol
|
| 8 |
+
|
| 9 |
+
from evolvingnav_paper.filter import BeliefFilter, EvidenceLedger
|
| 10 |
+
from evolvingnav_paper.policy import candidate_utility
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@dataclass(frozen=True)
|
| 14 |
+
class ViewEvidence:
|
| 15 |
+
evidence_id: str
|
| 16 |
+
state_id: int
|
| 17 |
+
surface_samples: frozenset[int]
|
| 18 |
+
detection_probability: float
|
| 19 |
+
covered_fraction: float = 1.0
|
| 20 |
+
pose_xyz: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass(frozen=True)
|
| 24 |
+
class AgentConfig:
|
| 25 |
+
max_inspections: int = 10
|
| 26 |
+
max_path_m: float = 100.0
|
| 27 |
+
chunk_m: float = 2.0
|
| 28 |
+
speed_mps: float = 1.0
|
| 29 |
+
inspection_s: float = 1.0
|
| 30 |
+
lambda_time: float = 0.05
|
| 31 |
+
lambda_inspect: float = 0.25
|
| 32 |
+
lambda_scan: float = 0.25
|
| 33 |
+
explore_cost: float = 2.0
|
| 34 |
+
discovery_probability: float = 0.5
|
| 35 |
+
speed_ema_alpha: float = 0.8
|
| 36 |
+
unknown_state: int | None = None
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@dataclass
|
| 40 |
+
class AgentResult:
|
| 41 |
+
found: bool = False
|
| 42 |
+
actions: list[str] = field(default_factory=list)
|
| 43 |
+
inspections: list[int] = field(default_factory=list)
|
| 44 |
+
path_m: float = 0.0
|
| 45 |
+
elapsed_s: float = 0.0
|
| 46 |
+
posterior: dict[int, float] = field(default_factory=dict)
|
| 47 |
+
termination: str = ""
|
| 48 |
+
evidence_trace: list[dict] = field(default_factory=list)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class World(Protocol):
|
| 52 |
+
def distance(self, goal) -> float: ...
|
| 53 |
+
def move_chunk(self, goal, max_distance: float) -> tuple[float, float]: ...
|
| 54 |
+
def inspect(self, state: int) -> tuple[bool, list[ViewEvidence]]: ...
|
| 55 |
+
def explore(self, budget_m: float) -> tuple[dict[int, tuple[object, float]], float, float]: ...
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class Agent:
|
| 59 |
+
def __init__(self, prior: dict[int, float], goals: dict[int, object], world: World,
|
| 60 |
+
transition, config: AgentConfig | None = None,
|
| 61 |
+
sample_count: dict[int, int] | None = None, controller=None,
|
| 62 |
+
memory=None, target_id: str | None = None,
|
| 63 |
+
time_origin_s: float = 0.0) -> None:
|
| 64 |
+
self.config = config or AgentConfig()
|
| 65 |
+
self.filter = BeliefFilter(prior, transition)
|
| 66 |
+
self.goals = {state: goal for state, goal in goals.items() if state in prior}
|
| 67 |
+
self.world = world
|
| 68 |
+
self.ledger = EvidenceLedger(sample_count=sample_count)
|
| 69 |
+
self.speed = self.config.speed_mps
|
| 70 |
+
self.controller = controller
|
| 71 |
+
self.memory = memory
|
| 72 |
+
self.target_id = target_id
|
| 73 |
+
self.time_origin_s = time_origin_s
|
| 74 |
+
|
| 75 |
+
def _return_probability(self, state: int, eta: float) -> float:
|
| 76 |
+
if state not in self.filter.posterior:
|
| 77 |
+
return 0.0
|
| 78 |
+
forecast = self.filter.arrival(eta)
|
| 79 |
+
# Equation 21 excludes probability already at this state.
|
| 80 |
+
return max(0.0, forecast.get(state, 0.0) - self.filter.posterior.get(state, 0.0)
|
| 81 |
+
* self.filter.transition.matrix(list(self.filter.posterior), eta)[
|
| 82 |
+
list(self.filter.posterior).index(state), list(self.filter.posterior).index(state)])
|
| 83 |
+
|
| 84 |
+
def _choose(self, result: AgentResult) -> int | str | None:
|
| 85 |
+
scored: list[tuple[float, int | str]] = []
|
| 86 |
+
for state, goal in self.goals.items():
|
| 87 |
+
distance = self.world.distance(goal)
|
| 88 |
+
if not math.isfinite(distance) or result.path_m + distance > self.config.max_path_m:
|
| 89 |
+
continue
|
| 90 |
+
eta = distance / self.speed + self.config.inspection_s
|
| 91 |
+
arrival = self.filter.arrival(eta)
|
| 92 |
+
return_probability = self._return_probability(state, eta)
|
| 93 |
+
if not self.ledger.eligible(
|
| 94 |
+
state, belief=self.filter.posterior.get(state, 0.0),
|
| 95 |
+
return_probability=return_probability, new_coverage=0.0,
|
| 96 |
+
dynamic=self.filter.transition.dynamic,
|
| 97 |
+
now_s=result.elapsed_s,
|
| 98 |
+
):
|
| 99 |
+
continue
|
| 100 |
+
uncovered = max(0.0, 1.0 - self.ledger.coverage(state))
|
| 101 |
+
new_detection = (
|
| 102 |
+
self.world.expected_new_detection(state, uncovered)
|
| 103 |
+
if hasattr(self.world, "expected_new_detection") else uncovered
|
| 104 |
+
)
|
| 105 |
+
utility = candidate_utility(
|
| 106 |
+
arrival_probability=arrival.get(state, 0.0),
|
| 107 |
+
new_detection_probability=new_detection,
|
| 108 |
+
distance_m=distance, eta_s=eta,
|
| 109 |
+
lambda_time=self.config.lambda_time,
|
| 110 |
+
lambda_inspect=self.config.lambda_inspect,
|
| 111 |
+
)
|
| 112 |
+
scored.append((utility, state))
|
| 113 |
+
unknown = self.config.unknown_state
|
| 114 |
+
if unknown is not None and self.filter.posterior.get(unknown, 0.0) > 0:
|
| 115 |
+
utility = (self.filter.posterior[unknown] * self.config.discovery_probability
|
| 116 |
+
/ (self.config.explore_cost + self.config.lambda_scan))
|
| 117 |
+
scored.append((utility, "EXPLORE"))
|
| 118 |
+
if not scored:
|
| 119 |
+
return None
|
| 120 |
+
preferred = max(scored, key=lambda row: (row[0], -row[1] if isinstance(row[1], int) else 0))
|
| 121 |
+
if self.controller is not None:
|
| 122 |
+
labels = [f"NAVIGATE_TO({action})" if isinstance(action, int) else action
|
| 123 |
+
for _, action in scored]
|
| 124 |
+
chosen = self.controller.choose(labels, {
|
| 125 |
+
"belief": self.filter.posterior,
|
| 126 |
+
"utilities": dict(zip(labels, [utility for utility, _ in scored], strict=True)),
|
| 127 |
+
"elapsed_s": result.elapsed_s, "path_m": result.path_m,
|
| 128 |
+
})
|
| 129 |
+
for utility, action in scored:
|
| 130 |
+
label = f"NAVIGATE_TO({action})" if isinstance(action, int) else action
|
| 131 |
+
if label == chosen and utility >= preferred[0] - 1e-9:
|
| 132 |
+
return action
|
| 133 |
+
return preferred[1]
|
| 134 |
+
|
| 135 |
+
def _admit_negative(self, evidence: list[ViewEvidence], now_s: float,
|
| 136 |
+
result: AgentResult) -> None:
|
| 137 |
+
for item in evidence:
|
| 138 |
+
new_coverage = self.ledger.admit(item)
|
| 139 |
+
if new_coverage > 0:
|
| 140 |
+
probability = min(1.0, item.detection_probability * new_coverage
|
| 141 |
+
/ max(item.covered_fraction, 1e-9))
|
| 142 |
+
before = self.filter.posterior.get(item.state_id, 0.0)
|
| 143 |
+
applied = self.filter.negative(
|
| 144 |
+
{item.state_id: probability},
|
| 145 |
+
f"{item.evidence_id}:{item.state_id}",
|
| 146 |
+
)
|
| 147 |
+
if applied:
|
| 148 |
+
result.evidence_trace.append({
|
| 149 |
+
"evidence_id": item.evidence_id,
|
| 150 |
+
"state_id": item.state_id,
|
| 151 |
+
"time_s": now_s,
|
| 152 |
+
"new_coverage": new_coverage,
|
| 153 |
+
"detection_probability": probability,
|
| 154 |
+
"prior": before,
|
| 155 |
+
"posterior": self.filter.posterior.get(item.state_id, 0.0),
|
| 156 |
+
})
|
| 157 |
+
if applied and self.memory is not None:
|
| 158 |
+
self.memory.record_negative(
|
| 159 |
+
item.evidence_id, item.state_id,
|
| 160 |
+
self.time_origin_s + now_s, item.pose_xyz
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
def run(self) -> AgentResult:
|
| 164 |
+
result = AgentResult()
|
| 165 |
+
for _ in range(self.config.max_inspections + len(self.goals) * 20):
|
| 166 |
+
selected = self._choose(result)
|
| 167 |
+
if selected is None:
|
| 168 |
+
result.termination = "search_exhausted"
|
| 169 |
+
unknown = self.config.unknown_state
|
| 170 |
+
unknown_mass = self.filter.posterior.get(unknown, 0.0) if unknown is not None else 0.0
|
| 171 |
+
searchable = sum(self.filter.posterior.get(state, 0.0) for state in self.goals)
|
| 172 |
+
if unknown_mass < 0.05 and searchable < 0.05:
|
| 173 |
+
result.actions.append("NOT_FOUND")
|
| 174 |
+
break
|
| 175 |
+
if selected == "EXPLORE":
|
| 176 |
+
result.actions.append("EXPLORE")
|
| 177 |
+
discovered, duration, displacement = self.world.explore(
|
| 178 |
+
self.config.max_path_m - result.path_m
|
| 179 |
+
)
|
| 180 |
+
self.filter.advance(duration)
|
| 181 |
+
result.elapsed_s += duration
|
| 182 |
+
result.path_m += displacement
|
| 183 |
+
if not discovered:
|
| 184 |
+
result.termination = "exploration_exhausted"
|
| 185 |
+
break
|
| 186 |
+
unknown = self.config.unknown_state
|
| 187 |
+
mass = self.filter.posterior[unknown]
|
| 188 |
+
total_weight = sum(weight for _, weight in discovered.values())
|
| 189 |
+
allocated = min(mass, total_weight)
|
| 190 |
+
self.filter.posterior[unknown] -= allocated
|
| 191 |
+
for state, (goal, probability) in discovered.items():
|
| 192 |
+
self.goals[state] = goal
|
| 193 |
+
if hasattr(self.world, "sample_count"):
|
| 194 |
+
self.ledger.sample_count[state] = self.world.sample_count(state)
|
| 195 |
+
if hasattr(self.filter.transition, "add_candidate"):
|
| 196 |
+
self.filter.transition.add_candidate(state)
|
| 197 |
+
self.filter.posterior[state] = self.filter.posterior.get(state, 0.0) + (
|
| 198 |
+
allocated * probability / total_weight)
|
| 199 |
+
continue
|
| 200 |
+
state = int(selected)
|
| 201 |
+
goal = self.goals[state]
|
| 202 |
+
result.actions.append(f"NAVIGATE_TO({state})")
|
| 203 |
+
while self.world.distance(goal) > 1e-4:
|
| 204 |
+
remaining = self.config.max_path_m - result.path_m
|
| 205 |
+
if remaining <= 0:
|
| 206 |
+
result.termination = "path_budget_exhausted"
|
| 207 |
+
result.posterior = self.filter.posterior.copy()
|
| 208 |
+
return result
|
| 209 |
+
displacement, duration = self.world.move_chunk(goal, min(self.config.chunk_m, remaining))
|
| 210 |
+
if displacement <= 0 or duration <= 0:
|
| 211 |
+
result.termination = "navigation_blocked"
|
| 212 |
+
result.posterior = self.filter.posterior.copy()
|
| 213 |
+
return result
|
| 214 |
+
self.filter.advance(duration)
|
| 215 |
+
result.path_m += displacement
|
| 216 |
+
result.elapsed_s += duration
|
| 217 |
+
self.speed = (self.config.speed_ema_alpha * self.speed
|
| 218 |
+
+ (1 - self.config.speed_ema_alpha) * displacement / duration)
|
| 219 |
+
if hasattr(self.world, "observe_chunk"):
|
| 220 |
+
self._admit_negative(self.world.observe_chunk(), result.elapsed_s, result)
|
| 221 |
+
if self._choose(result) != state:
|
| 222 |
+
break
|
| 223 |
+
else:
|
| 224 |
+
result.actions.append(f"INSPECT({state})")
|
| 225 |
+
if hasattr(self.world, "advance_time"):
|
| 226 |
+
self.world.advance_time(self.config.inspection_s)
|
| 227 |
+
self.filter.advance(self.config.inspection_s)
|
| 228 |
+
result.elapsed_s += self.config.inspection_s
|
| 229 |
+
detected, evidence = self.world.inspect(state)
|
| 230 |
+
result.inspections.append(state)
|
| 231 |
+
self.ledger.mark_inspected(state, now_s=result.elapsed_s)
|
| 232 |
+
if detected:
|
| 233 |
+
detection = getattr(self.world, "last_detection", None)
|
| 234 |
+
if (self.memory is not None and self.target_id is not None
|
| 235 |
+
and detection is not None):
|
| 236 |
+
self.memory.observe(
|
| 237 |
+
self.target_id, state, self.time_origin_s + result.elapsed_s,
|
| 238 |
+
detection["confidence"], detection["evidence_id"],
|
| 239 |
+
detection["world_point"],
|
| 240 |
+
)
|
| 241 |
+
result.found = True
|
| 242 |
+
result.actions.append("STOP")
|
| 243 |
+
break
|
| 244 |
+
self._admit_negative(evidence, result.elapsed_s, result)
|
| 245 |
+
if len(result.inspections) >= self.config.max_inspections:
|
| 246 |
+
result.termination = "inspection_budget_exhausted"
|
| 247 |
+
break
|
| 248 |
+
result.posterior = self.filter.posterior.copy()
|
| 249 |
+
return result
|
code/evolvingnav_paper/backend.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Habitat navigation and evaluator-private visual inspection."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import math
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Callable
|
| 8 |
+
|
| 9 |
+
import habitat_sim
|
| 10 |
+
import magnum as mn
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
from evolvingnav_paper.habitat_utils import make_simulator, path_distance, sensor_specs, set_agent
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def visible_fraction_from_masks(actual: np.ndarray, target_only: np.ndarray, semantic_id: int) -> float:
|
| 17 |
+
"""Visible target pixels divided by its unobstructed projection at the same pose."""
|
| 18 |
+
projected = int(np.count_nonzero(target_only == semantic_id))
|
| 19 |
+
if projected == 0:
|
| 20 |
+
return 0.0
|
| 21 |
+
visible = int(np.count_nonzero(actual == semantic_id))
|
| 22 |
+
return min(1.0, visible / projected)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class HabitatInspectionBackend:
|
| 26 |
+
def __init__(
|
| 27 |
+
self, hssd_root: Path, scene_id: str, navmesh: Path,
|
| 28 |
+
public_viewpoints: dict[int, dict], *, gpu: int = 0,
|
| 29 |
+
detector: Callable[[np.ndarray, np.ndarray, str], bool] | None = None,
|
| 30 |
+
) -> None:
|
| 31 |
+
self.hssd_root = hssd_root
|
| 32 |
+
self.scene_id = scene_id
|
| 33 |
+
self.navmesh = navmesh
|
| 34 |
+
self.gpu = gpu
|
| 35 |
+
self.public_viewpoints = public_viewpoints
|
| 36 |
+
self.detector = detector
|
| 37 |
+
self.real = None
|
| 38 |
+
self.target_only = None
|
| 39 |
+
self.target_only_object_id = None
|
| 40 |
+
self.object_id = None
|
| 41 |
+
self.truth = None
|
| 42 |
+
self.projected_pixels = {}
|
| 43 |
+
self.last_observation = None
|
| 44 |
+
self.last_detections = ()
|
| 45 |
+
self.last_semantic = None
|
| 46 |
+
|
| 47 |
+
def distance(self, start, goal) -> float:
|
| 48 |
+
if self.real is None:
|
| 49 |
+
raise RuntimeError("prepare an episode before requesting paths")
|
| 50 |
+
return path_distance(self.real.pathfinder, start, goal)
|
| 51 |
+
|
| 52 |
+
def _place_target(self, sim, truth: dict, target: dict) -> int:
|
| 53 |
+
config_path = self.hssd_root / target["template_handle"]
|
| 54 |
+
manager = sim.get_object_template_manager()
|
| 55 |
+
manager.load_configs(str(config_path.resolve()))
|
| 56 |
+
handles = manager.get_template_handles(target["template_hash"])
|
| 57 |
+
if not handles:
|
| 58 |
+
raise RuntimeError(f"target template unavailable: {config_path}")
|
| 59 |
+
obj = sim.get_rigid_object_manager().add_object_by_template_handle(handles[-1])
|
| 60 |
+
obj.motion_type = habitat_sim.physics.MotionType.KINEMATIC
|
| 61 |
+
obj.semantic_id = self.semantic_id
|
| 62 |
+
obj.translation = mn.Vector3(*truth["target_position_xyz"])
|
| 63 |
+
obj.rotation = mn.Quaternion.rotation(
|
| 64 |
+
mn.Rad((self.semantic_id % 12) * math.pi / 6), mn.Vector3.y_axis()
|
| 65 |
+
)
|
| 66 |
+
sim.perform_discrete_collision_detection()
|
| 67 |
+
return obj.object_id
|
| 68 |
+
|
| 69 |
+
def prepare(self, truth: dict, target: dict, candidate_ids: list[int],
|
| 70 |
+
*, dynamic: bool = False) -> None:
|
| 71 |
+
self.clear()
|
| 72 |
+
if self.real is not None:
|
| 73 |
+
self.real.close()
|
| 74 |
+
self.real = None
|
| 75 |
+
self.truth = dict(truth)
|
| 76 |
+
self.semantic_id = int(target["semantic_instance_id"])
|
| 77 |
+
self.target_category = str(target["category_canonical"])
|
| 78 |
+
config = habitat_sim.SimulatorConfiguration()
|
| 79 |
+
config.scene_id = "NONE"
|
| 80 |
+
config.create_renderer = True
|
| 81 |
+
config.enable_physics = True
|
| 82 |
+
config.gpu_device_id = self.gpu
|
| 83 |
+
agent = habitat_sim.agent.AgentConfiguration()
|
| 84 |
+
agent.sensor_specifications = sensor_specs(320, 240)
|
| 85 |
+
target_only = habitat_sim.Simulator(habitat_sim.Configuration(config, [agent]))
|
| 86 |
+
try:
|
| 87 |
+
target_only_id = self._place_target(target_only, truth, target)
|
| 88 |
+
self.projected_pixels = {}
|
| 89 |
+
for state in candidate_ids:
|
| 90 |
+
viewpoint = self.public_viewpoints[state]
|
| 91 |
+
set_agent(
|
| 92 |
+
target_only.get_agent(0), viewpoint["position_xyz"],
|
| 93 |
+
viewpoint["rotation_xyzw"],
|
| 94 |
+
)
|
| 95 |
+
semantic = np.asarray(target_only.get_sensor_observations()["semantic"])
|
| 96 |
+
self.projected_pixels[state] = int(np.count_nonzero(semantic == self.semantic_id))
|
| 97 |
+
finally:
|
| 98 |
+
if not dynamic:
|
| 99 |
+
target_only.close()
|
| 100 |
+
if dynamic:
|
| 101 |
+
self.target_only = target_only
|
| 102 |
+
self.target_only_object_id = target_only_id
|
| 103 |
+
self.real = make_simulator(
|
| 104 |
+
self.hssd_root, self.scene_id, self.navmesh, self.gpu, 320, 240
|
| 105 |
+
)
|
| 106 |
+
self.object_id = self._place_target(self.real, truth, target)
|
| 107 |
+
|
| 108 |
+
def inspect(self, state: int, position) -> dict:
|
| 109 |
+
if self.truth is None:
|
| 110 |
+
raise RuntimeError("prepare an episode before inspection")
|
| 111 |
+
viewpoint = self.public_viewpoints[state]
|
| 112 |
+
set_agent(self.real.get_agent(0), position, viewpoint["rotation_xyzw"])
|
| 113 |
+
observations = self.real.get_sensor_observations()
|
| 114 |
+
self.last_observation = {
|
| 115 |
+
"rgb": np.asarray(observations["rgb"]),
|
| 116 |
+
"depth": np.asarray(observations["depth"]),
|
| 117 |
+
}
|
| 118 |
+
actual = np.asarray(observations["semantic"])
|
| 119 |
+
self.last_semantic = actual
|
| 120 |
+
actual_pixels = int(np.count_nonzero(actual == self.semantic_id))
|
| 121 |
+
if self.target_only is not None:
|
| 122 |
+
set_agent(self.target_only.get_agent(0), position, viewpoint["rotation_xyzw"])
|
| 123 |
+
projection = np.asarray(self.target_only.get_sensor_observations()["semantic"])
|
| 124 |
+
projected_pixels = int(np.count_nonzero(projection == self.semantic_id))
|
| 125 |
+
else:
|
| 126 |
+
projected_pixels = self.projected_pixels[state]
|
| 127 |
+
visible_fraction = min(1.0, actual_pixels / projected_pixels) if projected_pixels else 0.0
|
| 128 |
+
goal_distance = min(
|
| 129 |
+
self.distance(position, goal["position_xyz"])
|
| 130 |
+
for goal in self.truth["valid_goal_viewpoints"]
|
| 131 |
+
)
|
| 132 |
+
if self.detector is not None and hasattr(self.detector, "detect_instances"):
|
| 133 |
+
self.last_detections = self.detector.detect_instances(
|
| 134 |
+
np.asarray(observations["rgb"]), self.target_category
|
| 135 |
+
)
|
| 136 |
+
detected = bool(self.last_detections)
|
| 137 |
+
elif self.detector is not None:
|
| 138 |
+
self.last_detections = ()
|
| 139 |
+
detected = self.detector(
|
| 140 |
+
np.asarray(observations["rgb"]),
|
| 141 |
+
np.asarray(observations["depth"]),
|
| 142 |
+
self.target_category,
|
| 143 |
+
)
|
| 144 |
+
else:
|
| 145 |
+
self.last_detections = ()
|
| 146 |
+
detected = actual_pixels > 0
|
| 147 |
+
return {
|
| 148 |
+
"detected": detected,
|
| 149 |
+
"visible_fraction": visible_fraction,
|
| 150 |
+
"distance_to_valid_goal_m": goal_distance,
|
| 151 |
+
"visible_target_pixels": actual_pixels,
|
| 152 |
+
"projected_target_pixels": projected_pixels,
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
def move_target(self, event: dict) -> None:
|
| 156 |
+
if self.real is None or self.object_id is None:
|
| 157 |
+
raise RuntimeError("prepare an episode before moving its target")
|
| 158 |
+
position = mn.Vector3(*event["target_position_xyz"])
|
| 159 |
+
self.real.get_rigid_object_manager().get_object_by_id(self.object_id).translation = position
|
| 160 |
+
if self.target_only is not None and self.target_only_object_id is not None:
|
| 161 |
+
self.target_only.get_rigid_object_manager().get_object_by_id(
|
| 162 |
+
self.target_only_object_id
|
| 163 |
+
).translation = position
|
| 164 |
+
self.real.perform_discrete_collision_detection()
|
| 165 |
+
self.truth["target_position_xyz"] = event["target_position_xyz"]
|
| 166 |
+
if "valid_goal_viewpoints" in event:
|
| 167 |
+
self.truth["valid_goal_viewpoints"] = event["valid_goal_viewpoints"]
|
| 168 |
+
if "current_state_id" in event:
|
| 169 |
+
self.truth["current_state_id"] = event["current_state_id"]
|
| 170 |
+
|
| 171 |
+
def clear(self) -> None:
|
| 172 |
+
if self.real is not None and self.object_id is not None:
|
| 173 |
+
self.real.get_rigid_object_manager().remove_object_by_id(self.object_id)
|
| 174 |
+
self.object_id = None
|
| 175 |
+
self.truth = None
|
| 176 |
+
self.last_observation = None
|
| 177 |
+
self.last_detections = ()
|
| 178 |
+
self.last_semantic = None
|
| 179 |
+
if self.target_only is not None:
|
| 180 |
+
self.target_only.close()
|
| 181 |
+
self.target_only = None
|
| 182 |
+
self.target_only_object_id = None
|
| 183 |
+
|
| 184 |
+
def close(self) -> None:
|
| 185 |
+
self.clear()
|
| 186 |
+
if self.real is not None:
|
| 187 |
+
self.real.close()
|
| 188 |
+
self.real = None
|
code/evolvingnav_paper/calibration.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Validation-only logistic calibration of online detector recall."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
from sklearn.linear_model import LogisticRegression
|
| 11 |
+
from sklearn.preprocessing import StandardScaler
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
FEATURES = (
|
| 15 |
+
"coverage", "range_m", "angle_cos", "projected_pixels",
|
| 16 |
+
"depth_quality", "category_recall",
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass(frozen=True)
|
| 21 |
+
class DetectionCalibrator:
|
| 22 |
+
intercept: float
|
| 23 |
+
coefficients: tuple[float, ...]
|
| 24 |
+
means: tuple[float, ...]
|
| 25 |
+
scales: tuple[float, ...]
|
| 26 |
+
|
| 27 |
+
@classmethod
|
| 28 |
+
def fit(cls, validation_rows: list[dict]) -> "DetectionCalibrator":
|
| 29 |
+
if not validation_rows or {int(row["detected"]) for row in validation_rows} != {0, 1}:
|
| 30 |
+
raise ValueError("calibration requires positive and negative validation observations")
|
| 31 |
+
features = np.asarray(
|
| 32 |
+
[[float(row[key]) for key in FEATURES] for row in validation_rows], dtype=float
|
| 33 |
+
)
|
| 34 |
+
if not np.isfinite(features).all():
|
| 35 |
+
raise ValueError("calibration features must be finite")
|
| 36 |
+
scaler = StandardScaler().fit(features)
|
| 37 |
+
classifier = LogisticRegression(max_iter=1000).fit(
|
| 38 |
+
scaler.transform(features), [int(row["detected"]) for row in validation_rows]
|
| 39 |
+
)
|
| 40 |
+
return cls(
|
| 41 |
+
float(classifier.intercept_[0]), tuple(classifier.coef_[0].tolist()),
|
| 42 |
+
tuple(scaler.mean_.tolist()), tuple(scaler.scale_.tolist()),
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
def predict(self, online_features: dict) -> float:
|
| 46 |
+
values = np.asarray([float(online_features[key]) for key in FEATURES])
|
| 47 |
+
standardized = (values - self.means) / self.scales
|
| 48 |
+
logit = self.intercept + float(np.dot(self.coefficients, standardized))
|
| 49 |
+
return float(1.0 / (1.0 + np.exp(-np.clip(logit, -30, 30))))
|
| 50 |
+
|
| 51 |
+
def save(self, path: Path) -> None:
|
| 52 |
+
path.write_text(json.dumps({
|
| 53 |
+
"intercept": self.intercept, "coefficients": self.coefficients,
|
| 54 |
+
"means": self.means, "scales": self.scales,
|
| 55 |
+
"features": FEATURES,
|
| 56 |
+
}, indent=2) + "\n", encoding="utf-8")
|
| 57 |
+
|
| 58 |
+
@classmethod
|
| 59 |
+
def load(cls, path: Path) -> "DetectionCalibrator":
|
| 60 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 61 |
+
if tuple(data["features"]) != FEATURES:
|
| 62 |
+
raise ValueError("calibrator feature schema mismatch")
|
| 63 |
+
return cls(
|
| 64 |
+
float(data["intercept"]), tuple(data["coefficients"]),
|
| 65 |
+
tuple(data["means"]), tuple(data["scales"]),
|
| 66 |
+
)
|
code/evolvingnav_paper/controller.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Frozen GPT-5.6-Luna function-call controller for legal high-level actions."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
from urllib.request import Request, urlopen
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class LunaToolController:
|
| 11 |
+
def __init__(self, *, requester=None) -> None:
|
| 12 |
+
self.requester = requester or self._request
|
| 13 |
+
|
| 14 |
+
@staticmethod
|
| 15 |
+
def _request(payload: dict) -> dict:
|
| 16 |
+
key = os.environ.get("OPENAI_API_KEY")
|
| 17 |
+
if not key:
|
| 18 |
+
raise RuntimeError("OPENAI_API_KEY is required for the Luna controller")
|
| 19 |
+
request = Request(
|
| 20 |
+
"https://api.openai.com/v1/responses",
|
| 21 |
+
data=json.dumps(payload).encode("utf-8"),
|
| 22 |
+
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
|
| 23 |
+
method="POST",
|
| 24 |
+
)
|
| 25 |
+
with urlopen(request, timeout=60) as response:
|
| 26 |
+
return json.load(response)
|
| 27 |
+
|
| 28 |
+
def choose(self, legal_actions: list[str], public_context: dict) -> str:
|
| 29 |
+
if not legal_actions:
|
| 30 |
+
raise ValueError("controller needs at least one legal action")
|
| 31 |
+
forbidden = ("private", "ground_truth", "true_state", "target_position_xyz",
|
| 32 |
+
"oracle_shortest_path")
|
| 33 |
+
if any(token in str(key).lower() for key in public_context for token in forbidden):
|
| 34 |
+
raise ValueError("evaluator-private field in controller context")
|
| 35 |
+
payload = {
|
| 36 |
+
"model": "gpt-5.6-luna",
|
| 37 |
+
"input": [
|
| 38 |
+
{"role": "system", "content": (
|
| 39 |
+
"You are a frozen embodied-search tool controller. Choose one legal high-level "
|
| 40 |
+
"action using only the supplied public belief, memory, costs and evidence. "
|
| 41 |
+
"Prefer the maximum paper utility and never infer evaluator-private truth."
|
| 42 |
+
)},
|
| 43 |
+
{"role": "user", "content": json.dumps(public_context, ensure_ascii=False)},
|
| 44 |
+
],
|
| 45 |
+
"tools": [{
|
| 46 |
+
"type": "function", "name": "select_action",
|
| 47 |
+
"description": "Choose one legal EvolvingNav high-level action.",
|
| 48 |
+
"strict": True,
|
| 49 |
+
"parameters": {
|
| 50 |
+
"type": "object",
|
| 51 |
+
"properties": {"action": {"type": "string", "enum": legal_actions}},
|
| 52 |
+
"required": ["action"], "additionalProperties": False,
|
| 53 |
+
},
|
| 54 |
+
}],
|
| 55 |
+
"tool_choice": {"type": "function", "name": "select_action"},
|
| 56 |
+
}
|
| 57 |
+
response = self.requester(payload)
|
| 58 |
+
calls = [item for item in response.get("output", [])
|
| 59 |
+
if item.get("type") == "function_call" and item.get("name") == "select_action"]
|
| 60 |
+
if len(calls) != 1:
|
| 61 |
+
raise ValueError("Luna did not return exactly one select_action call")
|
| 62 |
+
action = json.loads(calls[0]["arguments"])["action"]
|
| 63 |
+
if action not in legal_actions:
|
| 64 |
+
raise ValueError("Luna selected an illegal action")
|
| 65 |
+
return action
|
code/evolvingnav_paper/coverage.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Project public candidate geometry into an online depth frame (Appendix A.4)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import math
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def candidate_surface_samples(center_xyz, radius_m: float = 0.15,
|
| 11 |
+
place_points=None) -> np.ndarray:
|
| 12 |
+
"""A 5x5 surface grid from public receptacle slots or a center fallback."""
|
| 13 |
+
center = np.asarray(center_xyz, dtype=float)
|
| 14 |
+
if place_points:
|
| 15 |
+
points = np.asarray(place_points, dtype=float)
|
| 16 |
+
xs = np.linspace(points[:, 0].min(), points[:, 0].max(), 5)
|
| 17 |
+
zs = np.linspace(points[:, 2].min(), points[:, 2].max(), 5)
|
| 18 |
+
height = float(np.median(points[:, 1]))
|
| 19 |
+
return np.asarray([[x, height, z] for x in xs for z in zs])
|
| 20 |
+
offsets = np.linspace(-radius_m, radius_m, 5)
|
| 21 |
+
return np.asarray([center + [x, 0.0, z] for x in offsets for z in offsets])
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _rotation(xyzw) -> np.ndarray:
|
| 25 |
+
x, y, z, w = np.asarray(xyzw, dtype=float)
|
| 26 |
+
norm = x*x + y*y + z*z + w*w
|
| 27 |
+
if norm <= 0:
|
| 28 |
+
raise ValueError("invalid camera orientation")
|
| 29 |
+
s = 2.0 / norm
|
| 30 |
+
return np.array([
|
| 31 |
+
[1-s*(y*y+z*z), s*(x*y-z*w), s*(x*z+y*w)],
|
| 32 |
+
[s*(x*y+z*w), 1-s*(x*x+z*z), s*(y*z-x*w)],
|
| 33 |
+
[s*(x*z-y*w), s*(y*z+x*w), 1-s*(x*x+y*y)],
|
| 34 |
+
])
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def camera_forward(xyzw) -> np.ndarray:
|
| 38 |
+
return _rotation(xyzw) @ np.array([0.0, 0.0, -1.0])
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def heading_quaternion(displacement_xyz) -> list[float]:
|
| 42 |
+
direction = np.asarray(displacement_xyz, dtype=float)
|
| 43 |
+
if math.hypot(direction[0], direction[2]) <= 1e-9:
|
| 44 |
+
raise ValueError("heading needs horizontal displacement")
|
| 45 |
+
yaw = math.atan2(-direction[0], -direction[2])
|
| 46 |
+
return [0.0, math.sin(yaw / 2), 0.0, math.cos(yaw / 2)]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def depth_quality(depth: np.ndarray) -> float:
|
| 50 |
+
values = np.asarray(depth)
|
| 51 |
+
return float(np.mean(np.isfinite(values) & (values > 0)))
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def camera_transform(agent_xyz, agent_xyzw, sensor_height_m: float = 1.35) -> np.ndarray:
|
| 55 |
+
rotation = _rotation(agent_xyzw)
|
| 56 |
+
transform = np.eye(4, dtype=float)
|
| 57 |
+
transform[:3, :3] = rotation
|
| 58 |
+
transform[:3, 3] = np.asarray(agent_xyz, dtype=float) + rotation @ [0, sensor_height_m, 0]
|
| 59 |
+
return transform
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def visible_sample_ids(samples: np.ndarray, agent_xyz, agent_xyzw, depth: np.ndarray,
|
| 63 |
+
hfov_degrees: float, *, sensor_height_m: float = 1.35,
|
| 64 |
+
depth_tolerance_m: float = 0.25) -> frozenset[int]:
|
| 65 |
+
height, width = depth.shape
|
| 66 |
+
transform = camera_transform(agent_xyz, agent_xyzw, sensor_height_m)
|
| 67 |
+
rotation = transform[:3, :3]
|
| 68 |
+
camera_xyz = transform[:3, 3]
|
| 69 |
+
camera_points = (np.asarray(samples, dtype=float) - camera_xyz) @ rotation
|
| 70 |
+
focal = width / (2.0 * math.tan(math.radians(hfov_degrees) / 2.0))
|
| 71 |
+
visible: set[int] = set()
|
| 72 |
+
for index, (x, y, z) in enumerate(camera_points):
|
| 73 |
+
if z >= -0.05:
|
| 74 |
+
continue
|
| 75 |
+
u = int(round(width / 2 + focal * x / -z))
|
| 76 |
+
v = int(round(height / 2 - focal * y / -z))
|
| 77 |
+
if 0 <= u < width and 0 <= v < height:
|
| 78 |
+
measured = float(depth[v, u])
|
| 79 |
+
if math.isfinite(measured) and measured > 0 and abs(measured + z) <= depth_tolerance_m:
|
| 80 |
+
visible.add(index)
|
| 81 |
+
return frozenset(visible)
|
code/evolvingnav_paper/evaluate.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluator-only truth access and high-level N1/N2 scoring."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import math
|
| 6 |
+
from typing import Callable
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def evaluate_search(
|
| 10 |
+
episode: dict,
|
| 11 |
+
truth: dict,
|
| 12 |
+
belief: dict[int, float],
|
| 13 |
+
*,
|
| 14 |
+
start,
|
| 15 |
+
goals: dict,
|
| 16 |
+
distance: Callable,
|
| 17 |
+
inspect: Callable,
|
| 18 |
+
task: str = "n2",
|
| 19 |
+
) -> dict:
|
| 20 |
+
from evolvingnav_paper.policy import rank_candidates
|
| 21 |
+
|
| 22 |
+
if task not in {"n1", "n2"}:
|
| 23 |
+
raise ValueError(task)
|
| 24 |
+
true_state = int(truth["current_state_id"])
|
| 25 |
+
oracle_m = float(truth["oracle_shortest_path_m"])
|
| 26 |
+
max_inspections = 1 if task == "n1" else int(episode["episode_budget"]["max_candidate_inspections"])
|
| 27 |
+
max_path = float(episode["episode_budget"]["max_path_length_m"])
|
| 28 |
+
current, travelled, inspected, actions, evidence = start, 0.0, [], [], []
|
| 29 |
+
remaining = set(belief) & set(goals)
|
| 30 |
+
success = False
|
| 31 |
+
for _ in range(max_inspections):
|
| 32 |
+
costs = {state: float(distance(current, goals[state])) for state in remaining}
|
| 33 |
+
reachable = {state: belief[state] for state in remaining if math.isfinite(costs[state])}
|
| 34 |
+
if not reachable:
|
| 35 |
+
break
|
| 36 |
+
state = rank_candidates(reachable, costs, task=task)[0]
|
| 37 |
+
goal = goals[state]
|
| 38 |
+
leg = costs[state]
|
| 39 |
+
if not math.isfinite(leg) or travelled + leg > max_path:
|
| 40 |
+
break
|
| 41 |
+
travelled += leg
|
| 42 |
+
inspected.append(state)
|
| 43 |
+
actions.extend((f"NAVIGATE_TO({state})", f"INSPECT({state})"))
|
| 44 |
+
current = goal
|
| 45 |
+
observation = inspect(state, goal)
|
| 46 |
+
evidence.append({"state_id": state, **observation})
|
| 47 |
+
if observation["detected"]:
|
| 48 |
+
actions.append("STOP")
|
| 49 |
+
success = (
|
| 50 |
+
float(observation["distance_to_valid_goal_m"])
|
| 51 |
+
<= float(episode["success_spec"]["max_geodesic_distance_m"])
|
| 52 |
+
and float(observation["visible_fraction"]) >= 0.20
|
| 53 |
+
)
|
| 54 |
+
break
|
| 55 |
+
remaining.remove(state)
|
| 56 |
+
return {
|
| 57 |
+
"base_episode_id": episode["base_episode_id"],
|
| 58 |
+
"task": task,
|
| 59 |
+
"success": success,
|
| 60 |
+
"inspections": len(inspected),
|
| 61 |
+
"inspection_order": inspected,
|
| 62 |
+
"inspection_evidence": evidence,
|
| 63 |
+
"actions": actions,
|
| 64 |
+
"path_m": round(travelled, 6),
|
| 65 |
+
"spl": round(float(success) * oracle_m / max(travelled, oracle_m, 1e-9), 6),
|
| 66 |
+
"true_state_id": true_state,
|
| 67 |
+
}
|
code/evolvingnav_paper/filter.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Current-time filtering, counterfactual arrival forecasts, and evidence rounds."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import math
|
| 6 |
+
from collections import defaultdict
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class BeliefFilter:
|
| 12 |
+
def __init__(self, prior: dict[int, float], transition) -> None:
|
| 13 |
+
if not prior or any(v < 0 for v in prior.values()) or sum(prior.values()) <= 0:
|
| 14 |
+
raise ValueError("prior must be a nonempty probability distribution")
|
| 15 |
+
total = sum(prior.values())
|
| 16 |
+
self.posterior = {int(k): float(v / total) for k, v in prior.items()}
|
| 17 |
+
self.transition = transition
|
| 18 |
+
self.applied_evidence: set[str] = set()
|
| 19 |
+
|
| 20 |
+
def arrival(self, elapsed_s: float) -> dict[int, float]:
|
| 21 |
+
if elapsed_s < 0:
|
| 22 |
+
raise ValueError("time cannot go backwards")
|
| 23 |
+
states = list(self.posterior)
|
| 24 |
+
kernel = self.transition.matrix(states, elapsed_s)
|
| 25 |
+
prediction = np.asarray(list(self.posterior.values())) @ kernel
|
| 26 |
+
return dict(zip(states, prediction.tolist(), strict=True))
|
| 27 |
+
|
| 28 |
+
def advance(self, elapsed_s: float) -> None:
|
| 29 |
+
self.posterior = self.arrival(elapsed_s)
|
| 30 |
+
if hasattr(self.transition, "advance_clock"):
|
| 31 |
+
self.transition.advance_clock(elapsed_s)
|
| 32 |
+
|
| 33 |
+
def negative(self, detection_probability: dict[int, float], evidence_id: str) -> bool:
|
| 34 |
+
if evidence_id in self.applied_evidence:
|
| 35 |
+
return False
|
| 36 |
+
unnormalized = {
|
| 37 |
+
state: probability * (1.0 - float(detection_probability.get(state, 0.0)))
|
| 38 |
+
for state, probability in self.posterior.items()
|
| 39 |
+
}
|
| 40 |
+
if any(value < 0 for value in unnormalized.values()):
|
| 41 |
+
raise ValueError("detection probability must be in [0, 1]")
|
| 42 |
+
total = sum(unnormalized.values())
|
| 43 |
+
if total <= 0:
|
| 44 |
+
raise ValueError("measurement removes all belief mass")
|
| 45 |
+
self.posterior = {state: value / total for state, value in unnormalized.items()}
|
| 46 |
+
self.applied_evidence.add(evidence_id)
|
| 47 |
+
return True
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class EvidenceLedger:
|
| 51 |
+
def __init__(self, *, min_new_coverage: float = 0.05, sufficient_coverage: float = 0.70,
|
| 52 |
+
sample_count: dict[int, int] | None = None) -> None:
|
| 53 |
+
self.min_new_coverage = min_new_coverage
|
| 54 |
+
self.sufficient_coverage = sufficient_coverage
|
| 55 |
+
self.sample_count = sample_count or {}
|
| 56 |
+
self._round = defaultdict(int)
|
| 57 |
+
self._covered: dict[tuple[int, int], set[int]] = defaultdict(set)
|
| 58 |
+
self._used: set[str] = set()
|
| 59 |
+
self._inspected: set[int] = set()
|
| 60 |
+
self._last_round_time: dict[int, float] = {}
|
| 61 |
+
|
| 62 |
+
def round(self, state: int) -> int:
|
| 63 |
+
return self._round[state]
|
| 64 |
+
|
| 65 |
+
def coverage(self, state: int) -> float:
|
| 66 |
+
samples = self.sample_count.get(state, 1)
|
| 67 |
+
return len(self._covered[(state, self.round(state))]) / samples
|
| 68 |
+
|
| 69 |
+
def admit(self, evidence) -> float:
|
| 70 |
+
if evidence.evidence_id in self._used:
|
| 71 |
+
return 0.0
|
| 72 |
+
state = evidence.state_id
|
| 73 |
+
previous = self._covered[(state, self.round(state))]
|
| 74 |
+
new = set(evidence.surface_samples) - previous
|
| 75 |
+
fraction = len(new) / self.sample_count.get(state, max(len(new), 1))
|
| 76 |
+
self._used.add(evidence.evidence_id)
|
| 77 |
+
if fraction <= self.min_new_coverage:
|
| 78 |
+
return 0.0
|
| 79 |
+
previous.update(new)
|
| 80 |
+
return min(1.0, fraction)
|
| 81 |
+
|
| 82 |
+
def mark_inspected(self, state: int, now_s: float | None = None) -> None:
|
| 83 |
+
self._inspected.add(state)
|
| 84 |
+
if now_s is not None:
|
| 85 |
+
self._last_round_time[state] = now_s
|
| 86 |
+
|
| 87 |
+
def eligible(self, state: int, *, belief: float, return_probability: float,
|
| 88 |
+
new_coverage: float, dynamic: bool = True,
|
| 89 |
+
now_s: float | None = None) -> bool:
|
| 90 |
+
if state not in self._inspected and self.coverage(state) < self.sufficient_coverage:
|
| 91 |
+
return True
|
| 92 |
+
if new_coverage > self.min_new_coverage:
|
| 93 |
+
return True
|
| 94 |
+
time_passed = now_s is None or now_s > self._last_round_time.get(state, -math.inf)
|
| 95 |
+
if dynamic and time_passed and (belief > 0.10 or return_probability > 0.05):
|
| 96 |
+
self._round[state] += 1
|
| 97 |
+
self._inspected.discard(state)
|
| 98 |
+
if now_s is not None:
|
| 99 |
+
self._last_round_time[state] = now_s
|
| 100 |
+
return True
|
| 101 |
+
return False
|
code/evolvingnav_paper/habitat_utils.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Habitat-Sim camera, NavMesh and agent-pose helpers."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import math
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
import habitat_sim
|
| 10 |
+
import numpy as np
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def sensor_specs(width: int, height: int) -> list[Any]:
|
| 14 |
+
specs = []
|
| 15 |
+
for name, sensor_type in (
|
| 16 |
+
("rgb", habitat_sim.SensorType.COLOR),
|
| 17 |
+
("depth", habitat_sim.SensorType.DEPTH),
|
| 18 |
+
("semantic", habitat_sim.SensorType.SEMANTIC),
|
| 19 |
+
):
|
| 20 |
+
spec = habitat_sim.CameraSensorSpec()
|
| 21 |
+
spec.uuid = name
|
| 22 |
+
spec.sensor_type = sensor_type
|
| 23 |
+
spec.sensor_subtype = habitat_sim.SensorSubType.PINHOLE
|
| 24 |
+
spec.resolution = [height, width]
|
| 25 |
+
spec.position = [0.0, 1.35, 0.0]
|
| 26 |
+
spec.hfov = 79.0
|
| 27 |
+
specs.append(spec)
|
| 28 |
+
return specs
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def make_simulator(
|
| 32 |
+
hssd: Path, scene_id: str, navmesh: Path, gpu: int, width: int, height: int
|
| 33 |
+
) -> Any:
|
| 34 |
+
sim_config = habitat_sim.SimulatorConfiguration()
|
| 35 |
+
sim_config.scene_id = str(
|
| 36 |
+
(hssd / f"scenes-uncluttered/{scene_id}.scene_instance.json").resolve()
|
| 37 |
+
)
|
| 38 |
+
sim_config.scene_dataset_config_file = str(
|
| 39 |
+
(hssd / "hssd-hab-uncluttered.scene_dataset_config.json").resolve()
|
| 40 |
+
)
|
| 41 |
+
sim_config.enable_physics = True
|
| 42 |
+
sim_config.create_renderer = True
|
| 43 |
+
sim_config.load_semantic_mesh = True
|
| 44 |
+
sim_config.gpu_device_id = gpu
|
| 45 |
+
agent_config = habitat_sim.agent.AgentConfiguration()
|
| 46 |
+
agent_config.sensor_specifications = sensor_specs(width, height)
|
| 47 |
+
simulator = habitat_sim.Simulator(
|
| 48 |
+
habitat_sim.Configuration(sim_config, [agent_config])
|
| 49 |
+
)
|
| 50 |
+
if not simulator.pathfinder.load_nav_mesh(str(navmesh.resolve())):
|
| 51 |
+
simulator.close()
|
| 52 |
+
raise RuntimeError(f"cannot load navmesh {navmesh}")
|
| 53 |
+
return simulator
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def set_agent(agent: Any, position: list[float], xyzw: list[float]) -> None:
|
| 57 |
+
state = habitat_sim.AgentState()
|
| 58 |
+
state.position = np.asarray(position, dtype=np.float32)
|
| 59 |
+
state.rotation = np.asarray(xyzw, dtype=np.float32)
|
| 60 |
+
agent.set_state(state)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def path_distance(pathfinder: Any, start: list[float], goal: list[float]) -> float:
|
| 64 |
+
path = habitat_sim.ShortestPath()
|
| 65 |
+
path.requested_start = np.asarray(start, dtype=np.float32)
|
| 66 |
+
path.requested_end = np.asarray(goal, dtype=np.float32)
|
| 67 |
+
return float(path.geodesic_distance) if pathfinder.find_path(path) else math.inf
|
code/evolvingnav_paper/memory.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Causal, time-valid 4D entity memory (paper Equations 3, 17 and 18)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass, field, replace
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def backproject(u: float, v: float, depth_m: float, intrinsics: np.ndarray,
|
| 11 |
+
camera_to_world: np.ndarray) -> np.ndarray:
|
| 12 |
+
if not np.isfinite(depth_m) or depth_m <= 0:
|
| 13 |
+
raise ValueError("depth must be positive and finite")
|
| 14 |
+
ray = np.linalg.solve(np.asarray(intrinsics, dtype=float), [u, v, 1.0])
|
| 15 |
+
camera_point = np.r_[depth_m * ray, 1.0]
|
| 16 |
+
world = np.asarray(camera_to_world, dtype=float) @ camera_point
|
| 17 |
+
return world[:3] / world[3]
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass
|
| 21 |
+
class EntityVersion:
|
| 22 |
+
valid_from: float
|
| 23 |
+
valid_to: float | None
|
| 24 |
+
state_id: int
|
| 25 |
+
confidence: float
|
| 26 |
+
feature: tuple[float, ...] = ()
|
| 27 |
+
evidence_handles: list[str] = field(default_factory=list)
|
| 28 |
+
world_points: list[tuple[float, float, float]] = field(default_factory=list)
|
| 29 |
+
evidence_times: list[float] = field(default_factory=list)
|
| 30 |
+
evidence_confidences: list[float] = field(default_factory=list)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@dataclass(frozen=True)
|
| 34 |
+
class NegativeFrame:
|
| 35 |
+
evidence_id: str
|
| 36 |
+
state_id: int
|
| 37 |
+
timestamp: float
|
| 38 |
+
pose_xyz: tuple[float, float, float]
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class VersionedMemory:
|
| 42 |
+
def __init__(self) -> None:
|
| 43 |
+
self.entities: dict[str, list[EntityVersion]] = {}
|
| 44 |
+
self.used_evidence: set[str] = set()
|
| 45 |
+
self.negative_frames: list[NegativeFrame] = []
|
| 46 |
+
|
| 47 |
+
def record_negative(self, evidence_id: str, state_id: int, timestamp: float,
|
| 48 |
+
pose_xyz) -> None:
|
| 49 |
+
if evidence_id in self.used_evidence:
|
| 50 |
+
return
|
| 51 |
+
self.negative_frames.append(NegativeFrame(
|
| 52 |
+
evidence_id, state_id, timestamp, tuple(float(x) for x in pose_xyz)
|
| 53 |
+
))
|
| 54 |
+
self.used_evidence.add(evidence_id)
|
| 55 |
+
|
| 56 |
+
def evidence_at(self, cutoff: float) -> list[NegativeFrame]:
|
| 57 |
+
return [frame for frame in self.negative_frames if frame.timestamp <= cutoff]
|
| 58 |
+
|
| 59 |
+
def observe(self, entity_id: str, state_id: int, timestamp: float,
|
| 60 |
+
confidence: float, evidence_id: str, world_point: np.ndarray,
|
| 61 |
+
feature: tuple[float, ...] = ()) -> None:
|
| 62 |
+
if evidence_id in self.used_evidence:
|
| 63 |
+
return
|
| 64 |
+
versions = self.entities.setdefault(entity_id, [])
|
| 65 |
+
if versions and timestamp < versions[-1].valid_from:
|
| 66 |
+
raise ValueError("causal memory cannot accept an older observation")
|
| 67 |
+
if not 0 <= confidence <= 1:
|
| 68 |
+
raise ValueError("confidence must be a probability")
|
| 69 |
+
point = tuple(float(x) for x in world_point)
|
| 70 |
+
if versions and versions[-1].state_id == state_id:
|
| 71 |
+
current = versions[-1]
|
| 72 |
+
current.confidence = max(current.confidence, confidence)
|
| 73 |
+
current.evidence_handles.append(evidence_id)
|
| 74 |
+
current.world_points.append(point)
|
| 75 |
+
current.evidence_times.append(timestamp)
|
| 76 |
+
current.evidence_confidences.append(confidence)
|
| 77 |
+
else:
|
| 78 |
+
if versions:
|
| 79 |
+
versions[-1].valid_to = timestamp
|
| 80 |
+
versions.append(EntityVersion(timestamp, None, state_id, confidence,
|
| 81 |
+
feature, [evidence_id], [point],
|
| 82 |
+
[timestamp], [confidence]))
|
| 83 |
+
self.used_evidence.add(evidence_id)
|
| 84 |
+
|
| 85 |
+
@staticmethod
|
| 86 |
+
def _causal(version: EntityVersion, cutoff: float) -> EntityVersion:
|
| 87 |
+
indices = [i for i, time in enumerate(version.evidence_times) if time <= cutoff]
|
| 88 |
+
return replace(
|
| 89 |
+
version,
|
| 90 |
+
valid_to=version.valid_to if version.valid_to is not None and version.valid_to <= cutoff else None,
|
| 91 |
+
confidence=max(version.evidence_confidences[i] for i in indices),
|
| 92 |
+
evidence_handles=[version.evidence_handles[i] for i in indices],
|
| 93 |
+
world_points=[version.world_points[i] for i in indices],
|
| 94 |
+
evidence_times=[version.evidence_times[i] for i in indices],
|
| 95 |
+
evidence_confidences=[version.evidence_confidences[i] for i in indices],
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
def at(self, entity_id: str, timestamp: float) -> EntityVersion | None:
|
| 99 |
+
for version in reversed(self.entities.get(entity_id, [])):
|
| 100 |
+
if version.valid_from <= timestamp and (version.valid_to is None or timestamp < version.valid_to):
|
| 101 |
+
return self._causal(version, timestamp)
|
| 102 |
+
return None
|
| 103 |
+
|
| 104 |
+
def history(self, entity_id: str, cutoff: float) -> list[EntityVersion]:
|
| 105 |
+
return [self._causal(v, cutoff)
|
| 106 |
+
for v in self.entities.get(entity_id, []) if v.valid_from <= cutoff]
|
code/evolvingnav_paper/perception.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Frozen Grounding DINO and SAM2 target-category inspection."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from collections.abc import Callable
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
import yaml
|
| 11 |
+
|
| 12 |
+
Detection = tuple[str, float, tuple[float, float, float, float]]
|
| 13 |
+
Detector = Callable[[np.ndarray, tuple[str, ...]], tuple[Detection, ...]]
|
| 14 |
+
Segmenter = Callable[[np.ndarray, tuple[float, float, float, float]], np.ndarray]
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass(frozen=True)
|
| 18 |
+
class InstanceDetection:
|
| 19 |
+
category: str
|
| 20 |
+
confidence: float
|
| 21 |
+
mask: np.ndarray
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def detect_instances(detector: Detector, segmenter: Segmenter,
|
| 25 |
+
rgb: np.ndarray, category: str) -> tuple[InstanceDetection, ...]:
|
| 26 |
+
image = np.asarray(rgb)[..., :3]
|
| 27 |
+
found = []
|
| 28 |
+
for label, score, box in detector(image, (category,)):
|
| 29 |
+
if label != category:
|
| 30 |
+
continue
|
| 31 |
+
mask = np.asarray(segmenter(image, box), dtype=bool)
|
| 32 |
+
if mask.shape == image.shape[:2] and mask.any():
|
| 33 |
+
found.append(InstanceDetection(label, float(score), mask))
|
| 34 |
+
return tuple(found)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def detect_category(
|
| 38 |
+
detector: Detector, segmenter: Segmenter, rgb: np.ndarray, category: str
|
| 39 |
+
) -> bool:
|
| 40 |
+
"""Use only the rendered RGB image and public target category for STOP."""
|
| 41 |
+
return bool(detect_instances(detector, segmenter, rgb, category))
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class GroundedSAMInspector:
|
| 45 |
+
"""Frozen public models; the private semantic image never enters these heads."""
|
| 46 |
+
|
| 47 |
+
def __init__(self, config_path: Path, *, dino_model: str, sam_model: str) -> None:
|
| 48 |
+
config = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
| 49 |
+
self.detector, self.segmenter = _load_models(
|
| 50 |
+
dino_model, sam_model, config
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
def __call__(self, rgb: np.ndarray, _depth: np.ndarray, category: str) -> bool:
|
| 54 |
+
return detect_category(self.detector, self.segmenter, rgb, category)
|
| 55 |
+
|
| 56 |
+
def detect_instances(self, rgb: np.ndarray, category: str) -> tuple[InstanceDetection, ...]:
|
| 57 |
+
return detect_instances(self.detector, self.segmenter, rgb, category)
|
| 58 |
+
|
| 59 |
+
def close(self) -> None:
|
| 60 |
+
del self.detector, self.segmenter
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _load_models(dino_model: str, sam_model: str, config: dict):
|
| 64 |
+
import torch
|
| 65 |
+
from PIL import Image
|
| 66 |
+
from transformers import (
|
| 67 |
+
AutoModelForZeroShotObjectDetection,
|
| 68 |
+
AutoProcessor,
|
| 69 |
+
Sam2Model,
|
| 70 |
+
Sam2Processor,
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
device = config["device"]
|
| 74 |
+
dino_revision = config["grounding_dino"]["revision"]
|
| 75 |
+
sam_revision = config["sam"]["revision"]
|
| 76 |
+
dino_kwargs = {"revision": dino_revision} if dino_model == config["grounding_dino"]["model_id"] else {}
|
| 77 |
+
sam_kwargs = {"revision": sam_revision} if sam_model == config["sam"]["model_id"] else {}
|
| 78 |
+
dino_processor = AutoProcessor.from_pretrained(dino_model, **dino_kwargs)
|
| 79 |
+
dino = AutoModelForZeroShotObjectDetection.from_pretrained(
|
| 80 |
+
dino_model, **dino_kwargs
|
| 81 |
+
).to(device).eval()
|
| 82 |
+
sam_processor = Sam2Processor.from_pretrained(sam_model, **sam_kwargs)
|
| 83 |
+
sam = Sam2Model.from_pretrained(sam_model, **sam_kwargs).to(device).eval()
|
| 84 |
+
|
| 85 |
+
def detector(rgb: np.ndarray, categories: tuple[str, ...]) -> tuple[Detection, ...]:
|
| 86 |
+
image = Image.fromarray(rgb)
|
| 87 |
+
inputs = dino_processor(
|
| 88 |
+
images=image, text=[list(categories)], return_tensors="pt"
|
| 89 |
+
).to(device)
|
| 90 |
+
with torch.inference_mode():
|
| 91 |
+
outputs = dino(**inputs)
|
| 92 |
+
processed = dino_processor.post_process_grounded_object_detection(
|
| 93 |
+
outputs,
|
| 94 |
+
inputs.input_ids,
|
| 95 |
+
threshold=config["grounding_dino"]["box_threshold"],
|
| 96 |
+
text_threshold=config["grounding_dino"]["text_threshold"],
|
| 97 |
+
target_sizes=[image.size[::-1]],
|
| 98 |
+
)[0]
|
| 99 |
+
labels = processed.get("text_labels", processed.get("labels", ()))
|
| 100 |
+
return tuple(
|
| 101 |
+
(str(label), float(score), tuple(float(value) for value in box))
|
| 102 |
+
for label, score, box in zip(
|
| 103 |
+
labels, processed["scores"], processed["boxes"], strict=True
|
| 104 |
+
)
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
def segmenter(rgb: np.ndarray, box: tuple[float, float, float, float]) -> np.ndarray:
|
| 108 |
+
image = Image.fromarray(rgb)
|
| 109 |
+
inputs = sam_processor(
|
| 110 |
+
images=image, input_boxes=[[[*box]]], return_tensors="pt"
|
| 111 |
+
).to(device)
|
| 112 |
+
with torch.inference_mode():
|
| 113 |
+
outputs = sam(**inputs)
|
| 114 |
+
masks = sam_processor.post_process_masks(
|
| 115 |
+
outputs.pred_masks.cpu(), inputs["original_sizes"]
|
| 116 |
+
)[0]
|
| 117 |
+
scores = outputs.iou_scores[0, 0].cpu()
|
| 118 |
+
return masks[0, int(scores.argmax())].numpy().astype(bool)
|
| 119 |
+
|
| 120 |
+
return detector, segmenter
|
code/evolvingnav_paper/policy.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Public-input belief and candidate selection for the N1/N2 high-level track."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import math
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
|
| 9 |
+
from pack_p4d_hssd_records import pack_split
|
| 10 |
+
from readyagent.p4d_belief.data import MODEL_INPUT_KEYS, OPTIONAL_MODEL_INPUT_KEYS, derive_last_state
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def pack_public_query(query: dict, schema: dict, candidate_features: dict) -> dict[str, np.ndarray]:
|
| 14 |
+
"""Reuse the frozen feature schema without passing evaluator labels to the model."""
|
| 15 |
+
query_time = float(query["input"]["query"]["query_time_s"])
|
| 16 |
+
for field in ("target_history", "observable_context_history"):
|
| 17 |
+
timestamps = [float(row["timestamp_s"]) for row in query["input"].get(field, [])]
|
| 18 |
+
if any(timestamp > query_time for timestamp in timestamps):
|
| 19 |
+
raise ValueError(f"future observation in {field}")
|
| 20 |
+
if timestamps != sorted(timestamps):
|
| 21 |
+
raise ValueError(f"out-of-order observation in {field}")
|
| 22 |
+
record = {
|
| 23 |
+
"record_id": query["query_id"],
|
| 24 |
+
"world_variant": "routine", # packer metadata only; discarded below
|
| 25 |
+
"input": query["input"],
|
| 26 |
+
"supervision": {"current_state_id": 0, "moved_since_last_positive": False},
|
| 27 |
+
}
|
| 28 |
+
packed = pack_split(
|
| 29 |
+
[record],
|
| 30 |
+
max_history=64,
|
| 31 |
+
category_to_id=schema["category_to_id"],
|
| 32 |
+
state_count=int(schema["state_count_including_unknown"]),
|
| 33 |
+
candidate_features=candidate_features,
|
| 34 |
+
)
|
| 35 |
+
allowed = (*MODEL_INPUT_KEYS, *OPTIONAL_MODEL_INPUT_KEYS, "instance_uuid")
|
| 36 |
+
return {key: packed[key] for key in allowed if key in packed}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def candidate_utility(
|
| 40 |
+
*, arrival_probability: float, new_detection_probability: float,
|
| 41 |
+
distance_m: float, eta_s: float, lambda_time: float, lambda_inspect: float,
|
| 42 |
+
) -> float:
|
| 43 |
+
"""Equation (12): arrival belief times new detection chance per action cost."""
|
| 44 |
+
denominator = distance_m + lambda_time * eta_s + lambda_inspect
|
| 45 |
+
if not math.isfinite(denominator) or denominator <= 0:
|
| 46 |
+
return -math.inf
|
| 47 |
+
return arrival_probability * new_detection_probability / denominator
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def rank_candidates(
|
| 51 |
+
belief: dict[int, float], public_costs: dict[int, float], *, task: str = "n2",
|
| 52 |
+
detection_probabilities: dict[int, float] | None = None,
|
| 53 |
+
speed_mps: float = 1.0, inspection_s: float = 1.0,
|
| 54 |
+
lambda_time: float = 0.05, lambda_inspect: float = 0.25,
|
| 55 |
+
) -> list[int]:
|
| 56 |
+
"""N1 takes Top-1; fixed-target N2 applies Equation (12) to public viewpoints."""
|
| 57 |
+
if not belief:
|
| 58 |
+
return []
|
| 59 |
+
if task == "n1":
|
| 60 |
+
return [max(belief, key=lambda state: (belief[state], -state))]
|
| 61 |
+
if task != "n2":
|
| 62 |
+
raise ValueError(task)
|
| 63 |
+
if speed_mps <= 0:
|
| 64 |
+
raise ValueError("speed_mps must be positive")
|
| 65 |
+
detection_probabilities = detection_probabilities or {}
|
| 66 |
+
return sorted(
|
| 67 |
+
belief,
|
| 68 |
+
key=lambda state: (
|
| 69 |
+
-candidate_utility(
|
| 70 |
+
arrival_probability=belief[state],
|
| 71 |
+
new_detection_probability=detection_probabilities.get(state, 1.0),
|
| 72 |
+
distance_m=public_costs[state],
|
| 73 |
+
eta_s=public_costs[state] / speed_mps + inspection_s,
|
| 74 |
+
lambda_time=lambda_time,
|
| 75 |
+
lambda_inspect=lambda_inspect,
|
| 76 |
+
),
|
| 77 |
+
state,
|
| 78 |
+
),
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def load_belief(checkpoint_path, dataset_root):
|
| 83 |
+
import torch
|
| 84 |
+
from readyagent.p4d_belief.data import load_catalog
|
| 85 |
+
from readyagent.p4d_belief.models import ModelConfig
|
| 86 |
+
from readyagent.p4d_belief.training import build_model
|
| 87 |
+
|
| 88 |
+
catalog = load_catalog(dataset_root)
|
| 89 |
+
checkpoint = torch.load(checkpoint_path, map_location="cpu", weights_only=True)
|
| 90 |
+
if checkpoint.get("model_name", "p4d" if "transition_head" in checkpoint else None) != "p4d":
|
| 91 |
+
raise ValueError("checkpoint is not a P4D belief model")
|
| 92 |
+
model = build_model("p4d", catalog, ModelConfig(**checkpoint["model_config"]))
|
| 93 |
+
model.load_state_dict(checkpoint["model"])
|
| 94 |
+
return model.eval(), catalog.schema
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def model_input_batch(arrays: dict[str, np.ndarray], schema: dict):
|
| 98 |
+
"""Strict public-input allowlist for belief and transition inference."""
|
| 99 |
+
import torch
|
| 100 |
+
|
| 101 |
+
last_state = int(derive_last_state(arrays)[0])
|
| 102 |
+
candidates = arrays["candidate_state_ids"][0].tolist()
|
| 103 |
+
last_index = candidates.index(last_state)
|
| 104 |
+
instance = str(arrays["instance_uuid"][0])
|
| 105 |
+
vocabulary = schema.get("instance_uuid_to_id", {})
|
| 106 |
+
if instance not in vocabulary:
|
| 107 |
+
raise ValueError(f"unseen instance identity for this checkpoint: {instance}")
|
| 108 |
+
batch = {
|
| 109 |
+
key: torch.as_tensor(arrays[key])
|
| 110 |
+
for key in (*MODEL_INPUT_KEYS, *OPTIONAL_MODEL_INPUT_KEYS)
|
| 111 |
+
if key in arrays
|
| 112 |
+
}
|
| 113 |
+
batch["target_instance_id"] = torch.tensor([vocabulary[instance]])
|
| 114 |
+
batch["last_state"] = torch.tensor([last_state])
|
| 115 |
+
batch["last_candidate_index"] = torch.tensor([last_index])
|
| 116 |
+
return batch
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def predict_public(model, schema: dict, arrays: dict[str, np.ndarray]) -> dict[int, float]:
|
| 120 |
+
batch = model_input_batch(arrays, schema)
|
| 121 |
+
candidates = arrays["candidate_state_ids"][0].tolist()
|
| 122 |
+
import torch
|
| 123 |
+
|
| 124 |
+
with torch.inference_mode():
|
| 125 |
+
probabilities = model(batch)["probabilities"][0].cpu().numpy()
|
| 126 |
+
return {int(state): float(probabilities[index]) for index, state in enumerate(candidates) if state >= 0}
|
code/evolvingnav_paper/run.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run P4D-HSSD navigation episodes with belief or closed-loop Agent control."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import json
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
from evolvingnav_paper.backend import HabitatInspectionBackend
|
| 12 |
+
from evolvingnav_paper.agent import Agent, AgentConfig
|
| 13 |
+
from evolvingnav_paper.calibration import DetectionCalibrator
|
| 14 |
+
from evolvingnav_paper.controller import LunaToolController
|
| 15 |
+
from evolvingnav_paper.evaluate import evaluate_search
|
| 16 |
+
from evolvingnav_paper.memory import VersionedMemory
|
| 17 |
+
from evolvingnav_paper.perception import GroundedSAMInspector
|
| 18 |
+
from evolvingnav_paper.policy import load_belief, model_input_batch, pack_public_query, predict_public
|
| 19 |
+
from evolvingnav_paper.transition import IdentityTransition
|
| 20 |
+
from evolvingnav_paper.transition_model import NeuralTransition, TransitionHead
|
| 21 |
+
from evolvingnav_paper.world import HabitatAgentWorld
|
| 22 |
+
|
| 23 |
+
CODE_ROOT = Path(__file__).resolve().parents[1]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def rows(path: Path):
|
| 27 |
+
with path.open(encoding="utf-8") as handle:
|
| 28 |
+
for line in handle:
|
| 29 |
+
if line.strip():
|
| 30 |
+
yield json.loads(line)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
| 34 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 35 |
+
parser.add_argument("--task", choices=("n1", "n2", "n3", "n4"), default="n3")
|
| 36 |
+
parser.add_argument("--agent", action="store_true", help="Run the event-driven Agent for N1/N2 as well")
|
| 37 |
+
parser.add_argument("--controller", choices=("utility", "luna"), default="utility")
|
| 38 |
+
parser.add_argument("--limit", type=int, default=5)
|
| 39 |
+
parser.add_argument("--world", choices=("routine", "random", "static"), default="routine")
|
| 40 |
+
parser.add_argument("--dataset", type=Path, required=True)
|
| 41 |
+
parser.add_argument("--tasks", type=Path, required=True)
|
| 42 |
+
parser.add_argument("--checkpoint", type=Path, required=True)
|
| 43 |
+
parser.add_argument("--transition-checkpoint", type=Path)
|
| 44 |
+
parser.add_argument("--inspection", choices=("semantic-oracle", "grounded-sam"), default="grounded-sam")
|
| 45 |
+
parser.add_argument("--hssd-root", type=Path, required=True)
|
| 46 |
+
parser.add_argument("--navmesh-root", type=Path, required=True)
|
| 47 |
+
parser.add_argument("--grounding-dino-model", default="IDEA-Research/grounding-dino-tiny")
|
| 48 |
+
parser.add_argument("--sam2-model", default="facebook/sam2.1-hiera-tiny")
|
| 49 |
+
parser.add_argument("--perception-config", type=Path, default=CODE_ROOT / "configs/perception.yaml")
|
| 50 |
+
parser.add_argument("--calibration", type=Path)
|
| 51 |
+
parser.add_argument("--output", type=Path, required=True)
|
| 52 |
+
args = parser.parse_args(argv)
|
| 53 |
+
if args.limit < 1:
|
| 54 |
+
parser.error("--limit must be positive")
|
| 55 |
+
if args.task == "n4" and args.transition_checkpoint is None:
|
| 56 |
+
parser.error("N4 requires --transition-checkpoint")
|
| 57 |
+
return args
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def main() -> int:
|
| 61 |
+
args = arguments()
|
| 62 |
+
if args.output.exists():
|
| 63 |
+
raise FileExistsError(f"output already exists: {args.output}")
|
| 64 |
+
|
| 65 |
+
episodes = []
|
| 66 |
+
for episode in rows(args.tasks / f"public/episodes_{args.task}.jsonl"):
|
| 67 |
+
if episode["world_variant"] == args.world:
|
| 68 |
+
episodes.append(episode)
|
| 69 |
+
if len(episodes) == args.limit:
|
| 70 |
+
break
|
| 71 |
+
if len(episodes) != args.limit:
|
| 72 |
+
raise ValueError(f"only found {len(episodes)} matching episodes")
|
| 73 |
+
wanted_queries = {episode["query_id"] for episode in episodes}
|
| 74 |
+
queries = {row["query_id"]: row for row in rows(args.tasks / "public/query_inputs.jsonl") if row["query_id"] in wanted_queries}
|
| 75 |
+
active_checkpoint = args.transition_checkpoint if args.task == "n4" else args.checkpoint
|
| 76 |
+
model, schema = load_belief(active_checkpoint, args.dataset)
|
| 77 |
+
transition_head = None
|
| 78 |
+
if args.task == "n4":
|
| 79 |
+
import torch
|
| 80 |
+
|
| 81 |
+
checkpoint = torch.load(active_checkpoint, map_location="cpu", weights_only=True)
|
| 82 |
+
transition_head = TransitionHead(checkpoint["model_config"]["hidden_dim"])
|
| 83 |
+
transition_head.load_state_dict(checkpoint["transition_head"])
|
| 84 |
+
transition_head.eval()
|
| 85 |
+
with np.load(args.dataset / "records/packed/train.npz", allow_pickle=False) as train:
|
| 86 |
+
features = {key: train[key] for key in (
|
| 87 |
+
"candidate_region_category_id", "candidate_receptacle_category_id",
|
| 88 |
+
"candidate_center_xyz", "candidate_is_unknown",
|
| 89 |
+
)}
|
| 90 |
+
catalog = json.loads((args.tasks / "catalogs/candidate_states_navigation.json").read_text())
|
| 91 |
+
public_viewpoints = {
|
| 92 |
+
int(row["state_id"]): row["navigation_viewpoint"]
|
| 93 |
+
for row in catalog["states"] if row.get("navigation_eligible")
|
| 94 |
+
}
|
| 95 |
+
public_goals = {state: row["position_xyz"] for state, row in public_viewpoints.items()}
|
| 96 |
+
all_centers = {
|
| 97 |
+
int(row["state_id"]): row["state_center"] for row in catalog["states"]
|
| 98 |
+
}
|
| 99 |
+
state_centers = {
|
| 100 |
+
int(row["state_id"]): row["state_center"]
|
| 101 |
+
for row in catalog["states"] if row.get("navigation_eligible")
|
| 102 |
+
}
|
| 103 |
+
surface_points = {
|
| 104 |
+
int(row["state_id"]): [slot["point"] for slot in row.get("sampled_place_points", [])]
|
| 105 |
+
for row in rows(args.tasks / "catalogs/receptacles.jsonl")
|
| 106 |
+
}
|
| 107 |
+
objects = {
|
| 108 |
+
row["instance_uuid"]: row
|
| 109 |
+
for row in rows(args.tasks / "catalogs/object_instances.jsonl")
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
decisions = []
|
| 113 |
+
query_batches = []
|
| 114 |
+
for episode in episodes:
|
| 115 |
+
query = queries[episode["query_id"]]
|
| 116 |
+
packed = pack_public_query(query, schema, features)
|
| 117 |
+
query_batches.append(model_input_batch(packed, schema))
|
| 118 |
+
belief = predict_public(model, schema, packed)
|
| 119 |
+
candidates = {
|
| 120 |
+
int(state): belief[int(state)]
|
| 121 |
+
for state in episode["public_refs"]["candidate_state_ids"]
|
| 122 |
+
}
|
| 123 |
+
decisions.append({
|
| 124 |
+
"base_episode_id": episode["base_episode_id"], "query_id": episode["query_id"],
|
| 125 |
+
"task": args.task, "belief": candidates,
|
| 126 |
+
})
|
| 127 |
+
|
| 128 |
+
wanted = {episode["base_episode_id"] for episode in episodes}
|
| 129 |
+
private = {
|
| 130 |
+
row["base_episode_id"]: row["evaluation_private"]
|
| 131 |
+
for row in rows(args.tasks / "private/evaluation_gt.jsonl")
|
| 132 |
+
if row["base_episode_id"] in wanted
|
| 133 |
+
}
|
| 134 |
+
scene_ids = {episode["scene_id"] for episode in episodes}
|
| 135 |
+
if len(scene_ids) != 1:
|
| 136 |
+
raise ValueError("--tasks must select episodes from one scene")
|
| 137 |
+
scene_id = next(iter(scene_ids))
|
| 138 |
+
navmesh = args.navmesh_root / f"{scene_id}.navmesh"
|
| 139 |
+
inspector = (
|
| 140 |
+
GroundedSAMInspector(
|
| 141 |
+
args.perception_config,
|
| 142 |
+
dino_model=args.grounding_dino_model,
|
| 143 |
+
sam_model=args.sam2_model,
|
| 144 |
+
)
|
| 145 |
+
if args.inspection == "grounded-sam" else None
|
| 146 |
+
)
|
| 147 |
+
backend = HabitatInspectionBackend(
|
| 148 |
+
args.hssd_root, scene_id, navmesh, public_viewpoints,
|
| 149 |
+
detector=inspector,
|
| 150 |
+
)
|
| 151 |
+
calibrator = DetectionCalibrator.load(args.calibration) if args.calibration else None
|
| 152 |
+
controller = LunaToolController() if args.controller == "luna" else None
|
| 153 |
+
scores = []
|
| 154 |
+
try:
|
| 155 |
+
for episode, decision in zip(episodes, decisions, strict=True):
|
| 156 |
+
truth = private[episode["base_episode_id"]]
|
| 157 |
+
backend.prepare(
|
| 158 |
+
truth, objects[episode["target"]["object_id"]],
|
| 159 |
+
[state for state in decision["belief"] if state in public_goals],
|
| 160 |
+
dynamic=args.task == "n4",
|
| 161 |
+
)
|
| 162 |
+
if args.task in {"n3", "n4"} or args.agent:
|
| 163 |
+
query = queries[episode["query_id"]]
|
| 164 |
+
target_id = query["input"]["target"]["instance_uuid"]
|
| 165 |
+
memory = VersionedMemory()
|
| 166 |
+
for index, event in enumerate(query["input"].get("target_history", [])):
|
| 167 |
+
if event["event_type"] == "positive_observation":
|
| 168 |
+
observed_state = int(event["observed_state_id"])
|
| 169 |
+
memory.observe(
|
| 170 |
+
target_id, observed_state, float(event["timestamp_s"]),
|
| 171 |
+
float(event["detector_confidence"])
|
| 172 |
+
* float(event["instance_match_confidence"]),
|
| 173 |
+
f"{query['query_id']}:history:{index}",
|
| 174 |
+
np.asarray(all_centers[observed_state]),
|
| 175 |
+
)
|
| 176 |
+
motion_schedule = None
|
| 177 |
+
if args.task == "n4":
|
| 178 |
+
motion_schedule = truth.get("target_motion_schedule")
|
| 179 |
+
if motion_schedule is None:
|
| 180 |
+
raise ValueError("N4 private evaluation record requires target_motion_schedule")
|
| 181 |
+
required_motion = {
|
| 182 |
+
"time_s", "target_position_xyz", "current_state_id",
|
| 183 |
+
"valid_goal_viewpoints",
|
| 184 |
+
}
|
| 185 |
+
if any(required_motion - set(event) for event in motion_schedule):
|
| 186 |
+
raise ValueError("N4 target_motion_schedule has incomplete motion events")
|
| 187 |
+
world = HabitatAgentWorld(
|
| 188 |
+
backend, public_viewpoints,
|
| 189 |
+
state_centers,
|
| 190 |
+
episode["agent_start"]["position_xyz"],
|
| 191 |
+
episode["agent_start"]["rotation_xyzw"],
|
| 192 |
+
calibrator=calibrator,
|
| 193 |
+
known_states=set(decision["belief"]),
|
| 194 |
+
motion_schedule=motion_schedule,
|
| 195 |
+
surface_points=surface_points,
|
| 196 |
+
)
|
| 197 |
+
unknown_ids = [int(i) for i, flag in enumerate(features["candidate_is_unknown"]) if flag]
|
| 198 |
+
can_explore = "EXPLORE" in episode["public_refs"].get("action_space", [])
|
| 199 |
+
transition = (NeuralTransition(model, transition_head, query_batches[len(scores)])
|
| 200 |
+
if args.task == "n4" else IdentityTransition())
|
| 201 |
+
agent = Agent(
|
| 202 |
+
decision["belief"], public_goals, world, transition,
|
| 203 |
+
AgentConfig(
|
| 204 |
+
max_inspections=1 if args.task == "n1" else int(
|
| 205 |
+
episode["episode_budget"]["max_candidate_inspections"]),
|
| 206 |
+
max_path_m=float(episode["episode_budget"]["max_path_length_m"]),
|
| 207 |
+
chunk_m=2.0,
|
| 208 |
+
unknown_state=unknown_ids[0] if can_explore and unknown_ids else None,
|
| 209 |
+
),
|
| 210 |
+
sample_count={state: 25 for state in decision["belief"]},
|
| 211 |
+
controller=controller,
|
| 212 |
+
memory=memory, target_id=target_id,
|
| 213 |
+
time_origin_s=float(query["input"]["query"]["query_time_s"]),
|
| 214 |
+
)
|
| 215 |
+
result = agent.run()
|
| 216 |
+
final = world.private_inspections[-1] if world.private_inspections else None
|
| 217 |
+
success = bool(
|
| 218 |
+
result.found and final is not None
|
| 219 |
+
and final["distance_to_valid_goal_m"]
|
| 220 |
+
<= float(episode["success_spec"]["max_geodesic_distance_m"])
|
| 221 |
+
and final["visible_fraction"] >= 0.20
|
| 222 |
+
)
|
| 223 |
+
oracle_m = float(truth["oracle_shortest_path_m"])
|
| 224 |
+
score = {
|
| 225 |
+
"base_episode_id": episode["base_episode_id"],
|
| 226 |
+
"task": args.task, "success": success,
|
| 227 |
+
"inspections": len(result.inspections),
|
| 228 |
+
"inspection_order": result.inspections,
|
| 229 |
+
"inspection_evidence": world.private_inspections,
|
| 230 |
+
"actions": result.actions, "path_m": round(result.path_m, 6),
|
| 231 |
+
"spl": round(float(success) * oracle_m / max(result.path_m, oracle_m, 1e-9), 6),
|
| 232 |
+
"true_state_id": int(backend.truth["current_state_id"]),
|
| 233 |
+
"posterior": result.posterior,
|
| 234 |
+
"termination": result.termination,
|
| 235 |
+
"evidence_trace": result.evidence_trace,
|
| 236 |
+
}
|
| 237 |
+
else:
|
| 238 |
+
score = evaluate_search(
|
| 239 |
+
episode, truth, decision["belief"],
|
| 240 |
+
start=episode["agent_start"]["position_xyz"], goals=public_goals,
|
| 241 |
+
distance=backend.distance, inspect=backend.inspect, task=args.task,
|
| 242 |
+
)
|
| 243 |
+
scores.append(score)
|
| 244 |
+
backend.clear()
|
| 245 |
+
finally:
|
| 246 |
+
backend.close()
|
| 247 |
+
if inspector is not None:
|
| 248 |
+
inspector.close()
|
| 249 |
+
|
| 250 |
+
args.output.mkdir(parents=True)
|
| 251 |
+
with (args.output / "policy.jsonl").open("w", encoding="utf-8") as handle:
|
| 252 |
+
for decision in decisions:
|
| 253 |
+
handle.write(json.dumps(decision, ensure_ascii=False) + "\n")
|
| 254 |
+
with (args.output / "scores.jsonl").open("w", encoding="utf-8") as handle:
|
| 255 |
+
for score in scores:
|
| 256 |
+
handle.write(json.dumps(score, ensure_ascii=False) + "\n")
|
| 257 |
+
summary = {
|
| 258 |
+
"task": args.task, "world": args.world, "episodes": len(scores),
|
| 259 |
+
"successes": sum(row["success"] for row in scores),
|
| 260 |
+
"sr": sum(row["success"] for row in scores) / len(scores),
|
| 261 |
+
"spl": sum(row["spl"] for row in scores) / len(scores),
|
| 262 |
+
"track": f"high_level_{'event_agent' if args.task in {'n3', 'n4'} or args.agent else 'ranked'}_{args.inspection}",
|
| 263 |
+
"min_visible_fraction": 0.20,
|
| 264 |
+
"dataset": str(args.tasks), "checkpoint": str(args.checkpoint),
|
| 265 |
+
}
|
| 266 |
+
(args.output / "summary.json").write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
|
| 267 |
+
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
| 268 |
+
return 0
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
if __name__ == "__main__":
|
| 272 |
+
raise SystemExit(main())
|
code/evolvingnav_paper/transition.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Row-normalized state transitions and fixed-target identity dynamics."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class IdentityTransition:
|
| 9 |
+
dynamic = False
|
| 10 |
+
|
| 11 |
+
def matrix(self, states: list[int], elapsed_s: float) -> np.ndarray:
|
| 12 |
+
return np.eye(len(states), dtype=float)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class MatrixTransition:
|
| 16 |
+
"""A supplied row-stochastic transition, useful for deterministic integration tests."""
|
| 17 |
+
|
| 18 |
+
dynamic = True
|
| 19 |
+
|
| 20 |
+
def __init__(self, matrix: np.ndarray) -> None:
|
| 21 |
+
self.values = np.asarray(matrix, dtype=float)
|
| 22 |
+
if self.values.ndim != 2 or self.values.shape[0] != self.values.shape[1]:
|
| 23 |
+
raise ValueError("transition must be square")
|
| 24 |
+
if np.any(self.values < 0) or not np.allclose(self.values.sum(axis=1), 1):
|
| 25 |
+
raise ValueError("transition rows must sum to one")
|
| 26 |
+
|
| 27 |
+
def matrix(self, states: list[int], elapsed_s: float) -> np.ndarray:
|
| 28 |
+
if elapsed_s < 0:
|
| 29 |
+
raise ValueError("time cannot go backwards")
|
| 30 |
+
if len(states) != len(self.values):
|
| 31 |
+
raise ValueError("transition state count mismatch")
|
| 32 |
+
return np.eye(len(states)) if elapsed_s == 0 else self.values
|
code/evolvingnav_paper/transition_model.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Learned row-conditional transition head for N4 (Equations 10 and 19)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import math
|
| 6 |
+
from bisect import bisect_right
|
| 7 |
+
from collections import defaultdict
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
import torch
|
| 11 |
+
from torch import nn
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class TransitionHead(nn.Module):
|
| 15 |
+
"""Shares the belief backbone's query/candidate embeddings; has separate parameters."""
|
| 16 |
+
|
| 17 |
+
def __init__(self, hidden_dim: int = 128) -> None:
|
| 18 |
+
super().__init__()
|
| 19 |
+
self.mlp = nn.Sequential(
|
| 20 |
+
nn.Linear(3 * hidden_dim + 4, hidden_dim),
|
| 21 |
+
nn.GELU(),
|
| 22 |
+
nn.Linear(hidden_dim, 1),
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
def forward(self, context: torch.Tensor, candidates: torch.Tensor,
|
| 26 |
+
horizon_s: torch.Tensor, candidate_mask: torch.Tensor) -> torch.Tensor:
|
| 27 |
+
batch, states, hidden = candidates.shape
|
| 28 |
+
if context.shape != (batch, hidden) or horizon_s.shape != (batch,):
|
| 29 |
+
raise ValueError("transition context or horizon has incorrect shape")
|
| 30 |
+
if torch.any(horizon_s < 0):
|
| 31 |
+
raise ValueError("transition horizon must be nonnegative")
|
| 32 |
+
phase = horizon_s.float() * (2 * math.pi / 86400.0)
|
| 33 |
+
time = torch.stack((torch.log1p(horizon_s.float()),
|
| 34 |
+
torch.sin(phase), torch.cos(phase),
|
| 35 |
+
horizon_s.float() / 86400.0), dim=-1)
|
| 36 |
+
features = torch.cat((
|
| 37 |
+
context[:, None, None, :].expand(-1, states, states, -1),
|
| 38 |
+
candidates[:, :, None, :].expand(-1, -1, states, -1),
|
| 39 |
+
candidates[:, None, :, :].expand(-1, states, -1, -1),
|
| 40 |
+
time[:, None, None, :].expand(-1, states, states, -1),
|
| 41 |
+
), dim=-1)
|
| 42 |
+
logits = self.mlp(features).squeeze(-1)
|
| 43 |
+
logits = logits.masked_fill(~candidate_mask[:, None, :].bool(), -1e4)
|
| 44 |
+
probabilities = torch.softmax(logits, dim=-1)
|
| 45 |
+
return probabilities * candidate_mask[:, :, None].float()
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def transition_nll(kernel: torch.Tensor, source_index: torch.Tensor,
|
| 49 |
+
destination_index: torch.Tensor) -> torch.Tensor:
|
| 50 |
+
rows = kernel[torch.arange(len(kernel), device=kernel.device), source_index.long()]
|
| 51 |
+
selected = rows.gather(1, destination_index.long()[:, None]).squeeze(1)
|
| 52 |
+
return -selected.clamp_min(1e-9).log().mean()
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def chronological_pairs(*, instance_ids: np.ndarray, world_ids: np.ndarray,
|
| 56 |
+
times_s: np.ndarray, states: np.ndarray,
|
| 57 |
+
max_horizon_s: float) -> list[tuple[int, int, float, int, int]]:
|
| 58 |
+
"""Create train-only H<=ta, s(ta), delta, s(ta+delta) labels."""
|
| 59 |
+
groups: dict[tuple[str, int], list[int]] = defaultdict(list)
|
| 60 |
+
for index, (instance, world) in enumerate(zip(instance_ids, world_ids, strict=True)):
|
| 61 |
+
groups[(str(instance), int(world))].append(index)
|
| 62 |
+
pairs = []
|
| 63 |
+
for indices in groups.values():
|
| 64 |
+
ordered = sorted(indices, key=lambda i: float(times_s[i]))
|
| 65 |
+
for source, destination in zip(ordered, ordered[1:], strict=False):
|
| 66 |
+
horizon = float(times_s[destination] - times_s[source])
|
| 67 |
+
if 0 < horizon <= max_horizon_s:
|
| 68 |
+
pairs.append((source, destination, horizon,
|
| 69 |
+
int(states[source]), int(states[destination])))
|
| 70 |
+
return pairs
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def event_horizon_pairs(*, instance_ids: np.ndarray, world_ids: np.ndarray,
|
| 74 |
+
query_times_s: np.ndarray, query_states: np.ndarray,
|
| 75 |
+
events: list[dict], horizons_s: tuple[float, ...]
|
| 76 |
+
) -> list[tuple[int, float, float, int, int]]:
|
| 77 |
+
"""Sample short stationary and event-crossing tuples from one split's causal snapshots."""
|
| 78 |
+
if not horizons_s or any(h <= 0 for h in horizons_s):
|
| 79 |
+
raise ValueError("horizons must be positive")
|
| 80 |
+
groups: dict[tuple[str, int], list[int]] = defaultdict(list)
|
| 81 |
+
event_groups: dict[tuple[str, int], list[dict]] = defaultdict(list)
|
| 82 |
+
for index, (instance, world) in enumerate(zip(instance_ids, world_ids, strict=True)):
|
| 83 |
+
groups[(str(instance), int(world))].append(index)
|
| 84 |
+
for event in events:
|
| 85 |
+
event_groups[(str(event["instance_uuid"]), int(event.get("world_id", 0)))].append(event)
|
| 86 |
+
pairs: list[tuple[int, float, float, int, int]] = []
|
| 87 |
+
for (instance, world), indices in groups.items():
|
| 88 |
+
ordered = sorted(indices, key=lambda i: float(query_times_s[i]))
|
| 89 |
+
times = [float(query_times_s[i]) for i in ordered]
|
| 90 |
+
relevant = sorted(event_groups.get((instance, world), []),
|
| 91 |
+
key=lambda event: float(event["event_time_s"]))
|
| 92 |
+
event_times = [float(event["event_time_s"]) for event in relevant]
|
| 93 |
+
for position, source in enumerate(ordered):
|
| 94 |
+
horizon = float(horizons_s[position % len(horizons_s)])
|
| 95 |
+
anchor = times[position]
|
| 96 |
+
if anchor + horizon > times[-1]:
|
| 97 |
+
continue
|
| 98 |
+
if bisect_right(event_times, anchor + horizon) == bisect_right(event_times, anchor):
|
| 99 |
+
state = int(query_states[source])
|
| 100 |
+
pairs.append((source, anchor, horizon, state, state))
|
| 101 |
+
for event_index, event in enumerate(relevant):
|
| 102 |
+
event_time = float(event["event_time_s"])
|
| 103 |
+
for horizon in horizons_s:
|
| 104 |
+
anchor = event_time - horizon / 2.0
|
| 105 |
+
future = anchor + horizon
|
| 106 |
+
if anchor < times[0] or future > times[-1]:
|
| 107 |
+
continue
|
| 108 |
+
if (event_index > 0 and event_times[event_index - 1] >= anchor) or (
|
| 109 |
+
event_index + 1 < len(event_times) and event_times[event_index + 1] <= future
|
| 110 |
+
):
|
| 111 |
+
continue
|
| 112 |
+
source_position = bisect_right(times, anchor) - 1
|
| 113 |
+
if source_position < 0:
|
| 114 |
+
continue
|
| 115 |
+
pairs.append((
|
| 116 |
+
ordered[source_position], anchor, float(horizon),
|
| 117 |
+
int(event["source_state_id"]), int(event["destination_state_id"]),
|
| 118 |
+
))
|
| 119 |
+
return pairs
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
class NeuralTransition:
|
| 123 |
+
"""Inference adapter that never receives transition labels or mobility tags."""
|
| 124 |
+
|
| 125 |
+
dynamic = True
|
| 126 |
+
|
| 127 |
+
def __init__(self, belief_model: nn.Module, head: TransitionHead,
|
| 128 |
+
packed_batch: dict[str, torch.Tensor]) -> None:
|
| 129 |
+
self.model = belief_model.eval()
|
| 130 |
+
self.head = head.eval()
|
| 131 |
+
self.batch = {key: value.clone() for key, value in packed_batch.items()}
|
| 132 |
+
if self.batch["candidate_state_ids"].shape[0] != 1:
|
| 133 |
+
raise ValueError("N4 transition adapter expects one active episode")
|
| 134 |
+
self.elapsed_s = 0.0
|
| 135 |
+
|
| 136 |
+
def add_candidate(self, state_id: int) -> None:
|
| 137 |
+
present = self.batch["candidate_state_ids"][0].tolist()
|
| 138 |
+
if state_id in present:
|
| 139 |
+
return
|
| 140 |
+
self.batch["candidate_state_ids"] = torch.cat((
|
| 141 |
+
self.batch["candidate_state_ids"],
|
| 142 |
+
torch.tensor([[state_id]], dtype=self.batch["candidate_state_ids"].dtype),
|
| 143 |
+
), dim=1)
|
| 144 |
+
self.batch["candidate_mask"] = torch.cat((
|
| 145 |
+
self.batch["candidate_mask"],
|
| 146 |
+
torch.ones((1, 1), dtype=self.batch["candidate_mask"].dtype),
|
| 147 |
+
), dim=1)
|
| 148 |
+
|
| 149 |
+
def matrix(self, states: list[int], elapsed_s: float) -> np.ndarray:
|
| 150 |
+
if elapsed_s < 0:
|
| 151 |
+
raise ValueError("time cannot go backwards")
|
| 152 |
+
if elapsed_s == 0:
|
| 153 |
+
return np.eye(len(states), dtype=float)
|
| 154 |
+
device = next(self.model.parameters()).device
|
| 155 |
+
batch = {key: value.to(device) for key, value in self.batch.items()}
|
| 156 |
+
batch["query_time_days"] = batch["query_time_days"] + self.elapsed_s / 86400.0
|
| 157 |
+
batch["elapsed_since_last_positive_days"] = (
|
| 158 |
+
batch["elapsed_since_last_positive_days"] + self.elapsed_s / 86400.0
|
| 159 |
+
)
|
| 160 |
+
day_phase = 2 * math.pi * batch["query_time_days"]
|
| 161 |
+
batch["query_time_of_day_sin_cos"] = torch.stack(
|
| 162 |
+
(day_phase.sin(), day_phase.cos()), dim=-1
|
| 163 |
+
)
|
| 164 |
+
with torch.inference_mode():
|
| 165 |
+
context, candidates = self.model.backbone(batch)
|
| 166 |
+
kernel = self.head(
|
| 167 |
+
context, candidates, torch.tensor([elapsed_s], device=device),
|
| 168 |
+
batch["candidate_mask"],
|
| 169 |
+
)[0]
|
| 170 |
+
candidate_ids = batch["candidate_state_ids"][0].tolist()
|
| 171 |
+
index = [candidate_ids.index(state) for state in states]
|
| 172 |
+
return kernel[index][:, index].cpu().numpy()
|
| 173 |
+
|
| 174 |
+
def advance_clock(self, elapsed_s: float) -> None:
|
| 175 |
+
if elapsed_s < 0:
|
| 176 |
+
raise ValueError("time cannot go backwards")
|
| 177 |
+
self.elapsed_s += elapsed_s
|
code/evolvingnav_paper/verify_visual.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Render one scored success and confirm the benchmark visibility contract."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import json
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
from evolvingnav_paper.backend import HabitatInspectionBackend
|
| 12 |
+
from evolvingnav_paper.habitat_utils import set_agent
|
| 13 |
+
from evolvingnav_paper.run import rows
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
| 17 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 18 |
+
parser.add_argument("run", type=Path, help="a completed N1/N2/N3 run directory")
|
| 19 |
+
parser.add_argument("--tasks", type=Path, required=True)
|
| 20 |
+
parser.add_argument("--hssd-root", type=Path, required=True)
|
| 21 |
+
parser.add_argument("--navmesh-root", type=Path, required=True)
|
| 22 |
+
return parser.parse_args(argv)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def main() -> int:
|
| 26 |
+
args = arguments()
|
| 27 |
+
passed = next((row for row in rows(args.run / "scores.jsonl") if row["success"]), None)
|
| 28 |
+
if passed is None:
|
| 29 |
+
raise ValueError("run has no successful episode to verify")
|
| 30 |
+
episode_id = passed["base_episode_id"]
|
| 31 |
+
episode = next(row for row in rows(args.tasks / "public/base_episodes.jsonl") if row["base_episode_id"] == episode_id)
|
| 32 |
+
truth = next(row["evaluation_private"] for row in rows(args.tasks / "private/evaluation_gt.jsonl") if row["base_episode_id"] == episode_id)
|
| 33 |
+
target = next(row for row in rows(args.tasks / "catalogs/object_instances.jsonl") if row["instance_uuid"] == episode["target"]["object_id"])
|
| 34 |
+
stop_state = int(passed["inspection_order"][-1])
|
| 35 |
+
candidate_catalog = json.loads(
|
| 36 |
+
(args.tasks / "catalogs/candidate_states_navigation.json").read_text()
|
| 37 |
+
)
|
| 38 |
+
viewpoints = {
|
| 39 |
+
int(row["state_id"]): row["navigation_viewpoint"]
|
| 40 |
+
for row in candidate_catalog["states"] if row.get("navigation_eligible")
|
| 41 |
+
}
|
| 42 |
+
scene_id = episode["scene_id"]
|
| 43 |
+
navmesh = args.navmesh_root / f"{scene_id}.navmesh"
|
| 44 |
+
backend = HabitatInspectionBackend(args.hssd_root, scene_id, navmesh, viewpoints)
|
| 45 |
+
try:
|
| 46 |
+
backend.prepare(truth, target, [stop_state])
|
| 47 |
+
start = episode["agent_start"]
|
| 48 |
+
set_agent(backend.real.get_agent(0), start["position_xyz"], start["rotation_xyzw"])
|
| 49 |
+
start_pixels = int((np.asarray(backend.real.get_sensor_observations()["semantic"]) == int(target["semantic_instance_id"])).sum())
|
| 50 |
+
viewpoint = viewpoints[stop_state]
|
| 51 |
+
observation = backend.inspect(stop_state, viewpoint["position_xyz"])
|
| 52 |
+
finally:
|
| 53 |
+
backend.close()
|
| 54 |
+
result = {
|
| 55 |
+
"base_episode_id": episode_id,
|
| 56 |
+
"stop_state_id": stop_state,
|
| 57 |
+
"start_target_pixels": start_pixels,
|
| 58 |
+
"stop_target_pixels": observation["visible_target_pixels"],
|
| 59 |
+
"projected_target_pixels": observation["projected_target_pixels"],
|
| 60 |
+
"visible_fraction": observation["visible_fraction"],
|
| 61 |
+
"distance_to_valid_goal_m": observation["distance_to_valid_goal_m"],
|
| 62 |
+
"start_hidden": start_pixels == 0,
|
| 63 |
+
"stop_visible": observation["visible_fraction"] >= 0.20,
|
| 64 |
+
"valid_stop_distance": observation["distance_to_valid_goal_m"] <= 1.0,
|
| 65 |
+
"stop_action": passed["actions"][-1] == "STOP",
|
| 66 |
+
"matches_record": observation["visible_fraction"] == passed["inspection_evidence"][-1]["visible_fraction"],
|
| 67 |
+
}
|
| 68 |
+
result["passed"] = all(result[key] for key in (
|
| 69 |
+
"start_hidden", "stop_visible", "valid_stop_distance", "stop_action", "matches_record",
|
| 70 |
+
))
|
| 71 |
+
(args.run / "visual_check.json").write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
|
| 72 |
+
print(json.dumps(result, indent=2))
|
| 73 |
+
return 0 if result["passed"] else 1
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
if __name__ == "__main__":
|
| 77 |
+
raise SystemExit(main())
|
code/evolvingnav_paper/world.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""High-level Habitat action adapter; evaluator-private fields never enter Agent."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import habitat_sim
|
| 6 |
+
import numpy as np
|
| 7 |
+
|
| 8 |
+
from evolvingnav_paper.agent import ViewEvidence
|
| 9 |
+
from evolvingnav_paper.coverage import (
|
| 10 |
+
camera_forward, camera_transform, candidate_surface_samples, depth_quality,
|
| 11 |
+
heading_quaternion,
|
| 12 |
+
visible_sample_ids,
|
| 13 |
+
)
|
| 14 |
+
from evolvingnav_paper.habitat_utils import set_agent
|
| 15 |
+
from evolvingnav_paper.memory import backproject
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class HabitatAgentWorld:
|
| 19 |
+
def __init__(self, backend, viewpoints: dict[int, dict], state_centers: dict[int, list],
|
| 20 |
+
start_xyz, start_xyzw, *, speed_mps: float = 1.0,
|
| 21 |
+
calibrator=None, category_recall: float = 0.8,
|
| 22 |
+
known_states: set[int] | None = None,
|
| 23 |
+
motion_schedule: list[dict] | None = None,
|
| 24 |
+
surface_points: dict[int, list] | None = None) -> None:
|
| 25 |
+
self.backend = backend
|
| 26 |
+
self.viewpoints = viewpoints
|
| 27 |
+
self.state_centers = state_centers
|
| 28 |
+
self.position = np.asarray(start_xyz, dtype=float)
|
| 29 |
+
self.rotation = list(start_xyzw)
|
| 30 |
+
self.speed_mps = speed_mps
|
| 31 |
+
self.calibrator = calibrator
|
| 32 |
+
self.category_recall = category_recall
|
| 33 |
+
self.frame = 0
|
| 34 |
+
self.private_inspections: list[dict] = []
|
| 35 |
+
self.last_detection = None
|
| 36 |
+
surface_points = surface_points or {}
|
| 37 |
+
self.samples = {state: candidate_surface_samples(
|
| 38 |
+
center, place_points=surface_points.get(state))
|
| 39 |
+
for state, center in state_centers.items()}
|
| 40 |
+
self.frontiers = set(viewpoints) - (known_states or set())
|
| 41 |
+
self.motion_schedule = sorted(motion_schedule or [], key=lambda row: row["time_s"])
|
| 42 |
+
self.world_time_s = 0.0
|
| 43 |
+
self._next_motion = 0
|
| 44 |
+
|
| 45 |
+
def advance_time(self, seconds: float) -> None:
|
| 46 |
+
if seconds < 0:
|
| 47 |
+
raise ValueError("world time cannot move backwards")
|
| 48 |
+
self.world_time_s += seconds
|
| 49 |
+
while (self._next_motion < len(self.motion_schedule)
|
| 50 |
+
and self.motion_schedule[self._next_motion]["time_s"] <= self.world_time_s):
|
| 51 |
+
self.backend.move_target(self.motion_schedule[self._next_motion])
|
| 52 |
+
self._next_motion += 1
|
| 53 |
+
|
| 54 |
+
def distance(self, goal) -> float:
|
| 55 |
+
return self.backend.distance(self.position, goal)
|
| 56 |
+
|
| 57 |
+
def sample_count(self, state: int) -> int:
|
| 58 |
+
return len(self.samples[state])
|
| 59 |
+
|
| 60 |
+
def expected_new_detection(self, state: int, uncovered: float) -> float:
|
| 61 |
+
center = np.asarray(self.state_centers[state], dtype=float)
|
| 62 |
+
viewpoint = self.viewpoints[state]
|
| 63 |
+
distance = float(np.linalg.norm(center - np.asarray(viewpoint["position_xyz"])))
|
| 64 |
+
direction = center - np.asarray(viewpoint["position_xyz"])
|
| 65 |
+
angle = max(0.0, float(np.dot(
|
| 66 |
+
direction / max(distance, 1e-9),
|
| 67 |
+
camera_forward(viewpoint["rotation_xyzw"]),
|
| 68 |
+
)))
|
| 69 |
+
if self.calibrator is None:
|
| 70 |
+
return self.category_recall * uncovered
|
| 71 |
+
return self.calibrator.predict({
|
| 72 |
+
"coverage": uncovered,
|
| 73 |
+
"range_m": distance,
|
| 74 |
+
"angle_cos": angle,
|
| 75 |
+
"projected_pixels": int(25 * uncovered),
|
| 76 |
+
"depth_quality": 1.0,
|
| 77 |
+
"category_recall": self.category_recall,
|
| 78 |
+
})
|
| 79 |
+
|
| 80 |
+
def move_chunk(self, goal, max_distance: float) -> tuple[float, float]:
|
| 81 |
+
path = habitat_sim.ShortestPath()
|
| 82 |
+
path.requested_start = np.asarray(self.position, dtype=np.float32)
|
| 83 |
+
path.requested_end = np.asarray(goal, dtype=np.float32)
|
| 84 |
+
if not self.backend.real.pathfinder.find_path(path):
|
| 85 |
+
return 0.0, 0.0
|
| 86 |
+
remaining = min(float(max_distance), float(path.geodesic_distance))
|
| 87 |
+
points = [np.asarray(point, dtype=float) for point in path.points]
|
| 88 |
+
new_position = points[0]
|
| 89 |
+
for point in points[1:]:
|
| 90 |
+
length = float(np.linalg.norm(point - new_position))
|
| 91 |
+
if length >= remaining:
|
| 92 |
+
new_position = new_position + (point - new_position) * (remaining / length)
|
| 93 |
+
break
|
| 94 |
+
remaining -= length
|
| 95 |
+
new_position = point
|
| 96 |
+
displacement = min(float(max_distance), float(path.geodesic_distance))
|
| 97 |
+
heading = new_position - self.position
|
| 98 |
+
if np.linalg.norm(heading[[0, 2]]) > 1e-6:
|
| 99 |
+
self.rotation = heading_quaternion(heading)
|
| 100 |
+
self.position = new_position
|
| 101 |
+
set_agent(self.backend.real.get_agent(0), self.position.tolist(), self.rotation)
|
| 102 |
+
self.advance_time(displacement / self.speed_mps)
|
| 103 |
+
return displacement, displacement / self.speed_mps
|
| 104 |
+
|
| 105 |
+
def inspect(self, state: int) -> tuple[bool, list[ViewEvidence]]:
|
| 106 |
+
viewpoint = self.viewpoints[state]
|
| 107 |
+
self.position = np.asarray(viewpoint["position_xyz"], dtype=float)
|
| 108 |
+
self.rotation = viewpoint["rotation_xyzw"]
|
| 109 |
+
private = self.backend.inspect(state, self.position.tolist())
|
| 110 |
+
self.private_inspections.append({"state_id": state, **private})
|
| 111 |
+
observation = self.backend.last_observation
|
| 112 |
+
if observation is None:
|
| 113 |
+
raise RuntimeError("Habitat did not produce an RGB-D frame")
|
| 114 |
+
self.last_detection = None
|
| 115 |
+
if self.backend.last_detections:
|
| 116 |
+
strongest = max(self.backend.last_detections, key=lambda item: item.confidence)
|
| 117 |
+
depth = observation["depth"]
|
| 118 |
+
pixels = np.argwhere(strongest.mask & np.isfinite(depth) & (depth > 0))
|
| 119 |
+
if len(pixels):
|
| 120 |
+
v, u = np.median(pixels, axis=0)
|
| 121 |
+
depth_m = float(np.median(depth[pixels[:, 0], pixels[:, 1]]))
|
| 122 |
+
height, width = depth.shape
|
| 123 |
+
focal = width / (2 * np.tan(np.deg2rad(79.0) / 2))
|
| 124 |
+
intrinsics = np.array([[focal, 0, width / 2],
|
| 125 |
+
[0, focal, height / 2], [0, 0, 1]], dtype=float)
|
| 126 |
+
point = backproject(u, v, depth_m, intrinsics,
|
| 127 |
+
camera_transform(self.position, self.rotation))
|
| 128 |
+
self.last_detection = {
|
| 129 |
+
"world_point": point,
|
| 130 |
+
"confidence": strongest.confidence,
|
| 131 |
+
"evidence_id": f"frame-{self.frame + 1}:positive",
|
| 132 |
+
}
|
| 133 |
+
return bool(private["detected"]), self._evidence_from_depth(observation["depth"])
|
| 134 |
+
|
| 135 |
+
def observe_chunk(self) -> list[ViewEvidence]:
|
| 136 |
+
if self.backend.detector is None:
|
| 137 |
+
return []
|
| 138 |
+
observation = self.backend.real.get_sensor_observations()
|
| 139 |
+
depth = np.asarray(observation["depth"])
|
| 140 |
+
covered_any = any(visible_sample_ids(
|
| 141 |
+
samples, self.position, self.rotation, depth, 79.0
|
| 142 |
+
) for samples in self.samples.values())
|
| 143 |
+
if not covered_any:
|
| 144 |
+
return []
|
| 145 |
+
detected = self.backend.detector(
|
| 146 |
+
np.asarray(observation["rgb"]), depth, self.backend.target_category
|
| 147 |
+
)
|
| 148 |
+
return [] if detected else self._evidence_from_depth(depth)
|
| 149 |
+
|
| 150 |
+
def _evidence_from_depth(self, depth: np.ndarray) -> list[ViewEvidence]:
|
| 151 |
+
self.frame += 1
|
| 152 |
+
evidence = []
|
| 153 |
+
for candidate, samples in self.samples.items():
|
| 154 |
+
covered = visible_sample_ids(
|
| 155 |
+
samples, self.position, self.rotation, depth, 79.0
|
| 156 |
+
)
|
| 157 |
+
if covered:
|
| 158 |
+
fraction = len(covered) / len(samples)
|
| 159 |
+
if self.calibrator is None:
|
| 160 |
+
detection_probability = self.category_recall * fraction
|
| 161 |
+
else:
|
| 162 |
+
direction = np.asarray(self.state_centers[candidate]) - self.position
|
| 163 |
+
distance = float(np.linalg.norm(direction))
|
| 164 |
+
features = {
|
| 165 |
+
"coverage": fraction,
|
| 166 |
+
"range_m": distance,
|
| 167 |
+
"angle_cos": max(0.0, float(np.dot(
|
| 168 |
+
direction / max(distance, 1e-9), camera_forward(self.rotation)))),
|
| 169 |
+
"projected_pixels": len(covered),
|
| 170 |
+
"depth_quality": depth_quality(depth),
|
| 171 |
+
"category_recall": self.category_recall,
|
| 172 |
+
}
|
| 173 |
+
detection_probability = self.calibrator.predict(features)
|
| 174 |
+
evidence.append(ViewEvidence(
|
| 175 |
+
f"frame-{self.frame}:state-{candidate}", candidate, covered,
|
| 176 |
+
detection_probability, fraction, tuple(self.position.tolist()),
|
| 177 |
+
))
|
| 178 |
+
return evidence
|
| 179 |
+
|
| 180 |
+
def explore(self, budget_m: float) -> tuple[dict[int, tuple[object, float]], float, float]:
|
| 181 |
+
reachable = [
|
| 182 |
+
(self.distance(self.viewpoints[state]["position_xyz"]), state)
|
| 183 |
+
for state in self.frontiers
|
| 184 |
+
]
|
| 185 |
+
reachable = [(distance, state) for distance, state in reachable
|
| 186 |
+
if np.isfinite(distance) and distance <= budget_m]
|
| 187 |
+
if not reachable:
|
| 188 |
+
return {}, 0.0, 0.0
|
| 189 |
+
distance, state = min(reachable)
|
| 190 |
+
self.frontiers.remove(state)
|
| 191 |
+
goal = self.viewpoints[state]["position_xyz"]
|
| 192 |
+
displacement, duration = self.move_chunk(goal, distance)
|
| 193 |
+
self.rotation = self.viewpoints[state]["rotation_xyzw"]
|
| 194 |
+
set_agent(self.backend.real.get_agent(0), self.position.tolist(), self.rotation)
|
| 195 |
+
observations = self.backend.real.get_sensor_observations()
|
| 196 |
+
detected = (
|
| 197 |
+
bool(self.backend.detector(
|
| 198 |
+
np.asarray(observations["rgb"]), np.asarray(observations["depth"]),
|
| 199 |
+
self.backend.target_category,
|
| 200 |
+
)) if self.backend.detector is not None else False
|
| 201 |
+
)
|
| 202 |
+
return {state: (goal, 1.0 if detected else 0.5)}, duration, displacement
|
code/requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
numpy==1.26.4
|
| 2 |
+
torch==2.7.1
|
| 3 |
+
torchvision==0.22.1
|
| 4 |
+
scikit-learn==1.9.0
|
| 5 |
+
transformers==4.57.1
|
| 6 |
+
Pillow==10.4.0
|
| 7 |
+
PyYAML==6.0.3
|
| 8 |
+
pytest==8.4.2
|
code/scripts/collect_calibration.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Collect held-out RGB-D detector observations for logistic recall calibration."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import json
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
|
| 12 |
+
from evolvingnav_paper.backend import HabitatInspectionBackend
|
| 13 |
+
from evolvingnav_paper.coverage import (
|
| 14 |
+
camera_forward, candidate_surface_samples, depth_quality, visible_sample_ids,
|
| 15 |
+
)
|
| 16 |
+
from evolvingnav_paper.perception import GroundedSAMInspector
|
| 17 |
+
from evolvingnav_paper.run import rows
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def main() -> int:
|
| 21 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 22 |
+
parser.add_argument("--dataset", type=Path, required=True)
|
| 23 |
+
parser.add_argument("--tasks", type=Path, required=True)
|
| 24 |
+
parser.add_argument("--hssd-root", type=Path, required=True)
|
| 25 |
+
parser.add_argument("--navmesh-root", type=Path, required=True)
|
| 26 |
+
parser.add_argument("--output", type=Path, required=True)
|
| 27 |
+
parser.add_argument("--limit", type=int, default=16)
|
| 28 |
+
parser.add_argument("--dino-model", default="IDEA-Research/grounding-dino-tiny")
|
| 29 |
+
parser.add_argument("--sam-model", default="facebook/sam2.1-hiera-tiny")
|
| 30 |
+
parser.add_argument("--perception-config", type=Path,
|
| 31 |
+
default=Path(__file__).resolve().parents[1] / "configs/perception.yaml")
|
| 32 |
+
args = parser.parse_args()
|
| 33 |
+
if args.output.exists():
|
| 34 |
+
raise FileExistsError(args.output)
|
| 35 |
+
catalog = json.loads((args.tasks / "catalogs/candidate_states_navigation.json").read_text())
|
| 36 |
+
viewpoints = {int(row["state_id"]): row["navigation_viewpoint"]
|
| 37 |
+
for row in catalog["states"] if row.get("navigation_eligible")}
|
| 38 |
+
centers = {int(row["state_id"]): np.asarray(row["state_center"], dtype=float)
|
| 39 |
+
for row in catalog["states"]}
|
| 40 |
+
surface_points = {
|
| 41 |
+
int(row["state_id"]): [slot["point"] for slot in row.get("sampled_place_points", [])]
|
| 42 |
+
for row in rows(args.tasks / "catalogs/receptacles.jsonl")
|
| 43 |
+
}
|
| 44 |
+
objects = {row["instance_uuid"]: row
|
| 45 |
+
for row in rows(args.tasks / "catalogs/object_instances.jsonl")}
|
| 46 |
+
selected = []
|
| 47 |
+
for record in rows(args.dataset / "records/val_queries.jsonl"):
|
| 48 |
+
state = int(record["supervision"]["current_state_id"])
|
| 49 |
+
if state in viewpoints and record["input"]["target"]["instance_uuid"] in objects:
|
| 50 |
+
selected.append(record)
|
| 51 |
+
if len(selected) >= args.limit:
|
| 52 |
+
break
|
| 53 |
+
if len(selected) < args.limit:
|
| 54 |
+
raise ValueError(f"only {len(selected)} validation records have a public viewpoint")
|
| 55 |
+
inspector = GroundedSAMInspector(
|
| 56 |
+
args.perception_config, dino_model=args.dino_model, sam_model=args.sam_model
|
| 57 |
+
)
|
| 58 |
+
backend = HabitatInspectionBackend(
|
| 59 |
+
args.hssd_root, selected[0]["scene_id"],
|
| 60 |
+
args.navmesh_root / f"{selected[0]['scene_id']}.navmesh",
|
| 61 |
+
viewpoints, detector=inspector,
|
| 62 |
+
)
|
| 63 |
+
observations = []
|
| 64 |
+
try:
|
| 65 |
+
for record in selected:
|
| 66 |
+
target = record["input"]["target"]
|
| 67 |
+
state = int(record["supervision"]["current_state_id"])
|
| 68 |
+
center = centers[state]
|
| 69 |
+
nearest = sorted(viewpoints, key=lambda candidate: float(np.linalg.norm(
|
| 70 |
+
np.asarray(viewpoints[candidate]["position_xyz"]) - center
|
| 71 |
+
)))
|
| 72 |
+
selected_views = list(dict.fromkeys(
|
| 73 |
+
[state, *nearest[:3], nearest[len(nearest) // 2], nearest[-1]]
|
| 74 |
+
))
|
| 75 |
+
truth = {
|
| 76 |
+
"target_position_xyz": record["supervision"]["current_position"],
|
| 77 |
+
"current_state_id": state,
|
| 78 |
+
"valid_goal_viewpoints": [viewpoints[state]],
|
| 79 |
+
}
|
| 80 |
+
backend.prepare(truth, objects[target["instance_uuid"]], selected_views)
|
| 81 |
+
samples = candidate_surface_samples(center, place_points=surface_points.get(state))
|
| 82 |
+
for view_state in selected_views:
|
| 83 |
+
viewpoint = viewpoints[view_state]
|
| 84 |
+
backend.inspect(view_state, viewpoint["position_xyz"])
|
| 85 |
+
depth = backend.last_observation["depth"]
|
| 86 |
+
covered = visible_sample_ids(
|
| 87 |
+
samples, viewpoint["position_xyz"], viewpoint["rotation_xyzw"],
|
| 88 |
+
depth, 79.0,
|
| 89 |
+
)
|
| 90 |
+
semantic = backend.last_semantic == int(objects[target["instance_uuid"]]["semantic_instance_id"])
|
| 91 |
+
target_pixels = int(np.count_nonzero(semantic))
|
| 92 |
+
overlaps = [int(np.count_nonzero(detection.mask & semantic))
|
| 93 |
+
for detection in backend.last_detections]
|
| 94 |
+
position = np.asarray(viewpoint["position_xyz"], dtype=float)
|
| 95 |
+
direction = center - position
|
| 96 |
+
distance = float(np.linalg.norm(direction))
|
| 97 |
+
observations.append({
|
| 98 |
+
"coverage": len(covered) / len(samples),
|
| 99 |
+
"range_m": distance,
|
| 100 |
+
"angle_cos": max(0.0, float(np.dot(
|
| 101 |
+
direction / max(distance, 1e-9),
|
| 102 |
+
camera_forward(viewpoint["rotation_xyzw"])))),
|
| 103 |
+
"projected_pixels": len(covered),
|
| 104 |
+
"depth_quality": depth_quality(depth),
|
| 105 |
+
"category_recall": 0.8,
|
| 106 |
+
"detected": bool(target_pixels >= 20 and max(overlaps, default=0) / target_pixels >= 0.1),
|
| 107 |
+
})
|
| 108 |
+
backend.clear()
|
| 109 |
+
finally:
|
| 110 |
+
backend.close()
|
| 111 |
+
inspector.close()
|
| 112 |
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
| 113 |
+
with args.output.open("w", encoding="utf-8") as handle:
|
| 114 |
+
for row in observations:
|
| 115 |
+
handle.write(json.dumps(row) + "\n")
|
| 116 |
+
print(json.dumps({
|
| 117 |
+
"observations": len(observations),
|
| 118 |
+
"detected": sum(row["detected"] for row in observations),
|
| 119 |
+
"output": str(args.output),
|
| 120 |
+
}, indent=2))
|
| 121 |
+
return 0
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
if __name__ == "__main__":
|
| 125 |
+
raise SystemExit(main())
|
code/scripts/fit_calibration.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Fit detection recall from held-out RGB-D validation observations."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import json
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
from evolvingnav_paper.calibration import DetectionCalibrator
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def main() -> int:
|
| 14 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 15 |
+
parser.add_argument("--validation-jsonl", type=Path, required=True)
|
| 16 |
+
parser.add_argument("--output", type=Path, required=True)
|
| 17 |
+
args = parser.parse_args()
|
| 18 |
+
if args.output.exists():
|
| 19 |
+
raise FileExistsError(args.output)
|
| 20 |
+
with args.validation_jsonl.open(encoding="utf-8") as handle:
|
| 21 |
+
rows = [json.loads(line) for line in handle if line.strip()]
|
| 22 |
+
DetectionCalibrator.fit(rows).save(args.output)
|
| 23 |
+
return 0
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
if __name__ == "__main__":
|
| 27 |
+
raise SystemExit(main())
|
code/scripts/pack_p4d_hssd_records.py
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Pack P4D JSONL query records into leakage-safe NumPy training tensors."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import json
|
| 8 |
+
import math
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
MAX_HISTORY = 64
|
| 16 |
+
MAX_CONTEXT_HISTORY = 16
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def arguments() -> argparse.Namespace:
|
| 20 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 21 |
+
parser.add_argument("--root", type=Path, required=True)
|
| 22 |
+
parser.add_argument("--max-history", type=int, default=MAX_HISTORY)
|
| 23 |
+
return parser.parse_args()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def load_jsonl(path: Path) -> list[dict[str, Any]]:
|
| 27 |
+
with path.open(encoding="utf-8") as handle:
|
| 28 |
+
return [json.loads(line) for line in handle if line.strip()]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def pack_split(
|
| 32 |
+
records: list[dict[str, Any]],
|
| 33 |
+
*,
|
| 34 |
+
max_history: int,
|
| 35 |
+
category_to_id: dict[str, int],
|
| 36 |
+
state_count: int,
|
| 37 |
+
candidate_features: dict[str, np.ndarray],
|
| 38 |
+
) -> dict[str, np.ndarray]:
|
| 39 |
+
count = len(records)
|
| 40 |
+
event_type = np.zeros((count, max_history), dtype=np.int8)
|
| 41 |
+
event_time_days = np.zeros((count, max_history), dtype=np.float32)
|
| 42 |
+
delta_time_log1p = np.zeros((count, max_history), dtype=np.float32)
|
| 43 |
+
observed_state_id = np.full((count, max_history), -1, dtype=np.int16)
|
| 44 |
+
candidate_state_id = np.full((count, max_history), -1, dtype=np.int16)
|
| 45 |
+
# detector_conf, identity_conf, visible_fraction, frustum, unoccluded, neg_strength
|
| 46 |
+
evidence_features = np.zeros((count, max_history, 6), dtype=np.float32)
|
| 47 |
+
history_mask = np.zeros((count, max_history), dtype=np.bool_)
|
| 48 |
+
candidate_state_ids = np.full((count, state_count), -1, dtype=np.int16)
|
| 49 |
+
candidate_mask = np.zeros((count, state_count), dtype=np.bool_)
|
| 50 |
+
target_category_id = np.zeros(count, dtype=np.int16)
|
| 51 |
+
context_time_days = np.zeros((count, MAX_CONTEXT_HISTORY), dtype=np.float32)
|
| 52 |
+
context_category_counts = np.zeros(
|
| 53 |
+
(count, MAX_CONTEXT_HISTORY, len(category_to_id)), dtype=np.float32
|
| 54 |
+
)
|
| 55 |
+
context_observation_features = np.zeros(
|
| 56 |
+
(count, MAX_CONTEXT_HISTORY, 4), dtype=np.float32
|
| 57 |
+
)
|
| 58 |
+
context_mask = np.zeros((count, MAX_CONTEXT_HISTORY), dtype=np.bool_)
|
| 59 |
+
query_time_days = np.zeros(count, dtype=np.float32)
|
| 60 |
+
query_time_of_day_sin_cos = np.zeros((count, 2), dtype=np.float32)
|
| 61 |
+
query_weekday_id = np.zeros(count, dtype=np.int8)
|
| 62 |
+
elapsed_since_last_positive_days = np.zeros(count, dtype=np.float32)
|
| 63 |
+
meta_world_variant_id = np.zeros(count, dtype=np.int8)
|
| 64 |
+
meta_grounding_quality_id = np.zeros(count, dtype=np.int8)
|
| 65 |
+
y_current_state = np.zeros(count, dtype=np.int16)
|
| 66 |
+
y_moved = np.zeros(count, dtype=np.bool_)
|
| 67 |
+
y_transition_occurred = np.zeros(count, dtype=np.bool_)
|
| 68 |
+
y_returned_to_last = np.zeros(count, dtype=np.bool_)
|
| 69 |
+
record_ids = []
|
| 70 |
+
instance_ids = []
|
| 71 |
+
world_ids = {"routine": 0, "random": 1, "static": 2}
|
| 72 |
+
grounding_ids = {
|
| 73 |
+
"exact": 0,
|
| 74 |
+
"role_equivalent": 1,
|
| 75 |
+
"fallback": 2,
|
| 76 |
+
"counterfactual": 3,
|
| 77 |
+
"no_transition": 4,
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
for row_index, record in enumerate(records):
|
| 81 |
+
model_input = record["input"]
|
| 82 |
+
history = model_input["target_history"][-max_history:]
|
| 83 |
+
offset = max_history - len(history)
|
| 84 |
+
for history_index, event in enumerate(history, start=offset):
|
| 85 |
+
history_mask[row_index, history_index] = True
|
| 86 |
+
event_time_days[row_index, history_index] = event["timestamp_s"] / 86400.0
|
| 87 |
+
delta_time_log1p[row_index, history_index] = math.log1p(
|
| 88 |
+
max(0.0, event["delta_t_from_previous_s"])
|
| 89 |
+
)
|
| 90 |
+
if event["event_type"] == "positive_observation":
|
| 91 |
+
event_type[row_index, history_index] = 1
|
| 92 |
+
observed_state_id[row_index, history_index] = event["observed_state_id"]
|
| 93 |
+
evidence_features[row_index, history_index, :3] = (
|
| 94 |
+
event["detector_confidence"],
|
| 95 |
+
event["instance_match_confidence"],
|
| 96 |
+
event["visible_fraction_estimate"],
|
| 97 |
+
)
|
| 98 |
+
elif event["event_type"] == "candidate_inspection":
|
| 99 |
+
event_type[row_index, history_index] = 2
|
| 100 |
+
candidate_state_id[row_index, history_index] = event["candidate_state_id"]
|
| 101 |
+
evidence_features[row_index, history_index, 3:] = (
|
| 102 |
+
event["surface_in_frustum_fraction"],
|
| 103 |
+
event["surface_unoccluded_fraction"],
|
| 104 |
+
event["negative_evidence_strength"],
|
| 105 |
+
)
|
| 106 |
+
else:
|
| 107 |
+
raise ValueError(f"unknown event type: {event['event_type']}")
|
| 108 |
+
candidates = model_input["candidate_state_ids"]
|
| 109 |
+
if len(candidates) > state_count:
|
| 110 |
+
raise ValueError(f"candidate overflow: {record['record_id']}")
|
| 111 |
+
candidate_state_ids[row_index, : len(candidates)] = candidates
|
| 112 |
+
candidate_mask[row_index, : len(candidates)] = True
|
| 113 |
+
target = model_input["target"]
|
| 114 |
+
target_category_id[row_index] = category_to_id[target["category"]]
|
| 115 |
+
query_time_s = model_input["query"]["query_time_s"]
|
| 116 |
+
query_time_days[row_index] = query_time_s / 86400.0
|
| 117 |
+
phase = 2.0 * math.pi * (query_time_s % 86400.0) / 86400.0
|
| 118 |
+
query_time_of_day_sin_cos[row_index] = (math.sin(phase), math.cos(phase))
|
| 119 |
+
query_weekday_id[row_index] = int(query_time_s // 86400) % 7
|
| 120 |
+
context_history = model_input.get(
|
| 121 |
+
"observable_context_history", []
|
| 122 |
+
)[-MAX_CONTEXT_HISTORY:]
|
| 123 |
+
context_offset = MAX_CONTEXT_HISTORY - len(context_history)
|
| 124 |
+
for context_index, context in enumerate(
|
| 125 |
+
context_history, start=context_offset
|
| 126 |
+
):
|
| 127 |
+
context_mask[row_index, context_index] = True
|
| 128 |
+
context_time_days[row_index, context_index] = (
|
| 129 |
+
context["timestamp_s"] / 86400.0
|
| 130 |
+
)
|
| 131 |
+
for category, value in context["observed_category_counts"].items():
|
| 132 |
+
context_category_counts[
|
| 133 |
+
row_index, context_index, category_to_id[category]
|
| 134 |
+
] = float(value)
|
| 135 |
+
context_observation_features[row_index, context_index] = (
|
| 136 |
+
float(context["detected_object_count"]),
|
| 137 |
+
float(context["observed_change_count"]),
|
| 138 |
+
float(len(context["inspected_candidate_state_ids"])),
|
| 139 |
+
float(len(context["observed_region_ids"])),
|
| 140 |
+
)
|
| 141 |
+
elapsed_since_last_positive_days[row_index] = (
|
| 142 |
+
model_input["history_summary"]["elapsed_since_last_positive_s"] / 86400.0
|
| 143 |
+
)
|
| 144 |
+
meta_world_variant_id[row_index] = world_ids[record["world_variant"]]
|
| 145 |
+
grounding = record["supervision"].get(
|
| 146 |
+
"last_transition_grounding_quality", "no_transition"
|
| 147 |
+
)
|
| 148 |
+
meta_grounding_quality_id[row_index] = grounding_ids.get(
|
| 149 |
+
grounding, grounding_ids["no_transition"]
|
| 150 |
+
)
|
| 151 |
+
y_current_state[row_index] = record["supervision"]["current_state_id"]
|
| 152 |
+
y_moved[row_index] = record["supervision"]["moved_since_last_positive"]
|
| 153 |
+
y_transition_occurred[row_index] = record["supervision"].get(
|
| 154 |
+
"transition_occurred_since_last_positive", y_moved[row_index]
|
| 155 |
+
)
|
| 156 |
+
y_returned_to_last[row_index] = record["supervision"].get(
|
| 157 |
+
"returned_to_last_state", False
|
| 158 |
+
)
|
| 159 |
+
record_ids.append(record["record_id"])
|
| 160 |
+
instance_ids.append(target["instance_uuid"])
|
| 161 |
+
|
| 162 |
+
return {
|
| 163 |
+
"event_type": event_type,
|
| 164 |
+
"event_time_days": event_time_days,
|
| 165 |
+
"delta_time_log1p": delta_time_log1p,
|
| 166 |
+
"observed_state_id": observed_state_id,
|
| 167 |
+
"candidate_state_id": candidate_state_id,
|
| 168 |
+
"evidence_features": evidence_features,
|
| 169 |
+
"history_mask": history_mask,
|
| 170 |
+
"candidate_state_ids": candidate_state_ids,
|
| 171 |
+
"candidate_mask": candidate_mask,
|
| 172 |
+
"target_category_id": target_category_id,
|
| 173 |
+
"context_time_days": context_time_days,
|
| 174 |
+
"context_category_counts": context_category_counts,
|
| 175 |
+
"context_observation_features": context_observation_features,
|
| 176 |
+
"context_mask": context_mask,
|
| 177 |
+
"query_time_days": query_time_days,
|
| 178 |
+
"query_time_of_day_sin_cos": query_time_of_day_sin_cos,
|
| 179 |
+
"query_weekday_id": query_weekday_id,
|
| 180 |
+
"elapsed_since_last_positive_days": elapsed_since_last_positive_days,
|
| 181 |
+
"meta_world_variant_id": meta_world_variant_id,
|
| 182 |
+
"meta_grounding_quality_id": meta_grounding_quality_id,
|
| 183 |
+
"y_current_state": y_current_state,
|
| 184 |
+
"y_moved": y_moved,
|
| 185 |
+
"y_transition_occurred": y_transition_occurred,
|
| 186 |
+
"y_returned_to_last": y_returned_to_last,
|
| 187 |
+
"record_id": np.asarray(record_ids),
|
| 188 |
+
"instance_uuid": np.asarray(instance_ids),
|
| 189 |
+
**candidate_features,
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def main() -> int:
|
| 194 |
+
args = arguments()
|
| 195 |
+
root = args.root.resolve()
|
| 196 |
+
objects = load_jsonl(root / "object_pool/objects.jsonl")
|
| 197 |
+
categories = sorted({row["category_canonical"] for row in objects})
|
| 198 |
+
category_to_id = {category: index for index, category in enumerate(categories)}
|
| 199 |
+
state_space = json.loads((root / "scene/candidate_states.json").read_text())
|
| 200 |
+
state_count = len(state_space["states"])
|
| 201 |
+
all_records = {
|
| 202 |
+
split: load_jsonl(root / f"records/{split}_queries.jsonl")
|
| 203 |
+
for split in ("train", "val", "test")
|
| 204 |
+
}
|
| 205 |
+
region_categories = sorted(
|
| 206 |
+
{row["region_category"] for row in state_space["states"]}
|
| 207 |
+
)
|
| 208 |
+
receptacle_categories = sorted(
|
| 209 |
+
{row["receptacle_category"] for row in state_space["states"]}
|
| 210 |
+
)
|
| 211 |
+
region_to_id = {value: index for index, value in enumerate(region_categories)}
|
| 212 |
+
receptacle_to_id = {
|
| 213 |
+
value: index for index, value in enumerate(receptacle_categories)
|
| 214 |
+
}
|
| 215 |
+
candidate_features = {
|
| 216 |
+
"candidate_region_category_id": np.asarray(
|
| 217 |
+
[region_to_id[row["region_category"]] for row in state_space["states"]],
|
| 218 |
+
dtype=np.int16,
|
| 219 |
+
),
|
| 220 |
+
"candidate_receptacle_category_id": np.asarray(
|
| 221 |
+
[
|
| 222 |
+
receptacle_to_id[row["receptacle_category"]]
|
| 223 |
+
for row in state_space["states"]
|
| 224 |
+
],
|
| 225 |
+
dtype=np.int16,
|
| 226 |
+
),
|
| 227 |
+
"candidate_center_xyz": np.asarray(
|
| 228 |
+
[row["state_center"] or [0.0, 0.0, 0.0] for row in state_space["states"]],
|
| 229 |
+
dtype=np.float32,
|
| 230 |
+
),
|
| 231 |
+
"candidate_is_unknown": np.asarray(
|
| 232 |
+
[row["region_id"] == "unknown" for row in state_space["states"]],
|
| 233 |
+
dtype=np.bool_,
|
| 234 |
+
),
|
| 235 |
+
}
|
| 236 |
+
summaries = {}
|
| 237 |
+
output_dir = root / "records/packed"
|
| 238 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 239 |
+
for split in ("train", "val", "test"):
|
| 240 |
+
records = all_records[split]
|
| 241 |
+
tensors = pack_split(
|
| 242 |
+
records,
|
| 243 |
+
max_history=args.max_history,
|
| 244 |
+
category_to_id=category_to_id,
|
| 245 |
+
state_count=state_count,
|
| 246 |
+
candidate_features=candidate_features,
|
| 247 |
+
)
|
| 248 |
+
numeric = [value for value in tensors.values() if value.dtype.kind in "biufc"]
|
| 249 |
+
if not all(np.isfinite(value).all() for value in numeric):
|
| 250 |
+
raise ValueError(f"non-finite tensor in {split}")
|
| 251 |
+
if not np.all((tensors["y_current_state"] >= 0) & (tensors["y_current_state"] < state_count)):
|
| 252 |
+
raise ValueError(f"invalid state label in {split}")
|
| 253 |
+
if not np.all(tensors["history_mask"].any(axis=1)):
|
| 254 |
+
raise ValueError(f"empty history in {split}")
|
| 255 |
+
positive_mask = tensors["event_type"] == 1
|
| 256 |
+
if not np.all(positive_mask.any(axis=1)):
|
| 257 |
+
raise ValueError(f"missing positive event in {split}")
|
| 258 |
+
path = output_dir / f"{split}.npz"
|
| 259 |
+
np.savez_compressed(path, **tensors)
|
| 260 |
+
summaries[split] = {
|
| 261 |
+
"records": len(records),
|
| 262 |
+
"path": str(path.relative_to(root)),
|
| 263 |
+
"bytes": path.stat().st_size,
|
| 264 |
+
"history_length_min": int(tensors["history_mask"].sum(axis=1).min()),
|
| 265 |
+
"history_length_max": int(tensors["history_mask"].sum(axis=1).max()),
|
| 266 |
+
"moved_fraction": float(tensors["y_moved"].mean()),
|
| 267 |
+
"transition_occurred_fraction": float(
|
| 268 |
+
tensors["y_transition_occurred"].mean()
|
| 269 |
+
),
|
| 270 |
+
"returned_to_last_fraction": float(
|
| 271 |
+
tensors["y_returned_to_last"].mean()
|
| 272 |
+
),
|
| 273 |
+
}
|
| 274 |
+
schema = {
|
| 275 |
+
"schema_version": "p4d_packed_tensor_v1.2",
|
| 276 |
+
"max_history": args.max_history,
|
| 277 |
+
"state_count_including_unknown": state_count,
|
| 278 |
+
"category_to_id": category_to_id,
|
| 279 |
+
"region_category_to_id": region_to_id,
|
| 280 |
+
"receptacle_category_to_id": receptacle_to_id,
|
| 281 |
+
"metadata_world_variant_to_id": {"routine": 0, "random": 1, "static": 2},
|
| 282 |
+
"metadata_grounding_quality_to_id": {
|
| 283 |
+
"exact": 0,
|
| 284 |
+
"role_equivalent": 1,
|
| 285 |
+
"fallback": 2,
|
| 286 |
+
"counterfactual": 3,
|
| 287 |
+
"no_transition": 4,
|
| 288 |
+
},
|
| 289 |
+
"event_type_to_id": {"padding": 0, "positive_observation": 1, "candidate_inspection": 2},
|
| 290 |
+
"evidence_feature_order": [
|
| 291 |
+
"detector_confidence",
|
| 292 |
+
"instance_match_confidence",
|
| 293 |
+
"visible_fraction_estimate",
|
| 294 |
+
"surface_in_frustum_fraction",
|
| 295 |
+
"surface_unoccluded_fraction",
|
| 296 |
+
"negative_evidence_strength",
|
| 297 |
+
],
|
| 298 |
+
"observable_context_feature_order": [
|
| 299 |
+
"detected_object_count",
|
| 300 |
+
"observed_change_count",
|
| 301 |
+
"inspected_candidate_state_count",
|
| 302 |
+
"observed_region_count",
|
| 303 |
+
],
|
| 304 |
+
"label_tensors": [
|
| 305 |
+
"y_current_state",
|
| 306 |
+
"y_moved",
|
| 307 |
+
"y_transition_occurred",
|
| 308 |
+
"y_returned_to_last",
|
| 309 |
+
],
|
| 310 |
+
"metadata_tensors_not_for_model_input": [
|
| 311 |
+
"meta_world_variant_id",
|
| 312 |
+
"meta_grounding_quality_id",
|
| 313 |
+
"record_id",
|
| 314 |
+
"instance_uuid",
|
| 315 |
+
],
|
| 316 |
+
"access_rule": "input tensors derive from query.input; y_* derives from supervision; meta_* is only for filtering and reporting",
|
| 317 |
+
"pytorch_dtype_note": "cast int16 state/category ID arrays to torch.long before embedding, one_hot, or cross_entropy",
|
| 318 |
+
"splits": summaries,
|
| 319 |
+
"validation": {
|
| 320 |
+
"finite_numeric_tensors": True,
|
| 321 |
+
"valid_label_ranges": True,
|
| 322 |
+
"nonempty_history": True,
|
| 323 |
+
"positive_anchor_present": True,
|
| 324 |
+
},
|
| 325 |
+
}
|
| 326 |
+
(output_dir / "feature_schema.json").write_text(
|
| 327 |
+
json.dumps(schema, ensure_ascii=False, indent=2), encoding="utf-8"
|
| 328 |
+
)
|
| 329 |
+
print(json.dumps(schema, ensure_ascii=False, indent=2))
|
| 330 |
+
return 0
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
if __name__ == "__main__":
|
| 334 |
+
raise SystemExit(main())
|
code/scripts/train_p4d_belief.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Train and evaluate P4D-Belief on the frozen 30-day HSSD dataset."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import json
|
| 8 |
+
from dataclasses import asdict
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
import torch
|
| 12 |
+
|
| 13 |
+
from readyagent.p4d_belief.data import audit_training_contract, load_catalog
|
| 14 |
+
from readyagent.p4d_belief.models import ModelConfig
|
| 15 |
+
from readyagent.p4d_belief.training import (
|
| 16 |
+
TrainConfig,
|
| 17 |
+
evaluate_classical_baselines,
|
| 18 |
+
evaluate_model,
|
| 19 |
+
summarize_neural,
|
| 20 |
+
train_model,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def arguments() -> argparse.Namespace:
|
| 25 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 26 |
+
parser.add_argument("--dataset-root", type=Path, required=True)
|
| 27 |
+
parser.add_argument("--output", type=Path, required=True)
|
| 28 |
+
parser.add_argument("--models", nargs="+", choices=("p4d", "direct", "gru"), default=["p4d"])
|
| 29 |
+
parser.add_argument("--seeds", nargs="+", type=int, default=[0, 1, 2, 3, 4])
|
| 30 |
+
parser.add_argument("--layers", type=int, default=3)
|
| 31 |
+
parser.add_argument("--hidden-dim", type=int, default=128)
|
| 32 |
+
parser.add_argument("--heads", type=int, default=4)
|
| 33 |
+
parser.add_argument("--dropout", type=float, default=0.1)
|
| 34 |
+
parser.add_argument(
|
| 35 |
+
"--use-instance-identity",
|
| 36 |
+
action=argparse.BooleanOptionalAction,
|
| 37 |
+
default=True,
|
| 38 |
+
help="Use the observable target instance UUID as a stable identity embedding.",
|
| 39 |
+
)
|
| 40 |
+
parser.add_argument("--use-compatibility", action=argparse.BooleanOptionalAction, default=True)
|
| 41 |
+
parser.add_argument("--batch-size", type=int, default=64)
|
| 42 |
+
parser.add_argument("--eval-batch-size", type=int, default=256)
|
| 43 |
+
parser.add_argument("--epochs", type=int, default=100)
|
| 44 |
+
parser.add_argument("--patience", type=int, default=10)
|
| 45 |
+
parser.add_argument("--learning-rate", type=float, default=3e-4)
|
| 46 |
+
parser.add_argument("--weight-decay", type=float, default=1e-2)
|
| 47 |
+
parser.add_argument("--gradient-clip", type=float, default=1.0)
|
| 48 |
+
parser.add_argument(
|
| 49 |
+
"--routine-train-fraction",
|
| 50 |
+
type=float,
|
| 51 |
+
default=0.5,
|
| 52 |
+
help="Expected Routine fraction in the Routine/Static weighted sampler.",
|
| 53 |
+
)
|
| 54 |
+
parser.add_argument(
|
| 55 |
+
"--validation-subset",
|
| 56 |
+
choices=("main", "routine_all", "routine_exact", "static"),
|
| 57 |
+
default="main",
|
| 58 |
+
help="Subset whose NLL selects the early-stopping checkpoint.",
|
| 59 |
+
)
|
| 60 |
+
parser.add_argument(
|
| 61 |
+
"--unknown-train-boost",
|
| 62 |
+
type=float,
|
| 63 |
+
default=1.0,
|
| 64 |
+
help="Within-Routine sampling multiplier for true Unknown targets.",
|
| 65 |
+
)
|
| 66 |
+
parser.add_argument("--device", default="cuda")
|
| 67 |
+
parser.add_argument("--eval-split", choices=("val", "test"), default="val")
|
| 68 |
+
parser.add_argument("--skip-classical", action="store_true")
|
| 69 |
+
return parser.parse_args()
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def write_report(
|
| 73 |
+
output: Path,
|
| 74 |
+
*,
|
| 75 |
+
audit: dict,
|
| 76 |
+
classical: dict,
|
| 77 |
+
aggregate: dict,
|
| 78 |
+
model_config: ModelConfig,
|
| 79 |
+
train_config: TrainConfig,
|
| 80 |
+
) -> None:
|
| 81 |
+
lines = [
|
| 82 |
+
"# P4D-Belief 30天 Pilot 训练与测试报告",
|
| 83 |
+
"",
|
| 84 |
+
"## 实验设置",
|
| 85 |
+
"",
|
| 86 |
+
f"- 模型:Query-Conditioned Continuous-Time Transformer,{model_config.layers}层,hidden={model_config.hidden_dim},heads={model_config.heads}",
|
| 87 |
+
f"- 目标身份特征:{'启用稳定instance embedding' if model_config.use_instance_identity else '仅使用object category'}。",
|
| 88 |
+
f"- 训练:Routine Exact/No-transition + Static;Routine采样占比={train_config.routine_train_fraction:.0%};AdamW,lr={train_config.learning_rate},batch={train_config.batch_size}",
|
| 89 |
+
f"- 早停依据:验证集 `{train_config.validation_subset}` 的状态NLL。",
|
| 90 |
+
f"- Unknown正样本训练采样倍率:{train_config.unknown_train_boost:g}×。",
|
| 91 |
+
"- 模型输入不含 world type、grounding quality、GT activity、隐藏变化或监督字段;可选instance embedding只编码查询中已知的目标身份。",
|
| 92 |
+
"- P4D-Belief仅使用最终状态NLL;Bayesian Update不参与训练。",
|
| 93 |
+
"",
|
| 94 |
+
"## 训练前审计",
|
| 95 |
+
"",
|
| 96 |
+
f"- 审计通过:{audit['passed']}",
|
| 97 |
+
f"- Routine发生隐藏事件后回到last state的记录:{audit['routine_return_to_last_records']}",
|
| 98 |
+
"- `y_moved`表示查询状态与last state是否不同,不表示期间是否曾发生移动。",
|
| 99 |
+
"",
|
| 100 |
+
"## Test主结果(5 seeds均值±标准差)",
|
| 101 |
+
"",
|
| 102 |
+
"| 方法 | 子集 | Top-1 | Top-3 | NLL | ECE | 平均检查位置 | Persistence AUROC |",
|
| 103 |
+
"| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |",
|
| 104 |
+
]
|
| 105 |
+
for method, subsets in classical.items():
|
| 106 |
+
for subset in ("routine_exact", "routine_all", "random", "static"):
|
| 107 |
+
row = subsets[subset]
|
| 108 |
+
lines.append(
|
| 109 |
+
f"| {method} | {subset} | {row['top1']:.3f} | {row['top3']:.3f} | {row['nll']:.3f} | {row['ece']:.3f} | {row['mean_receptacles_checked']:.2f} | {row['persistence_auroc']:.3f} |"
|
| 110 |
+
)
|
| 111 |
+
for method, subsets in aggregate.items():
|
| 112 |
+
for subset in ("routine_exact", "routine_all", "random", "static"):
|
| 113 |
+
row = subsets[subset]
|
| 114 |
+
def cell(key: str) -> str:
|
| 115 |
+
return f"{row[key]['mean']:.3f}±{row[key]['std']:.3f}"
|
| 116 |
+
lines.append(
|
| 117 |
+
f"| {method} | {subset} | {cell('top1')} | {cell('top3')} | {cell('nll')} | {cell('ece')} | {cell('mean_receptacles_checked')} | {cell('persistence_auroc')} |"
|
| 118 |
+
)
|
| 119 |
+
lines.extend(
|
| 120 |
+
[
|
| 121 |
+
"",
|
| 122 |
+
"## 新版专项结果",
|
| 123 |
+
"",
|
| 124 |
+
"| 方法 | 子集 | 样本 | Top-1 | Top-3 | NLL | 平均检查位置 |",
|
| 125 |
+
"| --- | --- | ---: | ---: | ---: | ---: | ---: |",
|
| 126 |
+
]
|
| 127 |
+
)
|
| 128 |
+
for method, subsets in classical.items():
|
| 129 |
+
for subset in ("routine_unknown", "routine_returned"):
|
| 130 |
+
if subset not in subsets:
|
| 131 |
+
continue
|
| 132 |
+
row = subsets[subset]
|
| 133 |
+
lines.append(
|
| 134 |
+
f"| {method} | {subset} | {row['records']} | {row['top1']:.3f} | {row['top3']:.3f} | {row['nll']:.3f} | {row['mean_receptacles_checked']:.2f} |"
|
| 135 |
+
)
|
| 136 |
+
for method, subsets in aggregate.items():
|
| 137 |
+
for subset in ("routine_unknown", "routine_returned"):
|
| 138 |
+
if subset not in subsets:
|
| 139 |
+
continue
|
| 140 |
+
row = subsets[subset]
|
| 141 |
+
def diagnostic_cell(key: str) -> str:
|
| 142 |
+
return f"{row[key]['mean']:.3f}±{row[key]['std']:.3f}"
|
| 143 |
+
lines.append(
|
| 144 |
+
f"| {method} | {subset} | {row['records']} | {diagnostic_cell('top1')} | {diagnostic_cell('top3')} | {diagnostic_cell('nll')} | {diagnostic_cell('mean_receptacles_checked')} |"
|
| 145 |
+
)
|
| 146 |
+
lines.extend(
|
| 147 |
+
[
|
| 148 |
+
"",
|
| 149 |
+
"## 解释边界",
|
| 150 |
+
"",
|
| 151 |
+
"- 当前负证据的confidence/coverage来自受控模拟,不代表真实视觉检测器。",
|
| 152 |
+
"- 数据仅有一个HSSD场景;共享语义位置编码避免了state-ID embedding,但尚不能证明跨场景泛化。",
|
| 153 |
+
"- Unknown正标签表示对象物理离开建模场景,不表示遮挡或检测失败。",
|
| 154 |
+
"- `returned_to_last`用于区分期间发生移动与查询时最终Persistence;它不直接作为状态预测模型输入。",
|
| 155 |
+
"- 生成器GT Activity不进入模型;上下文只来自机器人可观察的巡检聚合。",
|
| 156 |
+
]
|
| 157 |
+
)
|
| 158 |
+
(output / "REPORT_ZH.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def main() -> int:
|
| 162 |
+
args = arguments()
|
| 163 |
+
dataset_root = args.dataset_root.resolve()
|
| 164 |
+
output = args.output.resolve()
|
| 165 |
+
output.mkdir(parents=True, exist_ok=True)
|
| 166 |
+
device = torch.device(args.device if args.device != "cuda" or torch.cuda.is_available() else "cpu")
|
| 167 |
+
catalog = load_catalog(dataset_root)
|
| 168 |
+
audit = audit_training_contract(dataset_root)
|
| 169 |
+
if not audit["passed"]:
|
| 170 |
+
raise RuntimeError("training contract audit failed")
|
| 171 |
+
(output / "training_contract_audit.json").write_text(
|
| 172 |
+
json.dumps(audit, ensure_ascii=False, indent=2), encoding="utf-8"
|
| 173 |
+
)
|
| 174 |
+
model_config = ModelConfig(
|
| 175 |
+
hidden_dim=args.hidden_dim,
|
| 176 |
+
layers=args.layers,
|
| 177 |
+
heads=args.heads,
|
| 178 |
+
dropout=args.dropout,
|
| 179 |
+
use_instance_identity=args.use_instance_identity,
|
| 180 |
+
use_compatibility=args.use_compatibility,
|
| 181 |
+
)
|
| 182 |
+
train_config = TrainConfig(
|
| 183 |
+
batch_size=args.batch_size,
|
| 184 |
+
eval_batch_size=args.eval_batch_size,
|
| 185 |
+
learning_rate=args.learning_rate,
|
| 186 |
+
weight_decay=args.weight_decay,
|
| 187 |
+
max_epochs=args.epochs,
|
| 188 |
+
patience=args.patience,
|
| 189 |
+
gradient_clip=args.gradient_clip,
|
| 190 |
+
routine_train_fraction=args.routine_train_fraction,
|
| 191 |
+
validation_subset=args.validation_subset,
|
| 192 |
+
unknown_train_boost=args.unknown_train_boost,
|
| 193 |
+
)
|
| 194 |
+
run_config = {
|
| 195 |
+
"dataset_root": str(dataset_root),
|
| 196 |
+
"output": str(output),
|
| 197 |
+
"device": str(device),
|
| 198 |
+
"gpu": torch.cuda.get_device_name(device) if device.type == "cuda" else None,
|
| 199 |
+
"models": args.models,
|
| 200 |
+
"seeds": args.seeds,
|
| 201 |
+
"model_config": asdict(model_config),
|
| 202 |
+
"train_config": asdict(train_config),
|
| 203 |
+
}
|
| 204 |
+
(output / "config.json").write_text(
|
| 205 |
+
json.dumps(run_config, ensure_ascii=False, indent=2), encoding="utf-8"
|
| 206 |
+
)
|
| 207 |
+
classical = {} if args.skip_classical else evaluate_classical_baselines(dataset_root, catalog)
|
| 208 |
+
(output / "classical_baselines.json").write_text(
|
| 209 |
+
json.dumps(classical, ensure_ascii=False, indent=2), encoding="utf-8"
|
| 210 |
+
)
|
| 211 |
+
seed_results: dict[str, list[dict]] = {name: [] for name in args.models}
|
| 212 |
+
training_results: list[dict] = []
|
| 213 |
+
for name in args.models:
|
| 214 |
+
for seed in args.seeds:
|
| 215 |
+
run_dir = output / "checkpoints" / name / f"seed_{seed}"
|
| 216 |
+
print(f"TRAIN model={name} seed={seed} device={device}", flush=True)
|
| 217 |
+
model, training = train_model(
|
| 218 |
+
name=name,
|
| 219 |
+
seed=seed,
|
| 220 |
+
dataset_root=dataset_root,
|
| 221 |
+
catalog=catalog,
|
| 222 |
+
model_config=model_config,
|
| 223 |
+
train_config=train_config,
|
| 224 |
+
device=device,
|
| 225 |
+
output_dir=run_dir,
|
| 226 |
+
)
|
| 227 |
+
metrics = evaluate_model(
|
| 228 |
+
model,
|
| 229 |
+
dataset_root=dataset_root,
|
| 230 |
+
catalog=catalog,
|
| 231 |
+
split=args.eval_split,
|
| 232 |
+
device=device,
|
| 233 |
+
batch_size=args.eval_batch_size,
|
| 234 |
+
include_update=name == "p4d",
|
| 235 |
+
)
|
| 236 |
+
(run_dir / f"{args.eval_split}_metrics.json").write_text(
|
| 237 |
+
json.dumps(metrics, ensure_ascii=False, indent=2), encoding="utf-8"
|
| 238 |
+
)
|
| 239 |
+
seed_results[name].append(metrics)
|
| 240 |
+
training_results.append(training)
|
| 241 |
+
print(
|
| 242 |
+
f"DONE model={name} seed={seed} epoch={training['best_epoch']} "
|
| 243 |
+
f"routine_exact_top1={metrics['routine_exact']['top1']:.4f}",
|
| 244 |
+
flush=True,
|
| 245 |
+
)
|
| 246 |
+
del model
|
| 247 |
+
if device.type == "cuda":
|
| 248 |
+
torch.cuda.empty_cache()
|
| 249 |
+
aggregate = summarize_neural(seed_results)
|
| 250 |
+
(output / "neural_aggregate.json").write_text(
|
| 251 |
+
json.dumps(aggregate, ensure_ascii=False, indent=2), encoding="utf-8"
|
| 252 |
+
)
|
| 253 |
+
(output / "training_summary.json").write_text(
|
| 254 |
+
json.dumps(training_results, ensure_ascii=False, indent=2), encoding="utf-8"
|
| 255 |
+
)
|
| 256 |
+
write_report(
|
| 257 |
+
output,
|
| 258 |
+
audit=audit,
|
| 259 |
+
classical=classical,
|
| 260 |
+
aggregate=aggregate,
|
| 261 |
+
model_config=model_config,
|
| 262 |
+
train_config=train_config,
|
| 263 |
+
)
|
| 264 |
+
print(json.dumps({"status": "OK", "output": str(output)}, ensure_ascii=False))
|
| 265 |
+
return 0
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
if __name__ == "__main__":
|
| 269 |
+
raise SystemExit(main())
|
code/scripts/train_transition.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Jointly fine-tune P4D state belief and chronological transition head."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import copy
|
| 8 |
+
import json
|
| 9 |
+
import math
|
| 10 |
+
import random
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
import torch
|
| 14 |
+
from torch.utils.data import DataLoader, Dataset
|
| 15 |
+
|
| 16 |
+
from evolvingnav_paper.transition_model import (
|
| 17 |
+
TransitionHead, event_horizon_pairs, transition_nll,
|
| 18 |
+
)
|
| 19 |
+
from readyagent.p4d_belief.data import PackedQueries, load_catalog
|
| 20 |
+
from readyagent.p4d_belief.models import ModelConfig, state_nll
|
| 21 |
+
from readyagent.p4d_belief.training import build_model
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class ChronologicalDataset(Dataset):
|
| 25 |
+
def __init__(self, root: Path, split: str, horizons_s: tuple[float, ...],
|
| 26 |
+
max_pairs: int | None = None) -> None:
|
| 27 |
+
self.records = PackedQueries(root / f"records/packed/{split}.npz")
|
| 28 |
+
arrays = self.records.arrays
|
| 29 |
+
events = []
|
| 30 |
+
for world_id, name in ((0, "routine"), (1, "random")):
|
| 31 |
+
events_path = root / f"worlds/{name}/private_gt/hidden_transitions.jsonl"
|
| 32 |
+
with events_path.open(encoding="utf-8") as handle:
|
| 33 |
+
for line in handle:
|
| 34 |
+
if line.strip():
|
| 35 |
+
event = json.loads(line)
|
| 36 |
+
event["world_id"] = world_id
|
| 37 |
+
events.append(event)
|
| 38 |
+
self.pairs = event_horizon_pairs(
|
| 39 |
+
instance_ids=arrays["instance_uuid"],
|
| 40 |
+
world_ids=arrays["meta_world_variant_id"],
|
| 41 |
+
query_times_s=arrays["query_time_days"].astype(float) * 86400.0,
|
| 42 |
+
query_states=arrays["y_current_state"], events=events,
|
| 43 |
+
horizons_s=horizons_s,
|
| 44 |
+
)
|
| 45 |
+
if max_pairs is not None:
|
| 46 |
+
self.pairs = self.pairs[:max_pairs]
|
| 47 |
+
if not self.pairs:
|
| 48 |
+
raise ValueError(f"no chronological pairs in {split}")
|
| 49 |
+
|
| 50 |
+
def __len__(self) -> int:
|
| 51 |
+
return len(self.pairs)
|
| 52 |
+
|
| 53 |
+
def __getitem__(self, item: int) -> dict[str, torch.Tensor]:
|
| 54 |
+
source, anchor_time, horizon, source_state, destination_state = self.pairs[item]
|
| 55 |
+
batch = self.records[source]
|
| 56 |
+
candidates = self.records.arrays["candidate_state_ids"][source].tolist()
|
| 57 |
+
source_time = float(self.records.arrays["query_time_days"][source]) * 86400.0
|
| 58 |
+
anchor_days = anchor_time / 86400.0
|
| 59 |
+
phase = 2 * math.pi * anchor_days
|
| 60 |
+
batch["query_time_days"] = torch.tensor(anchor_days, dtype=torch.float32)
|
| 61 |
+
batch["query_time_of_day_sin_cos"] = torch.tensor(
|
| 62 |
+
[math.sin(phase), math.cos(phase)], dtype=torch.float32
|
| 63 |
+
)
|
| 64 |
+
batch["query_weekday_id"] = torch.tensor(
|
| 65 |
+
(int(batch["query_weekday_id"]) + math.floor(anchor_days)
|
| 66 |
+
- math.floor(source_time / 86400.0)) % 7, dtype=torch.long
|
| 67 |
+
)
|
| 68 |
+
batch["elapsed_since_last_positive_days"] = (
|
| 69 |
+
batch["elapsed_since_last_positive_days"]
|
| 70 |
+
+ torch.tensor((anchor_time - source_time) / 86400.0, dtype=torch.float32)
|
| 71 |
+
)
|
| 72 |
+
batch["target_candidate_index"] = torch.tensor(candidates.index(source_state))
|
| 73 |
+
batch["y_current_state"] = torch.tensor(source_state)
|
| 74 |
+
batch["transition_source_index"] = torch.tensor(candidates.index(source_state))
|
| 75 |
+
batch["transition_destination_index"] = torch.tensor(candidates.index(destination_state))
|
| 76 |
+
batch["transition_horizon_s"] = torch.tensor(horizon, dtype=torch.float32)
|
| 77 |
+
return batch
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def epoch(model, head, loader, optimizer, device, gradient_clip: float) -> float:
|
| 81 |
+
training = optimizer is not None
|
| 82 |
+
model.train(training)
|
| 83 |
+
head.train(training)
|
| 84 |
+
total, count = 0.0, 0
|
| 85 |
+
for raw in loader:
|
| 86 |
+
batch = {key: value.to(device) for key, value in raw.items()}
|
| 87 |
+
with torch.set_grad_enabled(training):
|
| 88 |
+
output = model(batch)
|
| 89 |
+
kernel = head(output["context"], output["candidates"],
|
| 90 |
+
batch["transition_horizon_s"], batch["candidate_mask"])
|
| 91 |
+
loss = state_nll(output["probabilities"], batch["target_candidate_index"])
|
| 92 |
+
loss = loss + transition_nll(
|
| 93 |
+
kernel, batch["transition_source_index"],
|
| 94 |
+
batch["transition_destination_index"],
|
| 95 |
+
)
|
| 96 |
+
if training:
|
| 97 |
+
optimizer.zero_grad(set_to_none=True)
|
| 98 |
+
loss.backward()
|
| 99 |
+
torch.nn.utils.clip_grad_norm_(
|
| 100 |
+
list(model.parameters()) + list(head.parameters()), gradient_clip
|
| 101 |
+
)
|
| 102 |
+
optimizer.step()
|
| 103 |
+
total += float(loss.detach()) * len(batch["transition_horizon_s"])
|
| 104 |
+
count += len(batch["transition_horizon_s"])
|
| 105 |
+
return total / count
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def main() -> int:
|
| 109 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 110 |
+
parser.add_argument("--dataset", type=Path, required=True)
|
| 111 |
+
parser.add_argument("--belief-checkpoint", type=Path, required=True)
|
| 112 |
+
parser.add_argument("--output", type=Path, required=True)
|
| 113 |
+
parser.add_argument("--horizons-s", nargs="+", type=float,
|
| 114 |
+
default=[2.0, 10.0, 30.0, 60.0, 120.0, 300.0])
|
| 115 |
+
parser.add_argument("--max-pairs", type=int)
|
| 116 |
+
parser.add_argument("--epochs", type=int, default=100)
|
| 117 |
+
parser.add_argument("--patience", type=int, default=10)
|
| 118 |
+
parser.add_argument("--batch-size", type=int, default=64)
|
| 119 |
+
parser.add_argument("--seed", type=int, default=0)
|
| 120 |
+
parser.add_argument("--device", default="cuda")
|
| 121 |
+
args = parser.parse_args()
|
| 122 |
+
random.seed(args.seed)
|
| 123 |
+
torch.manual_seed(args.seed)
|
| 124 |
+
torch.cuda.manual_seed_all(args.seed)
|
| 125 |
+
if args.output.exists():
|
| 126 |
+
raise FileExistsError(args.output)
|
| 127 |
+
device = torch.device(args.device if args.device != "cuda" or torch.cuda.is_available() else "cpu")
|
| 128 |
+
catalog = load_catalog(args.dataset)
|
| 129 |
+
original = torch.load(args.belief_checkpoint, map_location="cpu", weights_only=True)
|
| 130 |
+
model = build_model("p4d", catalog, ModelConfig(**original["model_config"]))
|
| 131 |
+
model.load_state_dict(original["model"])
|
| 132 |
+
model.to(device)
|
| 133 |
+
head = TransitionHead(int(original["model_config"]["hidden_dim"])).to(device)
|
| 134 |
+
train = ChronologicalDataset(args.dataset, "train", tuple(args.horizons_s), args.max_pairs)
|
| 135 |
+
val = ChronologicalDataset(args.dataset, "val", tuple(args.horizons_s), args.max_pairs)
|
| 136 |
+
train_loader = DataLoader(train, batch_size=args.batch_size, shuffle=True)
|
| 137 |
+
val_loader = DataLoader(val, batch_size=args.batch_size)
|
| 138 |
+
optimizer = torch.optim.AdamW(
|
| 139 |
+
list(model.parameters()) + list(head.parameters()), lr=3e-4, weight_decay=1e-2
|
| 140 |
+
)
|
| 141 |
+
best, stale, state = float("inf"), 0, None
|
| 142 |
+
for number in range(1, args.epochs + 1):
|
| 143 |
+
train_loss = epoch(model, head, train_loader, optimizer, device, 1.0)
|
| 144 |
+
val_loss = epoch(model, head, val_loader, None, device, 1.0)
|
| 145 |
+
print(f"epoch={number} train={train_loss:.5f} val={val_loss:.5f}", flush=True)
|
| 146 |
+
if val_loss < best - 1e-5:
|
| 147 |
+
best, stale = val_loss, 0
|
| 148 |
+
state = (copy.deepcopy(model.state_dict()), copy.deepcopy(head.state_dict()))
|
| 149 |
+
else:
|
| 150 |
+
stale += 1
|
| 151 |
+
if stale >= args.patience:
|
| 152 |
+
break
|
| 153 |
+
if state is None:
|
| 154 |
+
raise RuntimeError("transition training produced no checkpoint")
|
| 155 |
+
args.output.mkdir(parents=True)
|
| 156 |
+
torch.save({
|
| 157 |
+
"model": state[0], "transition_head": state[1],
|
| 158 |
+
"model_config": original["model_config"], "best_validation_loss": best,
|
| 159 |
+
"horizons_s": args.horizons_s,
|
| 160 |
+
}, args.output / "best.pt")
|
| 161 |
+
(args.output / "summary.json").write_text(json.dumps({
|
| 162 |
+
"train_pairs": len(train), "val_pairs": len(val),
|
| 163 |
+
"best_validation_loss": best,
|
| 164 |
+
}, indent=2) + "\n")
|
| 165 |
+
return 0
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
if __name__ == "__main__":
|
| 169 |
+
raise SystemExit(main())
|
code/src/readyagent/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
__version__ = "0.1.0"
|
code/src/readyagent/p4d_belief/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""P4D-Belief training and evaluation components."""
|
| 2 |
+
|
| 3 |
+
from readyagent.p4d_belief.models import (
|
| 4 |
+
DirectPointer,
|
| 5 |
+
GRUDirectPointer,
|
| 6 |
+
P4DBelief,
|
| 7 |
+
bayesian_update,
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
__all__ = [
|
| 11 |
+
"DirectPointer",
|
| 12 |
+
"GRUDirectPointer",
|
| 13 |
+
"P4DBelief",
|
| 14 |
+
"bayesian_update",
|
| 15 |
+
]
|
code/src/readyagent/p4d_belief/baselines.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Leakage-safe classical baselines for current-state prediction."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _normalize(counts: np.ndarray) -> np.ndarray:
|
| 11 |
+
return counts / counts.sum(axis=-1, keepdims=True).clip(min=1e-12)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass
|
| 15 |
+
class ClassicalBaselines:
|
| 16 |
+
state_count: int
|
| 17 |
+
category_count: int
|
| 18 |
+
smoothing: float = 0.25
|
| 19 |
+
|
| 20 |
+
def fit(
|
| 21 |
+
self,
|
| 22 |
+
*,
|
| 23 |
+
target_category: np.ndarray,
|
| 24 |
+
target_state: np.ndarray,
|
| 25 |
+
last_state: np.ndarray,
|
| 26 |
+
weekday: np.ndarray,
|
| 27 |
+
time_sin_cos: np.ndarray,
|
| 28 |
+
) -> None:
|
| 29 |
+
self.object_counts = np.full(
|
| 30 |
+
(self.category_count, self.state_count), self.smoothing, dtype=np.float64
|
| 31 |
+
)
|
| 32 |
+
self.markov_counts = np.full(
|
| 33 |
+
(self.category_count, self.state_count, self.state_count),
|
| 34 |
+
self.smoothing,
|
| 35 |
+
dtype=np.float64,
|
| 36 |
+
)
|
| 37 |
+
self.time_counts = np.full(
|
| 38 |
+
(self.category_count, 7, 4, self.state_count), self.smoothing, dtype=np.float64
|
| 39 |
+
)
|
| 40 |
+
hour = _time_quarter(time_sin_cos)
|
| 41 |
+
np.add.at(self.object_counts, (target_category, target_state), 1.0)
|
| 42 |
+
np.add.at(self.markov_counts, (target_category, last_state, target_state), 1.0)
|
| 43 |
+
np.add.at(self.time_counts, (target_category, weekday, hour, target_state), 1.0)
|
| 44 |
+
self.object_probabilities = _normalize(self.object_counts)
|
| 45 |
+
self.markov_probabilities = _normalize(self.markov_counts)
|
| 46 |
+
self.time_probabilities = _normalize(self.time_counts)
|
| 47 |
+
|
| 48 |
+
def predict(
|
| 49 |
+
self,
|
| 50 |
+
method: str,
|
| 51 |
+
*,
|
| 52 |
+
target_category: np.ndarray,
|
| 53 |
+
last_state: np.ndarray,
|
| 54 |
+
weekday: np.ndarray,
|
| 55 |
+
time_sin_cos: np.ndarray,
|
| 56 |
+
) -> np.ndarray:
|
| 57 |
+
if method == "last_seen":
|
| 58 |
+
probabilities = np.zeros((len(last_state), self.state_count), dtype=np.float64)
|
| 59 |
+
probabilities[np.arange(len(last_state)), last_state] = 1.0
|
| 60 |
+
return probabilities
|
| 61 |
+
if method == "object_frequency":
|
| 62 |
+
return self.object_probabilities[target_category]
|
| 63 |
+
if method == "markov":
|
| 64 |
+
return self.markov_probabilities[target_category, last_state]
|
| 65 |
+
if method == "time_frequency":
|
| 66 |
+
quarter = _time_quarter(time_sin_cos)
|
| 67 |
+
return self.time_probabilities[target_category, weekday, quarter]
|
| 68 |
+
raise ValueError(method)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _time_quarter(time_sin_cos: np.ndarray) -> np.ndarray:
|
| 72 |
+
angle = np.arctan2(time_sin_cos[:, 0], time_sin_cos[:, 1])
|
| 73 |
+
fraction = np.mod(angle, 2 * np.pi) / (2 * np.pi)
|
| 74 |
+
return np.floor(fraction * 4).astype(np.int64).clip(0, 3)
|
code/src/readyagent/p4d_belief/data.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Leakage-safe loader for packed P4D-HSSD query records."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import bisect
|
| 6 |
+
import json
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
import torch
|
| 13 |
+
from torch.utils.data import Dataset
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
WORLD_NAMES = {0: "routine", 1: "random", 2: "static"}
|
| 17 |
+
QUALITY_NAMES = {0: "exact", 1: "fallback", 2: "counterfactual", 3: "no_transition"}
|
| 18 |
+
|
| 19 |
+
# This is the complete allowlist passed to neural models. In particular, no
|
| 20 |
+
# meta_world_variant_id, meta_grounding_quality_id, activity or supervision field
|
| 21 |
+
# is included.
|
| 22 |
+
MODEL_INPUT_KEYS = (
|
| 23 |
+
"event_type",
|
| 24 |
+
"event_time_days",
|
| 25 |
+
"observed_state_id",
|
| 26 |
+
"candidate_state_id",
|
| 27 |
+
"evidence_features",
|
| 28 |
+
"history_mask",
|
| 29 |
+
"candidate_state_ids",
|
| 30 |
+
"candidate_mask",
|
| 31 |
+
"target_category_id",
|
| 32 |
+
"query_time_days",
|
| 33 |
+
"query_time_of_day_sin_cos",
|
| 34 |
+
"query_weekday_id",
|
| 35 |
+
"elapsed_since_last_positive_days",
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
OPTIONAL_MODEL_INPUT_KEYS = (
|
| 39 |
+
"context_time_days",
|
| 40 |
+
"context_category_counts",
|
| 41 |
+
"context_observation_features",
|
| 42 |
+
"context_mask",
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
DERIVED_MODEL_INPUT_KEYS = ("target_instance_id",)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@dataclass(frozen=True)
|
| 49 |
+
class Catalog:
|
| 50 |
+
"""Static scene catalog used by the shared semantic candidate encoder."""
|
| 51 |
+
|
| 52 |
+
region_category: torch.Tensor
|
| 53 |
+
receptacle_category: torch.Tensor
|
| 54 |
+
center_xyz: torch.Tensor
|
| 55 |
+
is_unknown: torch.Tensor
|
| 56 |
+
region_instance_ids: tuple[str, ...]
|
| 57 |
+
schema: dict[str, Any]
|
| 58 |
+
|
| 59 |
+
@property
|
| 60 |
+
def state_count(self) -> int:
|
| 61 |
+
return int(self.region_category.shape[0])
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class PackedQueries(Dataset[dict[str, torch.Tensor]]):
|
| 65 |
+
"""Selected packed records with metadata kept outside model inputs."""
|
| 66 |
+
|
| 67 |
+
def __init__(self, packed_path: Path, indices: np.ndarray | None = None) -> None:
|
| 68 |
+
data = np.load(packed_path, allow_pickle=False)
|
| 69 |
+
self.arrays = {key: data[key] for key in data.files}
|
| 70 |
+
instance_values = sorted(str(value) for value in np.unique(self.arrays["instance_uuid"]))
|
| 71 |
+
instance_to_id = {value: index for index, value in enumerate(instance_values)}
|
| 72 |
+
self.target_instance_id = np.asarray(
|
| 73 |
+
[instance_to_id[str(value)] for value in self.arrays["instance_uuid"]],
|
| 74 |
+
dtype=np.int64,
|
| 75 |
+
)
|
| 76 |
+
total = len(self.arrays["y_current_state"])
|
| 77 |
+
self.indices = (
|
| 78 |
+
np.arange(total, dtype=np.int64)
|
| 79 |
+
if indices is None
|
| 80 |
+
else np.asarray(indices, dtype=np.int64)
|
| 81 |
+
)
|
| 82 |
+
self.last_state = derive_last_state(self.arrays)
|
| 83 |
+
self.target_candidate_index = map_state_to_candidate_index(
|
| 84 |
+
self.arrays["candidate_state_ids"], self.arrays["y_current_state"]
|
| 85 |
+
)
|
| 86 |
+
self.last_candidate_index = map_state_to_candidate_index(
|
| 87 |
+
self.arrays["candidate_state_ids"], self.last_state
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
def __len__(self) -> int:
|
| 91 |
+
return len(self.indices)
|
| 92 |
+
|
| 93 |
+
def __getitem__(self, item: int) -> dict[str, torch.Tensor]:
|
| 94 |
+
index = int(self.indices[item])
|
| 95 |
+
result: dict[str, torch.Tensor] = {}
|
| 96 |
+
for key in MODEL_INPUT_KEYS:
|
| 97 |
+
result[key] = torch.as_tensor(self.arrays[key][index])
|
| 98 |
+
for key in OPTIONAL_MODEL_INPUT_KEYS:
|
| 99 |
+
if key in self.arrays:
|
| 100 |
+
result[key] = torch.as_tensor(self.arrays[key][index])
|
| 101 |
+
result["target_instance_id"] = torch.tensor(
|
| 102 |
+
self.target_instance_id[index], dtype=torch.long
|
| 103 |
+
)
|
| 104 |
+
result["last_state"] = torch.tensor(self.last_state[index], dtype=torch.long)
|
| 105 |
+
result["last_candidate_index"] = torch.tensor(
|
| 106 |
+
self.last_candidate_index[index], dtype=torch.long
|
| 107 |
+
)
|
| 108 |
+
result["target_candidate_index"] = torch.tensor(
|
| 109 |
+
self.target_candidate_index[index], dtype=torch.long
|
| 110 |
+
)
|
| 111 |
+
result["y_current_state"] = torch.tensor(
|
| 112 |
+
self.arrays["y_current_state"][index], dtype=torch.long
|
| 113 |
+
)
|
| 114 |
+
result["y_persist"] = torch.tensor(
|
| 115 |
+
self.last_state[index] == self.arrays["y_current_state"][index],
|
| 116 |
+
dtype=torch.float32,
|
| 117 |
+
)
|
| 118 |
+
result["source_index"] = torch.tensor(index, dtype=torch.long)
|
| 119 |
+
return result
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def load_catalog(dataset_root: Path) -> Catalog:
|
| 123 |
+
packed = dataset_root / "records/packed"
|
| 124 |
+
schema = json.loads((packed / "feature_schema.json").read_text(encoding="utf-8"))
|
| 125 |
+
with np.load(packed / "train.npz", allow_pickle=False) as data:
|
| 126 |
+
region = torch.from_numpy(data["candidate_region_category_id"].astype(np.int64))
|
| 127 |
+
receptacle = torch.from_numpy(
|
| 128 |
+
data["candidate_receptacle_category_id"].astype(np.int64)
|
| 129 |
+
)
|
| 130 |
+
center = torch.from_numpy(data["candidate_center_xyz"].astype(np.float32))
|
| 131 |
+
unknown = torch.from_numpy(data["candidate_is_unknown"].astype(np.bool_))
|
| 132 |
+
instance_values = sorted(str(value) for value in np.unique(data["instance_uuid"]))
|
| 133 |
+
schema["instance_uuid_to_id"] = {
|
| 134 |
+
value: index for index, value in enumerate(instance_values)
|
| 135 |
+
}
|
| 136 |
+
candidate_json = json.loads(
|
| 137 |
+
(dataset_root / "scene/candidate_states.json").read_text(encoding="utf-8")
|
| 138 |
+
)
|
| 139 |
+
states = sorted(candidate_json["states"], key=lambda row: row["state_id"])
|
| 140 |
+
if len(states) != len(region):
|
| 141 |
+
raise ValueError("candidate catalog and packed arrays have different lengths")
|
| 142 |
+
region_ids = tuple(str(row["region_id"]) for row in states)
|
| 143 |
+
return Catalog(region, receptacle, center, unknown, region_ids, schema)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def derive_last_state(arrays: dict[str, np.ndarray]) -> np.ndarray:
|
| 147 |
+
positive = (arrays["event_type"] == 1) & arrays["history_mask"]
|
| 148 |
+
positions = np.where(positive, np.arange(positive.shape[1])[None, :], -1)
|
| 149 |
+
last_index = positions.max(axis=1)
|
| 150 |
+
if np.any(last_index < 0):
|
| 151 |
+
raise ValueError("every query must contain a positive observation anchor")
|
| 152 |
+
last_state = arrays["observed_state_id"][np.arange(len(last_index)), last_index]
|
| 153 |
+
if np.any(last_state < 0):
|
| 154 |
+
raise ValueError("positive observation has an invalid state")
|
| 155 |
+
return last_state.astype(np.int64)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def map_state_to_candidate_index(candidates: np.ndarray, states: np.ndarray) -> np.ndarray:
|
| 159 |
+
matches = candidates == np.asarray(states)[:, None]
|
| 160 |
+
if not np.all(matches.any(axis=1)):
|
| 161 |
+
raise ValueError("target or last state is absent from candidate set")
|
| 162 |
+
return matches.argmax(axis=1).astype(np.int64)
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def subset_indices(
|
| 166 |
+
arrays: dict[str, np.ndarray],
|
| 167 |
+
*,
|
| 168 |
+
world: str,
|
| 169 |
+
quality: str = "all",
|
| 170 |
+
) -> np.ndarray:
|
| 171 |
+
reverse_world = {value: key for key, value in WORLD_NAMES.items()}
|
| 172 |
+
if world == "main":
|
| 173 |
+
# Main training/validation excludes semantically-fallback transitions:
|
| 174 |
+
# Routine exact/no-transition plus all Static records.
|
| 175 |
+
keep = (
|
| 176 |
+
((arrays["meta_world_variant_id"] == reverse_world["routine"])
|
| 177 |
+
& np.isin(arrays["meta_grounding_quality_id"], (0, 3)))
|
| 178 |
+
| (arrays["meta_world_variant_id"] == reverse_world["static"])
|
| 179 |
+
)
|
| 180 |
+
else:
|
| 181 |
+
keep = arrays["meta_world_variant_id"] == reverse_world[world]
|
| 182 |
+
if quality == "exact":
|
| 183 |
+
# Exact Routine includes unchanged queries, for which grounding is not
|
| 184 |
+
# applicable and is explicitly labelled no_transition.
|
| 185 |
+
keep &= np.isin(arrays["meta_grounding_quality_id"], (0, 3))
|
| 186 |
+
elif quality == "fallback":
|
| 187 |
+
keep &= arrays["meta_grounding_quality_id"] == 1
|
| 188 |
+
elif quality != "all":
|
| 189 |
+
raise ValueError(f"unknown quality subset: {quality}")
|
| 190 |
+
return np.flatnonzero(keep)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def load_arrays(dataset_root: Path, split: str) -> dict[str, np.ndarray]:
|
| 194 |
+
path = dataset_root / "records/packed" / f"{split}.npz"
|
| 195 |
+
with np.load(path, allow_pickle=False) as data:
|
| 196 |
+
return {key: data[key] for key in data.files}
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def audit_training_contract(dataset_root: Path) -> dict[str, Any]:
|
| 200 |
+
"""Fail fast on label, chronology, candidate and leakage assumptions."""
|
| 201 |
+
|
| 202 |
+
report: dict[str, Any] = {
|
| 203 |
+
"splits": {},
|
| 204 |
+
"model_input_keys": list(
|
| 205 |
+
MODEL_INPUT_KEYS + OPTIONAL_MODEL_INPUT_KEYS + DERIVED_MODEL_INPUT_KEYS
|
| 206 |
+
),
|
| 207 |
+
}
|
| 208 |
+
forbidden = {
|
| 209 |
+
"meta_world_variant_id",
|
| 210 |
+
"meta_grounding_quality_id",
|
| 211 |
+
"activity_type_id",
|
| 212 |
+
"activity_history",
|
| 213 |
+
"y_current_state",
|
| 214 |
+
"y_moved",
|
| 215 |
+
}
|
| 216 |
+
report["forbidden_keys_absent_from_model_input"] = not bool(
|
| 217 |
+
forbidden.intersection(
|
| 218 |
+
MODEL_INPUT_KEYS + OPTIONAL_MODEL_INPUT_KEYS + DERIVED_MODEL_INPUT_KEYS
|
| 219 |
+
)
|
| 220 |
+
)
|
| 221 |
+
total_disagreement = 0
|
| 222 |
+
scheduled_events: dict[str, list[float]] = {}
|
| 223 |
+
schedule_path = dataset_root / "schedules/routine_events.jsonl"
|
| 224 |
+
if schedule_path.exists():
|
| 225 |
+
for line in schedule_path.read_text(encoding="utf-8").splitlines():
|
| 226 |
+
event = json.loads(line)
|
| 227 |
+
scheduled_events.setdefault(str(event["instance_uuid"]), []).append(
|
| 228 |
+
float(event["event_time_s"]) / 86400.0
|
| 229 |
+
)
|
| 230 |
+
for timestamps in scheduled_events.values():
|
| 231 |
+
timestamps.sort()
|
| 232 |
+
total_return_to_last = 0
|
| 233 |
+
for split in ("train", "val", "test"):
|
| 234 |
+
arrays = load_arrays(dataset_root, split)
|
| 235 |
+
last = derive_last_state(arrays)
|
| 236 |
+
target_index = map_state_to_candidate_index(
|
| 237 |
+
arrays["candidate_state_ids"], arrays["y_current_state"]
|
| 238 |
+
)
|
| 239 |
+
last_index = map_state_to_candidate_index(arrays["candidate_state_ids"], last)
|
| 240 |
+
valid_time = arrays["event_time_days"] <= arrays["query_time_days"][:, None] + 1e-6
|
| 241 |
+
valid_time |= ~arrays["history_mask"]
|
| 242 |
+
persist = last == arrays["y_current_state"]
|
| 243 |
+
disagreement = int(np.sum(arrays["y_moved"] != ~persist))
|
| 244 |
+
total_disagreement += disagreement
|
| 245 |
+
routine = arrays["meta_world_variant_id"] == 0
|
| 246 |
+
routine_indices = np.flatnonzero(routine)
|
| 247 |
+
hidden_event_count = np.zeros(len(routine_indices), dtype=np.int64)
|
| 248 |
+
for output_index, record_index in enumerate(routine_indices):
|
| 249 |
+
positive = (
|
| 250 |
+
(arrays["event_type"][record_index] == 1)
|
| 251 |
+
& arrays["history_mask"][record_index]
|
| 252 |
+
)
|
| 253 |
+
last_time = float(
|
| 254 |
+
arrays["event_time_days"][record_index, np.flatnonzero(positive)[-1]]
|
| 255 |
+
)
|
| 256 |
+
query_time = float(arrays["query_time_days"][record_index])
|
| 257 |
+
timestamps = scheduled_events.get(str(arrays["instance_uuid"][record_index]), [])
|
| 258 |
+
hidden_event_count[output_index] = bisect.bisect_right(
|
| 259 |
+
timestamps, query_time + 1e-6
|
| 260 |
+
) - bisect.bisect_right(timestamps, last_time + 1e-6)
|
| 261 |
+
routine_persist = persist[routine_indices]
|
| 262 |
+
returns = int(np.sum((hidden_event_count > 0) & routine_persist))
|
| 263 |
+
total_return_to_last += returns
|
| 264 |
+
report["splits"][split] = {
|
| 265 |
+
"records": int(len(last)),
|
| 266 |
+
"target_present": bool(np.all(target_index >= 0)),
|
| 267 |
+
"last_present": bool(np.all(last_index >= 0)),
|
| 268 |
+
"no_future_history": bool(np.all(valid_time)),
|
| 269 |
+
"persist_fraction": float(np.mean(persist)),
|
| 270 |
+
"moved_vs_nonpersist_disagreement": disagreement,
|
| 271 |
+
"routine_no_hidden_event_since_last_positive": int(
|
| 272 |
+
np.sum(hidden_event_count == 0)
|
| 273 |
+
),
|
| 274 |
+
"routine_event_and_return_to_last": returns,
|
| 275 |
+
"routine_event_and_changed": int(
|
| 276 |
+
np.sum((hidden_event_count > 0) & ~routine_persist)
|
| 277 |
+
),
|
| 278 |
+
"unknown_targets": int(
|
| 279 |
+
np.sum(arrays["y_current_state"] == int(arrays["candidate_is_unknown"].argmax()))
|
| 280 |
+
),
|
| 281 |
+
}
|
| 282 |
+
report["moved_label_semantics"] = (
|
| 283 |
+
"y_moved is net current-state difference from last positive, not whether any "
|
| 284 |
+
"hidden transition occurred"
|
| 285 |
+
)
|
| 286 |
+
report["routine_return_to_last_records"] = total_return_to_last
|
| 287 |
+
report["passed"] = bool(
|
| 288 |
+
report["forbidden_keys_absent_from_model_input"]
|
| 289 |
+
and all(
|
| 290 |
+
row["target_present"] and row["last_present"] and row["no_future_history"]
|
| 291 |
+
for row in report["splits"].values()
|
| 292 |
+
)
|
| 293 |
+
)
|
| 294 |
+
return report
|
code/src/readyagent/p4d_belief/metrics.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Belief, persistence, calibration and offline-search metrics."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
from sklearn.metrics import f1_score, roc_auc_score
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def expected_calibration_error(
|
| 12 |
+
probabilities: np.ndarray, targets: np.ndarray, bins: int = 15
|
| 13 |
+
) -> float:
|
| 14 |
+
confidence = probabilities.max(axis=1)
|
| 15 |
+
correct = probabilities.argmax(axis=1) == targets
|
| 16 |
+
boundaries = np.linspace(0.0, 1.0, bins + 1)
|
| 17 |
+
result = 0.0
|
| 18 |
+
for low, high in zip(boundaries[:-1], boundaries[1:], strict=True):
|
| 19 |
+
selected = (confidence > low) & (confidence <= high)
|
| 20 |
+
if selected.any():
|
| 21 |
+
result += selected.mean() * abs(confidence[selected].mean() - correct[selected].mean())
|
| 22 |
+
return float(result)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def binary_ece(probability: np.ndarray, target: np.ndarray, bins: int = 15) -> float:
|
| 26 |
+
boundaries = np.linspace(0.0, 1.0, bins + 1)
|
| 27 |
+
result = 0.0
|
| 28 |
+
for low, high in zip(boundaries[:-1], boundaries[1:], strict=True):
|
| 29 |
+
selected = (probability > low) & (probability <= high)
|
| 30 |
+
if selected.any():
|
| 31 |
+
result += selected.mean() * abs(probability[selected].mean() - target[selected].mean())
|
| 32 |
+
return float(result)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def evaluate_probabilities(
|
| 36 |
+
probabilities: np.ndarray,
|
| 37 |
+
targets: np.ndarray,
|
| 38 |
+
last_indices: np.ndarray,
|
| 39 |
+
region_instance_ids: tuple[str, ...],
|
| 40 |
+
) -> dict[str, Any]:
|
| 41 |
+
probabilities = np.asarray(probabilities, dtype=np.float64)
|
| 42 |
+
probabilities /= probabilities.sum(axis=1, keepdims=True).clip(min=1e-12)
|
| 43 |
+
target_probability = probabilities[np.arange(len(targets)), targets].clip(min=1e-12)
|
| 44 |
+
ordering = np.argsort(-probabilities, axis=1, kind="stable")
|
| 45 |
+
ranks = np.empty(len(targets), dtype=np.float64)
|
| 46 |
+
reciprocal_ranks = np.empty(len(targets), dtype=np.float64)
|
| 47 |
+
success_at_1 = np.empty(len(targets), dtype=np.float64)
|
| 48 |
+
success_at_3 = np.empty(len(targets), dtype=np.float64)
|
| 49 |
+
success_at_5 = np.empty(len(targets), dtype=np.float64)
|
| 50 |
+
room_checks = np.empty(len(targets), dtype=np.int64)
|
| 51 |
+
for row, order in enumerate(ordering):
|
| 52 |
+
score = probabilities[row, targets[row]]
|
| 53 |
+
greater = int(np.sum(probabilities[row] > score + 1e-12))
|
| 54 |
+
tied = int(np.sum(np.isclose(probabilities[row], score, rtol=0.0, atol=1e-12)))
|
| 55 |
+
first_rank = greater + 1
|
| 56 |
+
last_rank = greater + tied
|
| 57 |
+
ranks[row] = (first_rank + last_rank) / 2.0
|
| 58 |
+
reciprocal_ranks[row] = np.mean(1.0 / np.arange(first_rank, last_rank + 1))
|
| 59 |
+
for cutoff, destination in (
|
| 60 |
+
(1, success_at_1), (3, success_at_3), (5, success_at_5)
|
| 61 |
+
):
|
| 62 |
+
destination[row] = np.clip((cutoff - greater) / max(tied, 1), 0.0, 1.0)
|
| 63 |
+
# Room checks use a documented, stable catalog-order tiebreak because an
|
| 64 |
+
# exact expected number of unique rooms under tied permutations is not
|
| 65 |
+
# represented by the packed benchmark.
|
| 66 |
+
rank = int(np.flatnonzero(order == targets[row])[0]) + 1
|
| 67 |
+
rooms: set[str] = set()
|
| 68 |
+
for candidate in order[:rank]:
|
| 69 |
+
rooms.add(region_instance_ids[int(candidate)])
|
| 70 |
+
room_checks[row] = len(rooms)
|
| 71 |
+
one_hot = np.zeros_like(probabilities)
|
| 72 |
+
one_hot[np.arange(len(targets)), targets] = 1.0
|
| 73 |
+
persist_target = targets == last_indices
|
| 74 |
+
persist_probability = probabilities[np.arange(len(targets)), last_indices]
|
| 75 |
+
predicted_persist = persist_probability >= 0.5
|
| 76 |
+
persistence_auc = (
|
| 77 |
+
float(roc_auc_score(persist_target, persist_probability))
|
| 78 |
+
if len(np.unique(persist_target)) == 2
|
| 79 |
+
else float("nan")
|
| 80 |
+
)
|
| 81 |
+
return {
|
| 82 |
+
"records": int(len(targets)),
|
| 83 |
+
"top1": float(success_at_1.mean()),
|
| 84 |
+
"top3": float(success_at_3.mean()),
|
| 85 |
+
"success_at_5": float(success_at_5.mean()),
|
| 86 |
+
"mrr": float(reciprocal_ranks.mean()),
|
| 87 |
+
"nll": float(-np.log(target_probability).mean()),
|
| 88 |
+
"brier": float(np.square(probabilities - one_hot).sum(axis=1).mean()),
|
| 89 |
+
"ece": expected_calibration_error(probabilities, targets),
|
| 90 |
+
"mean_target_rank": float(ranks.mean()),
|
| 91 |
+
"mean_receptacles_checked": float(ranks.mean()),
|
| 92 |
+
"mean_rooms_checked": float(room_checks.mean()),
|
| 93 |
+
"persistence_accuracy": float(np.mean(predicted_persist == persist_target)),
|
| 94 |
+
"persistence_f1": float(f1_score(persist_target, predicted_persist, zero_division=0)),
|
| 95 |
+
"persistence_auroc": persistence_auc,
|
| 96 |
+
"persistence_brier": float(np.square(persist_probability - persist_target).mean()),
|
| 97 |
+
"persistence_ece": binary_ece(persist_probability, persist_target),
|
| 98 |
+
"mean_persistence_probability": float(persist_probability.mean()),
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def aggregate_seed_metrics(seed_results: list[dict[str, Any]]) -> dict[str, Any]:
|
| 103 |
+
if not seed_results:
|
| 104 |
+
return {}
|
| 105 |
+
aggregate: dict[str, Any] = {"seeds": len(seed_results)}
|
| 106 |
+
for subset in seed_results[0]:
|
| 107 |
+
rows = [result[subset] for result in seed_results if subset in result]
|
| 108 |
+
aggregate[subset] = {}
|
| 109 |
+
for key, value in rows[0].items():
|
| 110 |
+
if isinstance(value, (int, float)) and key != "records":
|
| 111 |
+
values = np.asarray([row[key] for row in rows], dtype=np.float64)
|
| 112 |
+
finite = values[np.isfinite(values)]
|
| 113 |
+
aggregate[subset][key] = {
|
| 114 |
+
"mean": float(finite.mean()) if len(finite) else float("nan"),
|
| 115 |
+
"std": float(finite.std(ddof=1)) if len(finite) > 1 else 0.0,
|
| 116 |
+
}
|
| 117 |
+
elif key == "records":
|
| 118 |
+
aggregate[subset][key] = value
|
| 119 |
+
return aggregate
|
code/src/readyagent/p4d_belief/models.py
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Query-conditioned continuous-time pointer models for P4D-Belief."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import math
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
|
| 8 |
+
import torch
|
| 9 |
+
from torch import nn
|
| 10 |
+
|
| 11 |
+
from readyagent.p4d_belief.data import Catalog
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass(frozen=True)
|
| 15 |
+
class ModelConfig:
|
| 16 |
+
hidden_dim: int = 128
|
| 17 |
+
layers: int = 3
|
| 18 |
+
heads: int = 4
|
| 19 |
+
dropout: float = 0.15
|
| 20 |
+
use_instance_identity: bool = False
|
| 21 |
+
use_compatibility: bool = False
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class SemanticLocationEncoder(nn.Module):
|
| 25 |
+
"""Shared location encoder with no scene-specific state-ID embedding."""
|
| 26 |
+
|
| 27 |
+
def __init__(self, catalog: Catalog, hidden_dim: int) -> None:
|
| 28 |
+
super().__init__()
|
| 29 |
+
region_count = len(catalog.schema["region_category_to_id"])
|
| 30 |
+
receptacle_count = len(catalog.schema["receptacle_category_to_id"])
|
| 31 |
+
semantic_dim = hidden_dim // 4
|
| 32 |
+
self.region_embedding = nn.Embedding(region_count, semantic_dim)
|
| 33 |
+
self.receptacle_embedding = nn.Embedding(receptacle_count, semantic_dim)
|
| 34 |
+
self.project = nn.Sequential(
|
| 35 |
+
nn.Linear(semantic_dim * 2 + 4, hidden_dim),
|
| 36 |
+
nn.GELU(),
|
| 37 |
+
nn.LayerNorm(hidden_dim),
|
| 38 |
+
)
|
| 39 |
+
center = catalog.center_xyz.float()
|
| 40 |
+
known = ~catalog.is_unknown.bool()
|
| 41 |
+
mean = center[known].mean(dim=0)
|
| 42 |
+
std = center[known].std(dim=0).clamp_min(1e-3)
|
| 43 |
+
self.register_buffer("region_category", catalog.region_category.long())
|
| 44 |
+
self.register_buffer("receptacle_category", catalog.receptacle_category.long())
|
| 45 |
+
self.register_buffer("center", center)
|
| 46 |
+
self.register_buffer("center_mean", mean)
|
| 47 |
+
self.register_buffer("center_std", std)
|
| 48 |
+
self.register_buffer("is_unknown", catalog.is_unknown.float())
|
| 49 |
+
|
| 50 |
+
def base_nodes(self) -> torch.Tensor:
|
| 51 |
+
normalized_center = (self.center - self.center_mean) / self.center_std
|
| 52 |
+
return self.project(
|
| 53 |
+
torch.cat(
|
| 54 |
+
(
|
| 55 |
+
self.region_embedding(self.region_category),
|
| 56 |
+
self.receptacle_embedding(self.receptacle_category),
|
| 57 |
+
normalized_center,
|
| 58 |
+
self.is_unknown[:, None],
|
| 59 |
+
),
|
| 60 |
+
dim=-1,
|
| 61 |
+
)
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class RelativeTimeEncoderLayer(nn.Module):
|
| 66 |
+
"""Transformer layer with a learned, per-head continuous-time bias."""
|
| 67 |
+
|
| 68 |
+
def __init__(self, hidden_dim: int, heads: int, dropout: float) -> None:
|
| 69 |
+
super().__init__()
|
| 70 |
+
self.heads = heads
|
| 71 |
+
self.time_bias = nn.Linear(2, heads, bias=False)
|
| 72 |
+
self.attention = nn.MultiheadAttention(
|
| 73 |
+
hidden_dim, heads, dropout=dropout, batch_first=True
|
| 74 |
+
)
|
| 75 |
+
self.norm1 = nn.LayerNorm(hidden_dim)
|
| 76 |
+
self.norm2 = nn.LayerNorm(hidden_dim)
|
| 77 |
+
self.feed_forward = nn.Sequential(
|
| 78 |
+
nn.Linear(hidden_dim, hidden_dim * 4),
|
| 79 |
+
nn.GELU(),
|
| 80 |
+
nn.Dropout(dropout),
|
| 81 |
+
nn.Linear(hidden_dim * 4, hidden_dim),
|
| 82 |
+
nn.Dropout(dropout),
|
| 83 |
+
)
|
| 84 |
+
self.dropout = nn.Dropout(dropout)
|
| 85 |
+
|
| 86 |
+
def forward(
|
| 87 |
+
self, tokens: torch.Tensor, times_days: torch.Tensor, valid: torch.Tensor
|
| 88 |
+
) -> torch.Tensor:
|
| 89 |
+
delta_hours = (times_days[:, :, None] - times_days[:, None, :]) * 24.0
|
| 90 |
+
time_features = torch.stack(
|
| 91 |
+
(torch.sign(delta_hours), torch.log1p(delta_hours.abs())), dim=-1
|
| 92 |
+
)
|
| 93 |
+
bias = self.time_bias(time_features).permute(0, 3, 1, 2)
|
| 94 |
+
key_mask = (~valid)[:, None, None, :]
|
| 95 |
+
bias = bias.masked_fill(key_mask, -1e4)
|
| 96 |
+
batch, length = valid.shape
|
| 97 |
+
attention_mask = bias.reshape(batch * self.heads, length, length)
|
| 98 |
+
attended, _ = self.attention(
|
| 99 |
+
tokens, tokens, tokens, attn_mask=attention_mask, need_weights=False
|
| 100 |
+
)
|
| 101 |
+
tokens = self.norm1(tokens + self.dropout(attended))
|
| 102 |
+
return self.norm2(tokens + self.feed_forward(tokens))
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
class HistoryInputs(nn.Module):
|
| 106 |
+
def __init__(self, catalog: Catalog, config: ModelConfig) -> None:
|
| 107 |
+
super().__init__()
|
| 108 |
+
hidden = config.hidden_dim
|
| 109 |
+
category_count = len(catalog.schema["category_to_id"])
|
| 110 |
+
instance_count = len(catalog.schema.get("instance_uuid_to_id", {}))
|
| 111 |
+
self.location = SemanticLocationEncoder(catalog, hidden)
|
| 112 |
+
self.object_embedding = nn.Embedding(category_count, hidden)
|
| 113 |
+
self.instance_embedding = (
|
| 114 |
+
nn.Embedding(instance_count, hidden)
|
| 115 |
+
if config.use_instance_identity and instance_count
|
| 116 |
+
else None
|
| 117 |
+
)
|
| 118 |
+
self.event_embedding = nn.Embedding(3, hidden)
|
| 119 |
+
self.weekday_embedding = nn.Embedding(7, hidden // 4)
|
| 120 |
+
self.quality_projection = nn.Sequential(
|
| 121 |
+
nn.Linear(6, hidden // 2), nn.GELU(), nn.Linear(hidden // 2, hidden)
|
| 122 |
+
)
|
| 123 |
+
self.time_projection = nn.Linear(5, hidden)
|
| 124 |
+
self.query_projection = nn.Sequential(
|
| 125 |
+
nn.Linear(hidden + hidden // 4 + 4, hidden),
|
| 126 |
+
nn.GELU(),
|
| 127 |
+
nn.LayerNorm(hidden),
|
| 128 |
+
)
|
| 129 |
+
self.history_summary_projection = nn.Sequential(
|
| 130 |
+
nn.Linear(7, hidden), nn.GELU(), nn.Linear(hidden, hidden)
|
| 131 |
+
)
|
| 132 |
+
self.observable_context_projection = nn.Sequential(
|
| 133 |
+
nn.Linear(category_count + 5, hidden),
|
| 134 |
+
nn.GELU(),
|
| 135 |
+
nn.Linear(hidden, hidden),
|
| 136 |
+
)
|
| 137 |
+
self.query_context_norm = nn.LayerNorm(hidden)
|
| 138 |
+
self.candidate_geometry = nn.Sequential(
|
| 139 |
+
nn.Linear(5, hidden), nn.GELU(), nn.LayerNorm(hidden)
|
| 140 |
+
)
|
| 141 |
+
self.token_norm = nn.LayerNorm(hidden)
|
| 142 |
+
|
| 143 |
+
def forward(
|
| 144 |
+
self, batch: dict[str, torch.Tensor]
|
| 145 |
+
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
|
| 146 |
+
base_nodes = self.location.base_nodes()
|
| 147 |
+
event_type = batch["event_type"].long()
|
| 148 |
+
history_mask = batch["history_mask"].bool()
|
| 149 |
+
observed = batch["observed_state_id"].long()
|
| 150 |
+
inspected = batch["candidate_state_id"].long()
|
| 151 |
+
location_id = torch.where(event_type == 1, observed, inspected).clamp_min(0)
|
| 152 |
+
location_token = base_nodes[location_id]
|
| 153 |
+
query_days = batch["query_time_days"].float()
|
| 154 |
+
event_days = batch["event_time_days"].float()
|
| 155 |
+
age_hours = ((query_days[:, None] - event_days) * 24.0).clamp_min(0.0)
|
| 156 |
+
time_features = torch.stack(
|
| 157 |
+
(
|
| 158 |
+
torch.log1p(age_hours),
|
| 159 |
+
torch.sin(2 * math.pi * event_days),
|
| 160 |
+
torch.cos(2 * math.pi * event_days),
|
| 161 |
+
torch.sin(2 * math.pi * event_days / 7.0),
|
| 162 |
+
torch.cos(2 * math.pi * event_days / 7.0),
|
| 163 |
+
),
|
| 164 |
+
dim=-1,
|
| 165 |
+
)
|
| 166 |
+
object_token = self.object_embedding(batch["target_category_id"].long())
|
| 167 |
+
if self.instance_embedding is not None:
|
| 168 |
+
object_token = object_token + self.instance_embedding(
|
| 169 |
+
batch["target_instance_id"].long()
|
| 170 |
+
)
|
| 171 |
+
tokens = self.token_norm(
|
| 172 |
+
location_token
|
| 173 |
+
+ self.event_embedding(event_type)
|
| 174 |
+
+ self.quality_projection(batch["evidence_features"].float())
|
| 175 |
+
+ self.time_projection(time_features)
|
| 176 |
+
+ object_token[:, None, :]
|
| 177 |
+
)
|
| 178 |
+
query_features = torch.cat(
|
| 179 |
+
(
|
| 180 |
+
object_token,
|
| 181 |
+
self.weekday_embedding(batch["query_weekday_id"].long()),
|
| 182 |
+
batch["query_time_of_day_sin_cos"].float(),
|
| 183 |
+
torch.log1p(batch["elapsed_since_last_positive_days"].float())[:, None],
|
| 184 |
+
torch.ones_like(query_days)[:, None],
|
| 185 |
+
),
|
| 186 |
+
dim=-1,
|
| 187 |
+
)
|
| 188 |
+
positive = (event_type == 1) & history_mask
|
| 189 |
+
inspection = (event_type == 2) & history_mask
|
| 190 |
+
positive_count = positive.sum(dim=1).float()
|
| 191 |
+
inspection_count = inspection.sum(dim=1).float()
|
| 192 |
+
state_one_hot = torch.nn.functional.one_hot(
|
| 193 |
+
observed.clamp_min(0), num_classes=base_nodes.shape[0]
|
| 194 |
+
).bool()
|
| 195 |
+
distinct_states = (state_one_hot & positive[:, :, None]).any(dim=1).sum(dim=1).float()
|
| 196 |
+
previous = torch.full(
|
| 197 |
+
(event_type.shape[0],), -1, dtype=torch.long, device=event_type.device
|
| 198 |
+
)
|
| 199 |
+
change_count = torch.zeros_like(positive_count)
|
| 200 |
+
for position in range(event_type.shape[1]):
|
| 201 |
+
current = observed[:, position]
|
| 202 |
+
is_positive = positive[:, position]
|
| 203 |
+
change_count += (
|
| 204 |
+
is_positive & (previous >= 0) & (current != previous)
|
| 205 |
+
).float()
|
| 206 |
+
previous = torch.where(is_positive, current, previous)
|
| 207 |
+
inspected_time = torch.where(
|
| 208 |
+
inspection, event_days, torch.full_like(event_days, -1e9)
|
| 209 |
+
).max(dim=1).values
|
| 210 |
+
time_since_inspection = torch.where(
|
| 211 |
+
inspection.any(dim=1),
|
| 212 |
+
(query_days - inspected_time).clamp_min(0.0),
|
| 213 |
+
batch["elapsed_since_last_positive_days"].float(),
|
| 214 |
+
)
|
| 215 |
+
negative_strength = batch["evidence_features"].float()[:, :, 5]
|
| 216 |
+
negative_sum = (negative_strength * inspection.float()).sum(dim=1)
|
| 217 |
+
negative_mean = negative_sum / inspection_count.clamp_min(1.0)
|
| 218 |
+
negative_max = negative_strength.masked_fill(~inspection, 0.0).max(dim=1).values
|
| 219 |
+
summary = torch.stack(
|
| 220 |
+
(
|
| 221 |
+
torch.log1p(positive_count),
|
| 222 |
+
torch.log1p(inspection_count),
|
| 223 |
+
torch.log1p(distinct_states),
|
| 224 |
+
torch.log1p(change_count),
|
| 225 |
+
torch.log1p(time_since_inspection * 24.0),
|
| 226 |
+
negative_mean,
|
| 227 |
+
negative_max,
|
| 228 |
+
),
|
| 229 |
+
dim=-1,
|
| 230 |
+
)
|
| 231 |
+
last_node = base_nodes[batch["last_state"].long()]
|
| 232 |
+
observable_context = torch.zeros_like(last_node)
|
| 233 |
+
if "context_mask" in batch:
|
| 234 |
+
context_mask = batch["context_mask"].bool()
|
| 235 |
+
context_age_hours = (
|
| 236 |
+
(query_days[:, None] - batch["context_time_days"].float()) * 24.0
|
| 237 |
+
).clamp_min(0.0)
|
| 238 |
+
context_features = torch.cat(
|
| 239 |
+
(
|
| 240 |
+
batch["context_category_counts"].float(),
|
| 241 |
+
batch["context_observation_features"].float(),
|
| 242 |
+
torch.log1p(context_age_hours)[:, :, None],
|
| 243 |
+
),
|
| 244 |
+
dim=-1,
|
| 245 |
+
)
|
| 246 |
+
projected_context = self.observable_context_projection(context_features)
|
| 247 |
+
observable_context = (
|
| 248 |
+
projected_context * context_mask[:, :, None].float()
|
| 249 |
+
).sum(dim=1) / context_mask.sum(dim=1).clamp_min(1)[:, None]
|
| 250 |
+
query_token = self.query_context_norm(
|
| 251 |
+
self.query_projection(query_features)
|
| 252 |
+
+ last_node
|
| 253 |
+
+ self.history_summary_projection(summary)
|
| 254 |
+
+ observable_context
|
| 255 |
+
)
|
| 256 |
+
return tokens, query_token, base_nodes, history_mask
|
| 257 |
+
|
| 258 |
+
def candidate_nodes(
|
| 259 |
+
self, base_nodes: torch.Tensor, batch: dict[str, torch.Tensor]
|
| 260 |
+
) -> torch.Tensor:
|
| 261 |
+
candidate_ids = batch["candidate_state_ids"].long().clamp_min(0)
|
| 262 |
+
nodes = base_nodes[candidate_ids]
|
| 263 |
+
centers = self.location.center[candidate_ids]
|
| 264 |
+
last_center = self.location.center[batch["last_state"].long()]
|
| 265 |
+
relative = (centers - last_center[:, None, :]) / self.location.center_std
|
| 266 |
+
distance = torch.linalg.vector_norm(relative, dim=-1, keepdim=True)
|
| 267 |
+
is_last = (
|
| 268 |
+
candidate_ids == batch["last_state"].long()[:, None]
|
| 269 |
+
).float()[:, :, None]
|
| 270 |
+
# Geometry is added without introducing a scene-specific state embedding.
|
| 271 |
+
geometry = torch.cat((relative, distance, is_last), dim=-1)
|
| 272 |
+
return nodes + self.candidate_geometry(geometry)
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
class CTTransformerBackbone(nn.Module):
|
| 276 |
+
def __init__(self, catalog: Catalog, config: ModelConfig) -> None:
|
| 277 |
+
super().__init__()
|
| 278 |
+
self.inputs = HistoryInputs(catalog, config)
|
| 279 |
+
self.layers = nn.ModuleList(
|
| 280 |
+
RelativeTimeEncoderLayer(config.hidden_dim, config.heads, config.dropout)
|
| 281 |
+
for _ in range(config.layers)
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
def forward(
|
| 285 |
+
self, batch: dict[str, torch.Tensor]
|
| 286 |
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
| 287 |
+
history, query, base_nodes, history_mask = self.inputs(batch)
|
| 288 |
+
tokens = torch.cat((history, query[:, None, :]), dim=1)
|
| 289 |
+
query_valid = torch.ones(
|
| 290 |
+
(history_mask.shape[0], 1), dtype=torch.bool, device=history_mask.device
|
| 291 |
+
)
|
| 292 |
+
valid = torch.cat((history_mask, query_valid), dim=1)
|
| 293 |
+
times = torch.cat(
|
| 294 |
+
(batch["event_time_days"].float(), batch["query_time_days"].float()[:, None]),
|
| 295 |
+
dim=1,
|
| 296 |
+
)
|
| 297 |
+
for layer in self.layers:
|
| 298 |
+
tokens = layer(tokens, times, valid)
|
| 299 |
+
candidates = self.inputs.candidate_nodes(base_nodes, batch)
|
| 300 |
+
return tokens[:, -1], candidates
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
class GRUBackbone(nn.Module):
|
| 304 |
+
def __init__(self, catalog: Catalog, config: ModelConfig) -> None:
|
| 305 |
+
super().__init__()
|
| 306 |
+
self.inputs = HistoryInputs(catalog, config)
|
| 307 |
+
self.gru = nn.GRU(
|
| 308 |
+
config.hidden_dim,
|
| 309 |
+
config.hidden_dim,
|
| 310 |
+
num_layers=2,
|
| 311 |
+
dropout=config.dropout,
|
| 312 |
+
batch_first=True,
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
def forward(
|
| 316 |
+
self, batch: dict[str, torch.Tensor]
|
| 317 |
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
| 318 |
+
history, query, base_nodes, history_mask = self.inputs(batch)
|
| 319 |
+
lengths = history_mask.sum(dim=1).long()
|
| 320 |
+
sequence = torch.zeros(
|
| 321 |
+
history.shape[0], history.shape[1] + 1, history.shape[2],
|
| 322 |
+
device=history.device, dtype=history.dtype,
|
| 323 |
+
)
|
| 324 |
+
sequence[:, : history.shape[1]] = history
|
| 325 |
+
sequence[torch.arange(len(lengths), device=history.device), lengths] = query
|
| 326 |
+
packed = nn.utils.rnn.pack_padded_sequence(
|
| 327 |
+
sequence, (lengths + 1).cpu(), batch_first=True, enforce_sorted=False
|
| 328 |
+
)
|
| 329 |
+
_, hidden = self.gru(packed)
|
| 330 |
+
candidates = self.inputs.candidate_nodes(base_nodes, batch)
|
| 331 |
+
return hidden[-1], candidates
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
class PointerBase(nn.Module):
|
| 335 |
+
def __init__(self, backbone: nn.Module, hidden_dim: int) -> None:
|
| 336 |
+
super().__init__()
|
| 337 |
+
self.backbone = backbone
|
| 338 |
+
self.query_pointer = nn.Linear(hidden_dim, hidden_dim, bias=False)
|
| 339 |
+
self.candidate_pointer = nn.Linear(hidden_dim, hidden_dim, bias=False)
|
| 340 |
+
self.scale = hidden_dim**-0.5
|
| 341 |
+
|
| 342 |
+
def pointer_logits(
|
| 343 |
+
self, query: torch.Tensor, candidates: torch.Tensor, mask: torch.Tensor
|
| 344 |
+
) -> torch.Tensor:
|
| 345 |
+
logits = torch.einsum(
|
| 346 |
+
"bd,bmd->bm", self.query_pointer(query), self.candidate_pointer(candidates)
|
| 347 |
+
) * self.scale
|
| 348 |
+
return logits.masked_fill(~mask.bool(), -1e4)
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
class DirectPointer(PointerBase):
|
| 352 |
+
"""Direct softmax comparator using the same CT Transformer and pointer."""
|
| 353 |
+
|
| 354 |
+
def __init__(self, catalog: Catalog, config: ModelConfig) -> None:
|
| 355 |
+
super().__init__(CTTransformerBackbone(catalog, config), config.hidden_dim)
|
| 356 |
+
|
| 357 |
+
def forward(self, batch: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
|
| 358 |
+
query, candidates = self.backbone(batch)
|
| 359 |
+
logits = self.pointer_logits(query, candidates, batch["candidate_mask"])
|
| 360 |
+
probabilities = torch.softmax(logits, dim=-1)
|
| 361 |
+
last_probability = probabilities.gather(
|
| 362 |
+
1, batch["last_candidate_index"].long()[:, None]
|
| 363 |
+
).squeeze(1)
|
| 364 |
+
return {"probabilities": probabilities, "rho": last_probability, "logits": logits}
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
class GRUDirectPointer(PointerBase):
|
| 368 |
+
def __init__(self, catalog: Catalog, config: ModelConfig) -> None:
|
| 369 |
+
super().__init__(GRUBackbone(catalog, config), config.hidden_dim)
|
| 370 |
+
|
| 371 |
+
def forward(self, batch: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
|
| 372 |
+
query, candidates = self.backbone(batch)
|
| 373 |
+
logits = self.pointer_logits(query, candidates, batch["candidate_mask"])
|
| 374 |
+
probabilities = torch.softmax(logits, dim=-1)
|
| 375 |
+
rho = probabilities.gather(
|
| 376 |
+
1, batch["last_candidate_index"].long()[:, None]
|
| 377 |
+
).squeeze(1)
|
| 378 |
+
return {"probabilities": probabilities, "rho": rho, "logits": logits}
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
class P4DBelief(PointerBase):
|
| 382 |
+
"""Persistence-relocation factorized pointer with a single final-state NLL."""
|
| 383 |
+
|
| 384 |
+
def __init__(self, catalog: Catalog, config: ModelConfig) -> None:
|
| 385 |
+
super().__init__(CTTransformerBackbone(catalog, config), config.hidden_dim)
|
| 386 |
+
self.persistence = nn.Linear(config.hidden_dim, 1)
|
| 387 |
+
self.compatibility = (
|
| 388 |
+
nn.Sequential(
|
| 389 |
+
nn.Linear(config.hidden_dim * 2, config.hidden_dim),
|
| 390 |
+
nn.GELU(),
|
| 391 |
+
nn.Linear(config.hidden_dim, 1),
|
| 392 |
+
)
|
| 393 |
+
if config.use_compatibility else None
|
| 394 |
+
)
|
| 395 |
+
|
| 396 |
+
def forward(self, batch: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
|
| 397 |
+
query, candidates = self.backbone(batch)
|
| 398 |
+
rho = torch.sigmoid(self.persistence(query)).squeeze(-1)
|
| 399 |
+
relocation_mask = batch["candidate_mask"].bool().clone()
|
| 400 |
+
relocation_mask.scatter_(
|
| 401 |
+
1, batch["last_candidate_index"].long()[:, None], False
|
| 402 |
+
)
|
| 403 |
+
relocation_logits = self.pointer_logits(query, candidates, relocation_mask)
|
| 404 |
+
if self.compatibility is not None:
|
| 405 |
+
paired = torch.cat(
|
| 406 |
+
(query[:, None, :].expand_as(candidates), candidates), dim=-1
|
| 407 |
+
)
|
| 408 |
+
relocation_logits = relocation_logits + self.compatibility(paired).squeeze(-1)
|
| 409 |
+
relocation_logits = relocation_logits.masked_fill(~relocation_mask, -1e4)
|
| 410 |
+
relocation = torch.softmax(relocation_logits, dim=-1)
|
| 411 |
+
probabilities = relocation * (1.0 - rho[:, None])
|
| 412 |
+
probabilities.scatter_(
|
| 413 |
+
1, batch["last_candidate_index"].long()[:, None], rho[:, None]
|
| 414 |
+
)
|
| 415 |
+
probabilities = probabilities * batch["candidate_mask"].float()
|
| 416 |
+
return {
|
| 417 |
+
"probabilities": probabilities,
|
| 418 |
+
"rho": rho,
|
| 419 |
+
"relocation_probabilities": relocation,
|
| 420 |
+
"relocation_mask": relocation_mask,
|
| 421 |
+
"context": query,
|
| 422 |
+
"candidates": candidates,
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
def state_nll(probabilities: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
|
| 427 |
+
selected = probabilities.gather(1, targets.long()[:, None]).squeeze(1)
|
| 428 |
+
return -torch.log(selected.clamp_min(1e-9)).mean()
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
def bayesian_update(
|
| 432 |
+
prior: torch.Tensor,
|
| 433 |
+
checked_candidate: torch.Tensor,
|
| 434 |
+
*,
|
| 435 |
+
observed: torch.Tensor,
|
| 436 |
+
confidence: torch.Tensor,
|
| 437 |
+
coverage: torch.Tensor,
|
| 438 |
+
) -> torch.Tensor:
|
| 439 |
+
"""Apply one parameter-free visibility-aware observation update."""
|
| 440 |
+
|
| 441 |
+
likelihood = torch.ones_like(prior)
|
| 442 |
+
strength = (confidence * coverage).clamp(0.0, 1.0)
|
| 443 |
+
rows = torch.arange(prior.shape[0], device=prior.device)
|
| 444 |
+
not_seen_likelihood = (1.0 - strength).clamp_min(1e-6)
|
| 445 |
+
likelihood[rows, checked_candidate.long()] = torch.where(
|
| 446 |
+
observed.bool(),
|
| 447 |
+
torch.ones_like(strength),
|
| 448 |
+
not_seen_likelihood,
|
| 449 |
+
)
|
| 450 |
+
if observed.any():
|
| 451 |
+
detected_rows = rows[observed.bool()]
|
| 452 |
+
likelihood[detected_rows] = 1e-6
|
| 453 |
+
likelihood[detected_rows, checked_candidate[observed.bool()].long()] = strength[
|
| 454 |
+
observed.bool()
|
| 455 |
+
].clamp_min(1e-6)
|
| 456 |
+
posterior = prior * likelihood
|
| 457 |
+
return posterior / posterior.sum(dim=-1, keepdim=True).clamp_min(1e-12)
|
code/src/readyagent/p4d_belief/training.py
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Training, inference and experiment orchestration utilities."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import copy
|
| 6 |
+
import json
|
| 7 |
+
import random
|
| 8 |
+
import time
|
| 9 |
+
from dataclasses import asdict, dataclass
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
import torch
|
| 15 |
+
from torch.utils.data import DataLoader, WeightedRandomSampler
|
| 16 |
+
|
| 17 |
+
from readyagent.p4d_belief.baselines import ClassicalBaselines
|
| 18 |
+
from readyagent.p4d_belief.data import (
|
| 19 |
+
Catalog,
|
| 20 |
+
PackedQueries,
|
| 21 |
+
derive_last_state,
|
| 22 |
+
load_arrays,
|
| 23 |
+
subset_indices,
|
| 24 |
+
)
|
| 25 |
+
from readyagent.p4d_belief.metrics import aggregate_seed_metrics, evaluate_probabilities
|
| 26 |
+
from readyagent.p4d_belief.models import (
|
| 27 |
+
DirectPointer,
|
| 28 |
+
GRUDirectPointer,
|
| 29 |
+
ModelConfig,
|
| 30 |
+
P4DBelief,
|
| 31 |
+
bayesian_update,
|
| 32 |
+
state_nll,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@dataclass(frozen=True)
|
| 37 |
+
class TrainConfig:
|
| 38 |
+
batch_size: int = 32
|
| 39 |
+
eval_batch_size: int = 256
|
| 40 |
+
learning_rate: float = 3e-4
|
| 41 |
+
weight_decay: float = 1e-4
|
| 42 |
+
max_epochs: int = 100
|
| 43 |
+
patience: int = 12
|
| 44 |
+
gradient_clip: float = 1.0
|
| 45 |
+
num_workers: int = 0
|
| 46 |
+
routine_train_fraction: float = 0.5
|
| 47 |
+
validation_subset: str = "main"
|
| 48 |
+
unknown_train_boost: float = 1.0
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
EVALUATION_SUBSETS = {
|
| 52 |
+
"routine_all": ("routine", "all"),
|
| 53 |
+
"routine_exact": ("routine", "exact"),
|
| 54 |
+
"routine_fallback": ("routine", "fallback"),
|
| 55 |
+
"random": ("random", "all"),
|
| 56 |
+
"static": ("static", "all"),
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
SPECIAL_EVALUATION_SUBSETS = (
|
| 60 |
+
"routine_unknown",
|
| 61 |
+
"routine_returned",
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def evaluation_subset_indices(
|
| 66 |
+
arrays: dict[str, np.ndarray], subset_name: str
|
| 67 |
+
) -> np.ndarray:
|
| 68 |
+
if subset_name in EVALUATION_SUBSETS:
|
| 69 |
+
world, quality = EVALUATION_SUBSETS[subset_name]
|
| 70 |
+
return subset_indices(arrays, world=world, quality=quality)
|
| 71 |
+
routine = arrays["meta_world_variant_id"] == 0
|
| 72 |
+
if subset_name == "routine_unknown":
|
| 73 |
+
unknown_candidates = np.flatnonzero(arrays["candidate_is_unknown"])
|
| 74 |
+
if not len(unknown_candidates):
|
| 75 |
+
return np.empty(0, dtype=np.int64)
|
| 76 |
+
return np.flatnonzero(
|
| 77 |
+
routine & (arrays["y_current_state"] == int(unknown_candidates[0]))
|
| 78 |
+
)
|
| 79 |
+
if subset_name == "routine_returned":
|
| 80 |
+
if "y_returned_to_last" not in arrays:
|
| 81 |
+
return np.empty(0, dtype=np.int64)
|
| 82 |
+
return np.flatnonzero(routine & arrays["y_returned_to_last"].astype(bool))
|
| 83 |
+
raise ValueError(f"unknown evaluation subset: {subset_name}")
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def seed_everything(seed: int) -> None:
|
| 87 |
+
random.seed(seed)
|
| 88 |
+
np.random.seed(seed)
|
| 89 |
+
torch.manual_seed(seed)
|
| 90 |
+
torch.cuda.manual_seed_all(seed)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def move_batch(batch: dict[str, torch.Tensor], device: torch.device) -> dict[str, torch.Tensor]:
|
| 94 |
+
return {key: value.to(device, non_blocking=True) for key, value in batch.items()}
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def build_model(name: str, catalog: Catalog, config: ModelConfig) -> torch.nn.Module:
|
| 98 |
+
if name == "p4d":
|
| 99 |
+
return P4DBelief(catalog, config)
|
| 100 |
+
if name == "direct":
|
| 101 |
+
return DirectPointer(catalog, config)
|
| 102 |
+
if name == "gru":
|
| 103 |
+
return GRUDirectPointer(catalog, config)
|
| 104 |
+
raise ValueError(name)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def main_training_indices(
|
| 108 |
+
arrays: dict[str, np.ndarray],
|
| 109 |
+
routine_fraction: float = 0.5,
|
| 110 |
+
unknown_boost: float = 1.0,
|
| 111 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 112 |
+
if not 0.0 <= routine_fraction <= 1.0:
|
| 113 |
+
raise ValueError("routine_fraction must be in [0, 1]")
|
| 114 |
+
if unknown_boost < 1.0:
|
| 115 |
+
raise ValueError("unknown_boost must be >= 1")
|
| 116 |
+
indices = subset_indices(arrays, world="main")
|
| 117 |
+
world = arrays["meta_world_variant_id"][indices]
|
| 118 |
+
routine = world == 0
|
| 119 |
+
weights = np.zeros(len(indices), dtype=np.float64)
|
| 120 |
+
if routine_fraction > 0.0:
|
| 121 |
+
routine_weights = np.ones(int(routine.sum()), dtype=np.float64)
|
| 122 |
+
unknown_candidates = np.flatnonzero(arrays["candidate_is_unknown"])
|
| 123 |
+
if len(unknown_candidates):
|
| 124 |
+
routine_targets = arrays["y_current_state"][indices[routine]]
|
| 125 |
+
routine_weights[routine_targets == int(unknown_candidates[0])] *= unknown_boost
|
| 126 |
+
weights[routine] = routine_fraction * routine_weights / routine_weights.sum()
|
| 127 |
+
if routine_fraction < 1.0:
|
| 128 |
+
weights[~routine] = (1.0 - routine_fraction) / (~routine).sum()
|
| 129 |
+
return indices, weights.astype(np.float64)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def validation_indices(
|
| 133 |
+
arrays: dict[str, np.ndarray], validation_subset: str
|
| 134 |
+
) -> np.ndarray:
|
| 135 |
+
if validation_subset == "main":
|
| 136 |
+
return subset_indices(arrays, world="main")
|
| 137 |
+
if validation_subset in (*EVALUATION_SUBSETS, *SPECIAL_EVALUATION_SUBSETS):
|
| 138 |
+
return evaluation_subset_indices(arrays, validation_subset)
|
| 139 |
+
raise ValueError(f"unknown validation subset: {validation_subset}")
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
@torch.no_grad()
|
| 143 |
+
def validation_nll(
|
| 144 |
+
model: torch.nn.Module, loader: DataLoader, device: torch.device
|
| 145 |
+
) -> float:
|
| 146 |
+
model.eval()
|
| 147 |
+
total_loss = 0.0
|
| 148 |
+
records = 0
|
| 149 |
+
for raw_batch in loader:
|
| 150 |
+
batch = move_batch(raw_batch, device)
|
| 151 |
+
probabilities = model(batch)["probabilities"]
|
| 152 |
+
loss = state_nll(probabilities, batch["target_candidate_index"])
|
| 153 |
+
total_loss += float(loss) * len(probabilities)
|
| 154 |
+
records += len(probabilities)
|
| 155 |
+
return total_loss / max(records, 1)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def train_model(
|
| 159 |
+
*,
|
| 160 |
+
name: str,
|
| 161 |
+
seed: int,
|
| 162 |
+
dataset_root: Path,
|
| 163 |
+
catalog: Catalog,
|
| 164 |
+
model_config: ModelConfig,
|
| 165 |
+
train_config: TrainConfig,
|
| 166 |
+
device: torch.device,
|
| 167 |
+
output_dir: Path,
|
| 168 |
+
) -> tuple[torch.nn.Module, dict[str, Any]]:
|
| 169 |
+
seed_everything(seed)
|
| 170 |
+
train_path = dataset_root / "records/packed/train.npz"
|
| 171 |
+
val_path = dataset_root / "records/packed/val.npz"
|
| 172 |
+
train_arrays = load_arrays(dataset_root, "train")
|
| 173 |
+
train_indices, weights = main_training_indices(
|
| 174 |
+
train_arrays,
|
| 175 |
+
train_config.routine_train_fraction,
|
| 176 |
+
train_config.unknown_train_boost,
|
| 177 |
+
)
|
| 178 |
+
val_arrays = load_arrays(dataset_root, "val")
|
| 179 |
+
val_indices = validation_indices(val_arrays, train_config.validation_subset)
|
| 180 |
+
train_dataset = PackedQueries(train_path, train_indices)
|
| 181 |
+
val_dataset = PackedQueries(val_path, val_indices)
|
| 182 |
+
generator = torch.Generator().manual_seed(seed)
|
| 183 |
+
sampler = WeightedRandomSampler(
|
| 184 |
+
torch.as_tensor(weights, dtype=torch.double),
|
| 185 |
+
num_samples=len(train_indices),
|
| 186 |
+
replacement=True,
|
| 187 |
+
generator=generator,
|
| 188 |
+
)
|
| 189 |
+
train_loader = DataLoader(
|
| 190 |
+
train_dataset,
|
| 191 |
+
batch_size=train_config.batch_size,
|
| 192 |
+
sampler=sampler,
|
| 193 |
+
num_workers=train_config.num_workers,
|
| 194 |
+
pin_memory=device.type == "cuda",
|
| 195 |
+
)
|
| 196 |
+
val_loader = DataLoader(
|
| 197 |
+
val_dataset,
|
| 198 |
+
batch_size=train_config.eval_batch_size,
|
| 199 |
+
shuffle=False,
|
| 200 |
+
num_workers=train_config.num_workers,
|
| 201 |
+
pin_memory=device.type == "cuda",
|
| 202 |
+
)
|
| 203 |
+
model = build_model(name, catalog, model_config).to(device)
|
| 204 |
+
optimizer = torch.optim.AdamW(
|
| 205 |
+
model.parameters(), lr=train_config.learning_rate, weight_decay=train_config.weight_decay
|
| 206 |
+
)
|
| 207 |
+
best_state: dict[str, torch.Tensor] | None = None
|
| 208 |
+
best_val = float("inf")
|
| 209 |
+
best_epoch = -1
|
| 210 |
+
history: list[dict[str, float]] = []
|
| 211 |
+
stale = 0
|
| 212 |
+
start = time.time()
|
| 213 |
+
for epoch in range(train_config.max_epochs):
|
| 214 |
+
model.train()
|
| 215 |
+
running = 0.0
|
| 216 |
+
seen = 0
|
| 217 |
+
for raw_batch in train_loader:
|
| 218 |
+
batch = move_batch(raw_batch, device)
|
| 219 |
+
optimizer.zero_grad(set_to_none=True)
|
| 220 |
+
probabilities = model(batch)["probabilities"]
|
| 221 |
+
loss = state_nll(probabilities, batch["target_candidate_index"])
|
| 222 |
+
if not torch.isfinite(loss):
|
| 223 |
+
raise RuntimeError(f"non-finite training loss for {name}, seed {seed}")
|
| 224 |
+
loss.backward()
|
| 225 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), train_config.gradient_clip)
|
| 226 |
+
optimizer.step()
|
| 227 |
+
running += float(loss.detach()) * len(probabilities)
|
| 228 |
+
seen += len(probabilities)
|
| 229 |
+
train_nll = running / seen
|
| 230 |
+
val_nll = validation_nll(model, val_loader, device)
|
| 231 |
+
history.append({"epoch": epoch + 1, "train_nll": train_nll, "val_nll": val_nll})
|
| 232 |
+
if val_nll < best_val - 1e-5:
|
| 233 |
+
best_val = val_nll
|
| 234 |
+
best_epoch = epoch + 1
|
| 235 |
+
best_state = copy.deepcopy(model.state_dict())
|
| 236 |
+
stale = 0
|
| 237 |
+
else:
|
| 238 |
+
stale += 1
|
| 239 |
+
if stale >= train_config.patience:
|
| 240 |
+
break
|
| 241 |
+
if best_state is None:
|
| 242 |
+
raise RuntimeError("training did not produce a checkpoint")
|
| 243 |
+
model.load_state_dict(best_state)
|
| 244 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 245 |
+
checkpoint_path = output_dir / "best.pt"
|
| 246 |
+
torch.save(
|
| 247 |
+
{
|
| 248 |
+
"model": best_state,
|
| 249 |
+
"model_name": name,
|
| 250 |
+
"seed": seed,
|
| 251 |
+
"model_config": asdict(model_config),
|
| 252 |
+
"train_config": asdict(train_config),
|
| 253 |
+
"best_epoch": best_epoch,
|
| 254 |
+
"best_val_nll": best_val,
|
| 255 |
+
},
|
| 256 |
+
checkpoint_path,
|
| 257 |
+
)
|
| 258 |
+
training_result = {
|
| 259 |
+
"model": name,
|
| 260 |
+
"seed": seed,
|
| 261 |
+
"parameters": sum(parameter.numel() for parameter in model.parameters()),
|
| 262 |
+
"best_epoch": best_epoch,
|
| 263 |
+
"best_val_nll": best_val,
|
| 264 |
+
"epochs_run": len(history),
|
| 265 |
+
"wall_seconds": time.time() - start,
|
| 266 |
+
"checkpoint": str(checkpoint_path),
|
| 267 |
+
"history": history,
|
| 268 |
+
}
|
| 269 |
+
(output_dir / "training.json").write_text(
|
| 270 |
+
json.dumps(training_result, ensure_ascii=False, indent=2), encoding="utf-8"
|
| 271 |
+
)
|
| 272 |
+
return model, training_result
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
@torch.no_grad()
|
| 276 |
+
def predict_model(
|
| 277 |
+
model: torch.nn.Module,
|
| 278 |
+
dataset: PackedQueries,
|
| 279 |
+
*,
|
| 280 |
+
batch_size: int,
|
| 281 |
+
device: torch.device,
|
| 282 |
+
hold_out_last_inspection: bool = False,
|
| 283 |
+
apply_update: bool = False,
|
| 284 |
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
| 285 |
+
loader = DataLoader(dataset, batch_size=batch_size, shuffle=False, pin_memory=device.type == "cuda")
|
| 286 |
+
model.eval()
|
| 287 |
+
probabilities: list[np.ndarray] = []
|
| 288 |
+
targets: list[np.ndarray] = []
|
| 289 |
+
lasts: list[np.ndarray] = []
|
| 290 |
+
for raw_batch in loader:
|
| 291 |
+
batch = move_batch(raw_batch, device)
|
| 292 |
+
checked: torch.Tensor | None = None
|
| 293 |
+
strength: torch.Tensor | None = None
|
| 294 |
+
coverage: torch.Tensor | None = None
|
| 295 |
+
eligible: torch.Tensor | None = None
|
| 296 |
+
if hold_out_last_inspection:
|
| 297 |
+
inspection = (batch["event_type"] == 2) & batch["history_mask"].bool()
|
| 298 |
+
positions = torch.where(
|
| 299 |
+
inspection,
|
| 300 |
+
torch.arange(inspection.shape[1], device=device)[None, :],
|
| 301 |
+
-1,
|
| 302 |
+
)
|
| 303 |
+
held_index = positions.max(dim=1).values
|
| 304 |
+
eligible = held_index >= 0
|
| 305 |
+
safe_index = held_index.clamp_min(0)
|
| 306 |
+
rows = torch.arange(len(held_index), device=device)
|
| 307 |
+
checked = batch["candidate_state_id"][rows, safe_index].long().clamp_min(0)
|
| 308 |
+
evidence = batch["evidence_features"][rows, safe_index].float()
|
| 309 |
+
coverage = (evidence[:, 3] * evidence[:, 4]).clamp(0.0, 1.0)
|
| 310 |
+
strength = evidence[:, 5].clamp(0.0, 1.0)
|
| 311 |
+
batch["history_mask"] = batch["history_mask"].clone()
|
| 312 |
+
batch["history_mask"][rows[eligible], held_index[eligible]] = False
|
| 313 |
+
output = model(batch)
|
| 314 |
+
belief = output["probabilities"]
|
| 315 |
+
if apply_update and checked is not None and eligible is not None:
|
| 316 |
+
updated = bayesian_update(
|
| 317 |
+
belief,
|
| 318 |
+
checked,
|
| 319 |
+
observed=torch.zeros_like(eligible),
|
| 320 |
+
confidence=strength,
|
| 321 |
+
coverage=coverage,
|
| 322 |
+
)
|
| 323 |
+
belief = torch.where(eligible[:, None], updated, belief)
|
| 324 |
+
probabilities.append(belief.cpu().numpy())
|
| 325 |
+
targets.append(batch["target_candidate_index"].cpu().numpy())
|
| 326 |
+
lasts.append(batch["last_candidate_index"].cpu().numpy())
|
| 327 |
+
return np.concatenate(probabilities), np.concatenate(targets), np.concatenate(lasts)
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def evaluate_model(
|
| 331 |
+
model: torch.nn.Module,
|
| 332 |
+
*,
|
| 333 |
+
dataset_root: Path,
|
| 334 |
+
catalog: Catalog,
|
| 335 |
+
split: str,
|
| 336 |
+
device: torch.device,
|
| 337 |
+
batch_size: int,
|
| 338 |
+
include_update: bool,
|
| 339 |
+
) -> dict[str, dict[str, Any]]:
|
| 340 |
+
packed_path = dataset_root / "records/packed" / f"{split}.npz"
|
| 341 |
+
arrays = load_arrays(dataset_root, split)
|
| 342 |
+
result: dict[str, dict[str, Any]] = {}
|
| 343 |
+
for subset_name in (*EVALUATION_SUBSETS, *SPECIAL_EVALUATION_SUBSETS):
|
| 344 |
+
indices = evaluation_subset_indices(arrays, subset_name)
|
| 345 |
+
if not len(indices):
|
| 346 |
+
continue
|
| 347 |
+
dataset = PackedQueries(packed_path, indices)
|
| 348 |
+
probabilities, targets, lasts = predict_model(
|
| 349 |
+
model, dataset, batch_size=batch_size, device=device
|
| 350 |
+
)
|
| 351 |
+
result[subset_name] = evaluate_probabilities(
|
| 352 |
+
probabilities, targets, lasts, catalog.region_instance_ids
|
| 353 |
+
)
|
| 354 |
+
if include_update:
|
| 355 |
+
held_prior, held_targets, held_lasts = predict_model(
|
| 356 |
+
model,
|
| 357 |
+
dataset,
|
| 358 |
+
batch_size=batch_size,
|
| 359 |
+
device=device,
|
| 360 |
+
hold_out_last_inspection=True,
|
| 361 |
+
apply_update=False,
|
| 362 |
+
)
|
| 363 |
+
updated, _, _ = predict_model(
|
| 364 |
+
model,
|
| 365 |
+
dataset,
|
| 366 |
+
batch_size=batch_size,
|
| 367 |
+
device=device,
|
| 368 |
+
hold_out_last_inspection=True,
|
| 369 |
+
apply_update=True,
|
| 370 |
+
)
|
| 371 |
+
result[f"{subset_name}_heldout_prior"] = evaluate_probabilities(
|
| 372 |
+
held_prior, held_targets, held_lasts, catalog.region_instance_ids
|
| 373 |
+
)
|
| 374 |
+
result[f"{subset_name}_bayes_update"] = evaluate_probabilities(
|
| 375 |
+
updated, held_targets, held_lasts, catalog.region_instance_ids
|
| 376 |
+
)
|
| 377 |
+
return result
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
def evaluate_classical_baselines(
|
| 381 |
+
dataset_root: Path, catalog: Catalog, split: str = "test"
|
| 382 |
+
) -> dict[str, dict[str, dict[str, Any]]]:
|
| 383 |
+
train = load_arrays(dataset_root, "train")
|
| 384 |
+
train_indices = subset_indices(train, world="main")
|
| 385 |
+
train_last = derive_last_state(train)
|
| 386 |
+
baseline = ClassicalBaselines(
|
| 387 |
+
state_count=catalog.state_count,
|
| 388 |
+
category_count=len(catalog.schema["category_to_id"]),
|
| 389 |
+
)
|
| 390 |
+
baseline.fit(
|
| 391 |
+
target_category=train["target_category_id"][train_indices].astype(np.int64),
|
| 392 |
+
target_state=train["y_current_state"][train_indices].astype(np.int64),
|
| 393 |
+
last_state=train_last[train_indices],
|
| 394 |
+
weekday=train["query_weekday_id"][train_indices].astype(np.int64),
|
| 395 |
+
time_sin_cos=train["query_time_of_day_sin_cos"][train_indices],
|
| 396 |
+
)
|
| 397 |
+
test = load_arrays(dataset_root, split)
|
| 398 |
+
test_last = derive_last_state(test)
|
| 399 |
+
all_results: dict[str, dict[str, dict[str, Any]]] = {}
|
| 400 |
+
for method in ("last_seen", "object_frequency", "time_frequency", "markov"):
|
| 401 |
+
method_results: dict[str, dict[str, Any]] = {}
|
| 402 |
+
for subset_name in (*EVALUATION_SUBSETS, *SPECIAL_EVALUATION_SUBSETS):
|
| 403 |
+
indices = evaluation_subset_indices(test, subset_name)
|
| 404 |
+
if not len(indices):
|
| 405 |
+
continue
|
| 406 |
+
probabilities = baseline.predict(
|
| 407 |
+
method,
|
| 408 |
+
target_category=test["target_category_id"][indices].astype(np.int64),
|
| 409 |
+
last_state=test_last[indices],
|
| 410 |
+
weekday=test["query_weekday_id"][indices].astype(np.int64),
|
| 411 |
+
time_sin_cos=test["query_time_of_day_sin_cos"][indices],
|
| 412 |
+
)
|
| 413 |
+
targets = test["y_current_state"][indices].astype(np.int64)
|
| 414 |
+
method_results[subset_name] = evaluate_probabilities(
|
| 415 |
+
probabilities, targets, test_last[indices], catalog.region_instance_ids
|
| 416 |
+
)
|
| 417 |
+
all_results[method] = method_results
|
| 418 |
+
return all_results
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
def summarize_neural(seed_results: dict[str, list[dict[str, Any]]]) -> dict[str, Any]:
|
| 422 |
+
return {name: aggregate_seed_metrics(results) for name, results in seed_results.items()}
|
code/tests/test_agent_core.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
import pytest
|
| 5 |
+
|
| 6 |
+
from evolvingnav_paper.agent import Agent, AgentConfig, ViewEvidence
|
| 7 |
+
from evolvingnav_paper.filter import BeliefFilter, EvidenceLedger
|
| 8 |
+
from evolvingnav_paper.memory import VersionedMemory, backproject
|
| 9 |
+
from evolvingnav_paper.transition import IdentityTransition, MatrixTransition
|
| 10 |
+
from evolvingnav_paper.coverage import (
|
| 11 |
+
camera_forward, candidate_surface_samples, depth_quality, heading_quaternion,
|
| 12 |
+
visible_sample_ids,
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def test_backprojection_and_causal_versions() -> None:
|
| 17 |
+
point = backproject(1, 1, 2.0, np.diag([2., 2., 1.]), np.eye(4))
|
| 18 |
+
np.testing.assert_allclose(point, [1., 1., 2.])
|
| 19 |
+
memory = VersionedMemory()
|
| 20 |
+
memory.observe("cup", 2, 4.0, 0.8, "frame-1", point)
|
| 21 |
+
memory.observe("cup", 2, 8.0, 0.95, "frame-later", point)
|
| 22 |
+
memory.observe("cup", 3, 9.0, 0.9, "frame-2", point)
|
| 23 |
+
assert memory.at("cup", 7.0).state_id == 2
|
| 24 |
+
assert memory.at("cup", 7.0).valid_to is None
|
| 25 |
+
assert memory.at("cup", 7.0).evidence_handles == ["frame-1"]
|
| 26 |
+
assert memory.at("cup", 7.0).confidence == 0.8
|
| 27 |
+
assert memory.at("cup", 10.0).state_id == 3
|
| 28 |
+
assert memory.history("cup", 10.0)[0].valid_to == 9.0
|
| 29 |
+
memory.record_negative("frame-neg", 2, 10.5, [1, 2, 3])
|
| 30 |
+
assert memory.evidence_at(10.0) == []
|
| 31 |
+
assert memory.evidence_at(11.0)[0].state_id == 2
|
| 32 |
+
with pytest.raises(ValueError, match="causal"):
|
| 33 |
+
memory.observe("cup", 1, 8.0, 0.8, "old", point)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_filter_propagation_then_new_evidence_once_and_arrival_independent() -> None:
|
| 37 |
+
kernel = MatrixTransition(np.array([[0.8, 0.2], [0.1, 0.9]]))
|
| 38 |
+
belief = BeliefFilter({1: 0.8, 2: 0.2}, kernel)
|
| 39 |
+
arrival = belief.arrival(5.0)
|
| 40 |
+
assert arrival[2] > 0.2
|
| 41 |
+
assert belief.posterior == {1: 0.8, 2: 0.2}
|
| 42 |
+
belief.advance(5.0)
|
| 43 |
+
assert belief.posterior[2] == pytest.approx(arrival[2])
|
| 44 |
+
evidence = ViewEvidence("frame-1", 1, frozenset({1, 2, 3, 4}), 0.8)
|
| 45 |
+
ledger = EvidenceLedger(min_new_coverage=0.05, sample_count={1: 10, 2: 10})
|
| 46 |
+
admitted = ledger.admit(evidence)
|
| 47 |
+
assert admitted == pytest.approx(0.4)
|
| 48 |
+
prior = belief.posterior[1]
|
| 49 |
+
belief.negative({1: 0.8 * admitted}, "frame-1:1")
|
| 50 |
+
assert belief.posterior[1] < prior
|
| 51 |
+
unchanged = belief.posterior.copy()
|
| 52 |
+
assert ledger.admit(evidence) == 0.0
|
| 53 |
+
assert belief.negative({1: 0.3}, "frame-1:1") is False
|
| 54 |
+
assert belief.posterior == unchanged
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_filter_advances_transition_context_only_after_elapsed_chunk() -> None:
|
| 58 |
+
class Clocked(IdentityTransition):
|
| 59 |
+
def __init__(self):
|
| 60 |
+
self.clock = 0.0
|
| 61 |
+
|
| 62 |
+
def advance_clock(self, seconds):
|
| 63 |
+
self.clock += seconds
|
| 64 |
+
|
| 65 |
+
transition = Clocked()
|
| 66 |
+
belief = BeliefFilter({1: 1.0}, transition)
|
| 67 |
+
belief.arrival(10.0)
|
| 68 |
+
assert transition.clock == 0.0
|
| 69 |
+
belief.advance(4.0)
|
| 70 |
+
assert transition.clock == 4.0
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def test_dynamic_reopening_and_static_no_return() -> None:
|
| 74 |
+
ledger = EvidenceLedger(sample_count={1: 10})
|
| 75 |
+
ledger.admit(ViewEvidence("a", 1, frozenset(range(8)), 0.9))
|
| 76 |
+
assert not ledger.eligible(1, belief=0.01, return_probability=0.0, new_coverage=0.0)
|
| 77 |
+
assert ledger.eligible(1, belief=0.01, return_probability=0.06, new_coverage=0.0)
|
| 78 |
+
assert ledger.round(1) == 1
|
| 79 |
+
assert ledger.admit(ViewEvidence("b", 1, frozenset(range(8)), 0.9)) == 0.8
|
| 80 |
+
fixed = BeliefFilter({1: 0.8, 2: 0.2}, IdentityTransition())
|
| 81 |
+
fixed.advance(100.0)
|
| 82 |
+
assert fixed.posterior == {1: 0.8, 2: 0.2}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_dynamic_round_does_not_reopen_without_elapsed_time() -> None:
|
| 86 |
+
ledger = EvidenceLedger(sample_count={1: 10})
|
| 87 |
+
ledger.admit(ViewEvidence("a", 1, frozenset(range(8)), 0.9))
|
| 88 |
+
ledger.mark_inspected(1, now_s=5.0)
|
| 89 |
+
assert not ledger.eligible(1, belief=0.8, return_probability=0.0,
|
| 90 |
+
new_coverage=0.0, now_s=5.0)
|
| 91 |
+
assert ledger.eligible(1, belief=0.8, return_probability=0.0,
|
| 92 |
+
new_coverage=0.0, now_s=6.0)
|
| 93 |
+
assert ledger.round(1) == 1
|
| 94 |
+
assert ledger.eligible(1, belief=0.8, return_probability=0.0,
|
| 95 |
+
new_coverage=0.0, now_s=6.0)
|
| 96 |
+
assert ledger.round(1) == 1
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def test_agent_replans_after_negative_and_stops_on_verified_detection() -> None:
|
| 100 |
+
class World:
|
| 101 |
+
def __init__(self):
|
| 102 |
+
self.position = 0.0
|
| 103 |
+
|
| 104 |
+
def distance(self, goal):
|
| 105 |
+
return abs(goal - self.position)
|
| 106 |
+
|
| 107 |
+
def move_chunk(self, goal, max_distance):
|
| 108 |
+
delta = min(abs(goal - self.position), max_distance)
|
| 109 |
+
self.position += np.sign(goal - self.position) * delta
|
| 110 |
+
return delta, delta / 1.0
|
| 111 |
+
|
| 112 |
+
def inspect(self, state):
|
| 113 |
+
return (state == 2, [ViewEvidence(f"frame-{state}", state,
|
| 114 |
+
frozenset(range(10)), 0.9)])
|
| 115 |
+
|
| 116 |
+
def explore(self):
|
| 117 |
+
return {}, 0.0
|
| 118 |
+
|
| 119 |
+
world = World()
|
| 120 |
+
agent = Agent(
|
| 121 |
+
{1: 0.8, 2: 0.2}, {1: 1.0, 2: 3.0, 3: 0.1}, world,
|
| 122 |
+
IdentityTransition(), AgentConfig(max_inspections=2, max_path_m=10.0,
|
| 123 |
+
chunk_m=1.0), sample_count={1: 10, 2: 10},
|
| 124 |
+
)
|
| 125 |
+
result = agent.run()
|
| 126 |
+
assert result.found
|
| 127 |
+
assert result.inspections == [1, 2]
|
| 128 |
+
assert result.actions[-1] == "STOP"
|
| 129 |
+
assert agent.filter.posterior[1] < 0.8
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def test_agent_uses_new_rgbd_evidence_before_arrival() -> None:
|
| 133 |
+
class World:
|
| 134 |
+
def __init__(self):
|
| 135 |
+
self.position = 0.0
|
| 136 |
+
self.frames = 0
|
| 137 |
+
|
| 138 |
+
def distance(self, goal):
|
| 139 |
+
return abs(goal - self.position)
|
| 140 |
+
|
| 141 |
+
def move_chunk(self, goal, max_distance):
|
| 142 |
+
displacement = min(abs(goal - self.position), max_distance)
|
| 143 |
+
self.position += np.sign(goal - self.position) * displacement
|
| 144 |
+
return displacement, displacement
|
| 145 |
+
|
| 146 |
+
def observe_chunk(self):
|
| 147 |
+
self.frames += 1
|
| 148 |
+
return [ViewEvidence("en-route-1", 1, frozenset({0}), 1.0)] if self.frames == 1 else []
|
| 149 |
+
|
| 150 |
+
def inspect(self, state):
|
| 151 |
+
return state == 2, []
|
| 152 |
+
|
| 153 |
+
agent = Agent({1: 0.8, 2: 0.2}, {1: 2.0, 2: 4.0}, World(),
|
| 154 |
+
IdentityTransition(), AgentConfig(max_inspections=2, chunk_m=1.0),
|
| 155 |
+
sample_count={1: 1, 2: 1})
|
| 156 |
+
result = agent.run()
|
| 157 |
+
assert result.found
|
| 158 |
+
assert result.inspections == [2]
|
| 159 |
+
assert agent.filter.posterior[1] == 0.0
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def test_unknown_mass_executes_explore_and_adds_a_searchable_state() -> None:
|
| 163 |
+
class World:
|
| 164 |
+
def __init__(self):
|
| 165 |
+
self.position = 0.0
|
| 166 |
+
|
| 167 |
+
def distance(self, goal):
|
| 168 |
+
return abs(goal - self.position)
|
| 169 |
+
|
| 170 |
+
def move_chunk(self, goal, max_distance):
|
| 171 |
+
distance = min(abs(goal - self.position), max_distance)
|
| 172 |
+
self.position += distance
|
| 173 |
+
return distance, distance
|
| 174 |
+
|
| 175 |
+
def explore(self, _budget_m):
|
| 176 |
+
self.position = 1.0
|
| 177 |
+
return {2: (2.0, 0.8)}, 1.0, 1.0
|
| 178 |
+
|
| 179 |
+
def inspect(self, state):
|
| 180 |
+
return state == 2, []
|
| 181 |
+
|
| 182 |
+
agent = Agent(
|
| 183 |
+
{1: 0.05, 99: 0.95}, {1: 10.0}, World(), IdentityTransition(),
|
| 184 |
+
AgentConfig(unknown_state=99, max_inspections=2),
|
| 185 |
+
)
|
| 186 |
+
result = agent.run()
|
| 187 |
+
assert result.found
|
| 188 |
+
assert result.actions[0] == "EXPLORE"
|
| 189 |
+
assert result.inspections == [2]
|
| 190 |
+
assert result.posterior[99] < 0.95
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def test_online_depth_coverage_uses_public_geometry_not_target_mask() -> None:
|
| 194 |
+
samples = candidate_surface_samples([0, 0, -2], radius_m=0.0)
|
| 195 |
+
depth = np.full((100, 100), 2.0, dtype=float)
|
| 196 |
+
seen = visible_sample_ids(samples, [0, 0, 0], [0, 0, 0, 1], depth, 90.0,
|
| 197 |
+
sensor_height_m=0.0)
|
| 198 |
+
assert seen == frozenset(range(len(samples)))
|
| 199 |
+
depth[:] = 1.0
|
| 200 |
+
assert not visible_sample_ids(samples, [0, 0, 0], [0, 0, 0, 1], depth, 90.0,
|
| 201 |
+
sensor_height_m=0.0)
|
| 202 |
+
np.testing.assert_allclose(camera_forward(heading_quaternion([1, 0, 0])),
|
| 203 |
+
[1, 0, 0], atol=1e-6)
|
| 204 |
+
assert depth_quality(np.array([[1., 2.], [0., np.nan]])) == 0.5
|
| 205 |
+
slots = [[-1., 0.9, -1.], [1., 0.9, -1.], [-1., 0.9, 1.], [1., 0.9, 1.]]
|
| 206 |
+
surface = candidate_surface_samples([0., 0.9, 0.], place_points=slots)
|
| 207 |
+
assert len(surface) == 25
|
| 208 |
+
np.testing.assert_allclose(surface.min(axis=0), [-1., 0.9, -1.])
|
| 209 |
+
np.testing.assert_allclose(surface.max(axis=0), [1., 0.9, 1.])
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def test_learned_transition_rows_are_normalized_and_chronological_loss() -> None:
|
| 213 |
+
import torch
|
| 214 |
+
from evolvingnav_paper.transition_model import TransitionHead, transition_nll
|
| 215 |
+
|
| 216 |
+
head = TransitionHead(hidden_dim=8)
|
| 217 |
+
context = torch.randn(2, 8)
|
| 218 |
+
candidates = torch.randn(2, 3, 8)
|
| 219 |
+
probabilities = head(context, candidates, torch.tensor([2.0, 4.0]),
|
| 220 |
+
torch.ones(2, 3, dtype=torch.bool))
|
| 221 |
+
assert probabilities.shape == (2, 3, 3)
|
| 222 |
+
torch.testing.assert_close(probabilities.sum(-1), torch.ones(2, 3))
|
| 223 |
+
loss = transition_nll(probabilities, torch.tensor([0, 2]), torch.tensor([1, 0]))
|
| 224 |
+
assert torch.isfinite(loss)
|
| 225 |
+
loss.backward()
|
| 226 |
+
assert head.mlp[0].weight.grad is not None
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def test_transition_pairs_use_only_same_world_instance_and_later_times() -> None:
|
| 230 |
+
from evolvingnav_paper.transition_model import chronological_pairs
|
| 231 |
+
|
| 232 |
+
pairs = chronological_pairs(
|
| 233 |
+
instance_ids=np.array(["a", "a", "b", "a"]),
|
| 234 |
+
world_ids=np.array([0, 0, 0, 1]),
|
| 235 |
+
times_s=np.array([1., 4., 2., 5.]),
|
| 236 |
+
states=np.array([1, 2, 3, 4]), max_horizon_s=10,
|
| 237 |
+
)
|
| 238 |
+
assert pairs == [(0, 1, 3.0, 1, 2)]
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def test_event_horizon_pairs_are_causal_and_cover_short_target_motion() -> None:
|
| 242 |
+
from evolvingnav_paper.transition_model import event_horizon_pairs
|
| 243 |
+
|
| 244 |
+
pairs = event_horizon_pairs(
|
| 245 |
+
instance_ids=np.array(["a", "a", "a"]),
|
| 246 |
+
world_ids=np.array([0, 0, 0]),
|
| 247 |
+
query_times_s=np.array([10., 40., 100.]),
|
| 248 |
+
query_states=np.array([1, 1, 2]),
|
| 249 |
+
events=[{"instance_uuid": "a", "event_time_s": 60.,
|
| 250 |
+
"source_state_id": 1, "destination_state_id": 2}],
|
| 251 |
+
horizons_s=(20.,),
|
| 252 |
+
)
|
| 253 |
+
assert (1, 50.0, 20.0, 1, 2) in pairs
|
| 254 |
+
assert all(source_time >= [10., 40., 100.][source] for source, source_time, *_ in pairs)
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def test_validation_fitted_detection_probability_uses_online_features() -> None:
|
| 258 |
+
from evolvingnav_paper.calibration import DetectionCalibrator
|
| 259 |
+
|
| 260 |
+
rows = [
|
| 261 |
+
{"coverage": float(i) / 20, "range_m": 1.0, "angle_cos": 1.0,
|
| 262 |
+
"projected_pixels": 100, "depth_quality": 1.0, "category_recall": 0.9,
|
| 263 |
+
"detected": i >= 10}
|
| 264 |
+
for i in range(21)
|
| 265 |
+
]
|
| 266 |
+
calibrator = DetectionCalibrator.fit(rows)
|
| 267 |
+
low = calibrator.predict({key: value for key, value in rows[0].items() if key != "detected"})
|
| 268 |
+
high = calibrator.predict({key: value for key, value in rows[-1].items() if key != "detected"})
|
| 269 |
+
assert 0 < low < high < 1
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def test_frozen_vlm_controller_can_only_select_legal_public_action() -> None:
|
| 273 |
+
from evolvingnav_paper.controller import LunaToolController
|
| 274 |
+
|
| 275 |
+
def requester(payload):
|
| 276 |
+
assert payload["model"] == "gpt-5.6-luna"
|
| 277 |
+
assert "evaluation_private" not in str(payload)
|
| 278 |
+
assert payload["tools"][0]["parameters"]["properties"]["action"]["enum"] == [
|
| 279 |
+
"NAVIGATE_TO(1)", "EXPLORE"
|
| 280 |
+
]
|
| 281 |
+
return {"output": [{"type": "function_call", "name": "select_action",
|
| 282 |
+
"arguments": '{"action":"NAVIGATE_TO(1)"}'}]}
|
| 283 |
+
|
| 284 |
+
controller = LunaToolController(requester=requester)
|
| 285 |
+
assert controller.choose(["NAVIGATE_TO(1)", "EXPLORE"],
|
| 286 |
+
{"belief": {1: 0.7}}) == "NAVIGATE_TO(1)"
|
code/tests/test_package_layout.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The published code keeps its runtime helpers inside its own package."""
|
| 2 |
+
|
| 3 |
+
import importlib.util
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def test_habitat_helpers_are_packaged() -> None:
|
| 8 |
+
assert importlib.util.find_spec("evolvingnav_paper.habitat_utils") is not None
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def test_perception_does_not_import_the_original_readyagent_stack() -> None:
|
| 12 |
+
source = (Path(__file__).resolve().parents[1] / "evolvingnav_paper/perception.py").read_text()
|
| 13 |
+
assert "readyagent.artifacts" not in source
|
| 14 |
+
assert "readyagent.perception" not in source
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def test_navigation_cli_accepts_explicit_asset_paths() -> None:
|
| 18 |
+
from evolvingnav_paper import run
|
| 19 |
+
|
| 20 |
+
assert hasattr(run, "arguments")
|
| 21 |
+
args = run.arguments([
|
| 22 |
+
"--dataset", "/data/belief", "--tasks", "/data/tasks",
|
| 23 |
+
"--hssd-root", "/data/hssd", "--navmesh-root", "/data/navmeshes",
|
| 24 |
+
"--checkpoint", "/models/p4d.pt", "--output", "/tmp/p4d-run",
|
| 25 |
+
])
|
| 26 |
+
assert args.navmesh_root == Path("/data/navmeshes")
|
| 27 |
+
assert args.inspection == "grounded-sam"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_visual_verifier_accepts_explicit_asset_paths() -> None:
|
| 31 |
+
from evolvingnav_paper import verify_visual
|
| 32 |
+
|
| 33 |
+
assert hasattr(verify_visual, "arguments")
|
| 34 |
+
args = verify_visual.arguments([
|
| 35 |
+
"/tmp/p4d-run", "--tasks", "/data/tasks",
|
| 36 |
+
"--hssd-root", "/data/hssd", "--navmesh-root", "/data/navmeshes",
|
| 37 |
+
])
|
| 38 |
+
assert args.navmesh_root == Path("/data/navmeshes")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def test_unit_tests_do_not_require_the_local_benchmark_dataset() -> None:
|
| 42 |
+
source = (Path(__file__).resolve().parent / "test_paper_code.py").read_text()
|
| 43 |
+
assert "data/task_datasets" not in source
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_record_packer_requires_an_explicit_dataset_root(monkeypatch) -> None:
|
| 47 |
+
import sys
|
| 48 |
+
|
| 49 |
+
import pytest
|
| 50 |
+
from pack_p4d_hssd_records import arguments
|
| 51 |
+
|
| 52 |
+
monkeypatch.setattr(sys, "argv", ["pack_p4d_hssd_records.py"])
|
| 53 |
+
with pytest.raises(SystemExit) as error:
|
| 54 |
+
arguments()
|
| 55 |
+
assert error.value.code == 2
|
code/tests/test_paper_code.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
|
| 5 |
+
from evolvingnav_paper.policy import candidate_utility, pack_public_query, predict_public, rank_candidates
|
| 6 |
+
from evolvingnav_paper.evaluate import evaluate_search
|
| 7 |
+
from evolvingnav_paper.backend import visible_fraction_from_masks
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_public_packing_never_exposes_labels_or_world_metadata() -> None:
|
| 11 |
+
query = {
|
| 12 |
+
"query_id": "q1", "world_variant": "routine",
|
| 13 |
+
"input": {
|
| 14 |
+
"target": {"instance_uuid": "mug_1", "category": "mug"},
|
| 15 |
+
"query": {"query_time_s": 100000.0},
|
| 16 |
+
"history_summary": {"elapsed_since_last_positive_s": 10.0},
|
| 17 |
+
"target_history": [{
|
| 18 |
+
"timestamp_s": 99990.0, "delta_t_from_previous_s": 0.0,
|
| 19 |
+
"event_type": "positive_observation", "observed_state_id": 1,
|
| 20 |
+
"detector_confidence": 1.0, "instance_match_confidence": 1.0,
|
| 21 |
+
"visible_fraction_estimate": 1.0,
|
| 22 |
+
}],
|
| 23 |
+
"candidate_state_ids": [1, 2],
|
| 24 |
+
},
|
| 25 |
+
}
|
| 26 |
+
schema = {"category_to_id": {"mug": 0}, "state_count_including_unknown": 3}
|
| 27 |
+
features = {
|
| 28 |
+
"candidate_region_category_id": np.zeros(3, dtype=np.int16),
|
| 29 |
+
"candidate_receptacle_category_id": np.zeros(3, dtype=np.int16),
|
| 30 |
+
"candidate_center_xyz": np.zeros((3, 3), dtype=np.float32),
|
| 31 |
+
"candidate_is_unknown": np.zeros(3, dtype=bool),
|
| 32 |
+
}
|
| 33 |
+
arrays = pack_public_query(query, schema, features)
|
| 34 |
+
assert "y_current_state" not in arrays
|
| 35 |
+
assert "meta_world_variant_id" not in arrays
|
| 36 |
+
assert arrays["observed_state_id"][0, -1] == 1
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_search_uses_public_cost_and_evaluator_scores_private_goal() -> None:
|
| 40 |
+
belief = {1: 0.75, 2: 0.25}
|
| 41 |
+
order = rank_candidates(belief, {1: 3.0, 2: 1.0})
|
| 42 |
+
assert order == [1, 2]
|
| 43 |
+
truth = {"current_state_id": 2, "oracle_shortest_path_m": 1.0}
|
| 44 |
+
public = {
|
| 45 |
+
"base_episode_id": "ep",
|
| 46 |
+
"episode_budget": {"max_candidate_inspections": 2, "max_path_length_m": 8.0},
|
| 47 |
+
"success_spec": {"max_geodesic_distance_m": 1.0},
|
| 48 |
+
}
|
| 49 |
+
def route(a, b):
|
| 50 |
+
return abs(a - b)
|
| 51 |
+
|
| 52 |
+
def inspect(state, _viewpoint):
|
| 53 |
+
return {
|
| 54 |
+
"detected": state == 2, "visible_fraction": 1.0,
|
| 55 |
+
"distance_to_valid_goal_m": 0.0,
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
result = evaluate_search(
|
| 59 |
+
public, truth, belief, start=0.0, goals={1: 3.0, 2: 4.0},
|
| 60 |
+
distance=route, inspect=inspect,
|
| 61 |
+
)
|
| 62 |
+
assert result["success"] is True
|
| 63 |
+
assert result["inspections"] == 2
|
| 64 |
+
assert result["path_m"] == 4.0
|
| 65 |
+
assert result["spl"] == 0.25
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_candidate_utility_matches_equation_twelve() -> None:
|
| 69 |
+
assert candidate_utility(
|
| 70 |
+
arrival_probability=0.6, new_detection_probability=0.8,
|
| 71 |
+
distance_m=2.0, eta_s=5.0, lambda_time=0.2, lambda_inspect=1.0,
|
| 72 |
+
) == 0.12
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def test_n1_selects_belief_top1_even_when_other_goal_is_nearer() -> None:
|
| 76 |
+
belief = {1: 0.7, 2: 0.3}
|
| 77 |
+
costs = {1: 9.0, 2: 1.0}
|
| 78 |
+
assert rank_candidates(belief, costs, task="n1") == [1]
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def test_model_receives_last_state_derived_from_public_history() -> None:
|
| 82 |
+
import torch
|
| 83 |
+
|
| 84 |
+
class RecordingModel:
|
| 85 |
+
def __call__(self, batch):
|
| 86 |
+
assert batch["last_state"].tolist() == [1]
|
| 87 |
+
assert batch["last_candidate_index"].tolist() == [0]
|
| 88 |
+
return {"probabilities": torch.tensor([[0.8, 0.2]])}
|
| 89 |
+
|
| 90 |
+
arrays = {
|
| 91 |
+
"event_type": np.array([[1]], dtype=np.int8),
|
| 92 |
+
"history_mask": np.array([[True]]),
|
| 93 |
+
"observed_state_id": np.array([[1]], dtype=np.int16),
|
| 94 |
+
"candidate_state_ids": np.array([[1, 2]], dtype=np.int16),
|
| 95 |
+
"candidate_mask": np.array([[True, True]]),
|
| 96 |
+
"instance_uuid": np.array(["mug_1"]),
|
| 97 |
+
}
|
| 98 |
+
schema = {"instance_uuid_to_id": {"mug_1": 0}}
|
| 99 |
+
assert predict_public(RecordingModel(), schema, arrays) == {1: 0.800000011920929, 2: 0.20000000298023224}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def test_transition_model_batch_contains_no_evaluator_fields() -> None:
|
| 103 |
+
from evolvingnav_paper.policy import model_input_batch
|
| 104 |
+
|
| 105 |
+
arrays = {
|
| 106 |
+
"event_type": np.array([[1]], dtype=np.int8),
|
| 107 |
+
"history_mask": np.array([[True]]),
|
| 108 |
+
"observed_state_id": np.array([[1]], dtype=np.int16),
|
| 109 |
+
"candidate_state_ids": np.array([[1, 2]], dtype=np.int16),
|
| 110 |
+
"candidate_mask": np.array([[True, True]]),
|
| 111 |
+
"instance_uuid": np.array(["mug_1"]),
|
| 112 |
+
"evaluation_private": np.array([123]),
|
| 113 |
+
}
|
| 114 |
+
result = model_input_batch(arrays, {"instance_uuid_to_id": {"mug_1": 0}})
|
| 115 |
+
assert "evaluation_private" not in result
|
| 116 |
+
assert result["last_state"].item() == 1
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def test_public_packing_rejects_future_observation() -> None:
|
| 120 |
+
import pytest
|
| 121 |
+
|
| 122 |
+
query = {
|
| 123 |
+
"query_id": "future", "input": {
|
| 124 |
+
"target": {"instance_uuid": "mug_1", "category": "mug"},
|
| 125 |
+
"query": {"query_time_s": 100.0},
|
| 126 |
+
"history_summary": {"elapsed_since_last_positive_s": 0.0},
|
| 127 |
+
"target_history": [{
|
| 128 |
+
"timestamp_s": 101.0, "delta_t_from_previous_s": 0.0,
|
| 129 |
+
"event_type": "positive_observation", "observed_state_id": 1,
|
| 130 |
+
"detector_confidence": 1.0, "instance_match_confidence": 1.0,
|
| 131 |
+
"visible_fraction_estimate": 1.0,
|
| 132 |
+
}],
|
| 133 |
+
"candidate_state_ids": [1, 2],
|
| 134 |
+
},
|
| 135 |
+
}
|
| 136 |
+
schema = {"category_to_id": {"mug": 0}, "state_count_including_unknown": 3}
|
| 137 |
+
features = {
|
| 138 |
+
"candidate_region_category_id": np.zeros(3, dtype=np.int16),
|
| 139 |
+
"candidate_receptacle_category_id": np.zeros(3, dtype=np.int16),
|
| 140 |
+
"candidate_center_xyz": np.zeros((3, 3), dtype=np.float32),
|
| 141 |
+
"candidate_is_unknown": np.zeros(3, dtype=bool),
|
| 142 |
+
}
|
| 143 |
+
with pytest.raises(ValueError, match="future"):
|
| 144 |
+
pack_public_query(query, schema, features)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def test_n2_replans_from_current_viewpoint_and_stops_at_verified_goal() -> None:
|
| 148 |
+
episode = {
|
| 149 |
+
"base_episode_id": "ep", "episode_budget": {
|
| 150 |
+
"max_candidate_inspections": 3, "max_path_length_m": 30.0,
|
| 151 |
+
},
|
| 152 |
+
"success_spec": {"max_geodesic_distance_m": 1.0, "require_stop_action": True},
|
| 153 |
+
}
|
| 154 |
+
truth = {"current_state_id": 3, "oracle_shortest_path_m": 5.0}
|
| 155 |
+
belief = {1: 0.8, 2: 0.12, 3: 0.08}
|
| 156 |
+
goals = {1: 4.0, 2: -3.0, 3: 5.0}
|
| 157 |
+
|
| 158 |
+
def inspect(state, viewpoint):
|
| 159 |
+
return {
|
| 160 |
+
"detected": state == 3,
|
| 161 |
+
"visible_fraction": 0.9 if state == 3 else 0.0,
|
| 162 |
+
"distance_to_valid_goal_m": 0.0 if state == 3 else 5.0,
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
result = evaluate_search(
|
| 166 |
+
episode, truth, belief, start=0.0, goals=goals,
|
| 167 |
+
distance=lambda a, b: abs(a - b), inspect=inspect,
|
| 168 |
+
)
|
| 169 |
+
assert result["inspection_order"] == [1, 3]
|
| 170 |
+
assert result["actions"][-1] == "STOP"
|
| 171 |
+
assert result["success"] is True
|
| 172 |
+
assert result["inspection_evidence"][-1]["state_id"] == 3
|
| 173 |
+
assert result["inspection_evidence"][-1]["visible_fraction"] == 0.9
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def test_correct_state_without_valid_view_is_not_success() -> None:
|
| 177 |
+
episode = {
|
| 178 |
+
"base_episode_id": "ep", "episode_budget": {
|
| 179 |
+
"max_candidate_inspections": 1, "max_path_length_m": 10.0,
|
| 180 |
+
},
|
| 181 |
+
"success_spec": {"max_geodesic_distance_m": 1.0, "require_stop_action": True},
|
| 182 |
+
}
|
| 183 |
+
truth = {"current_state_id": 1, "oracle_shortest_path_m": 2.0}
|
| 184 |
+
|
| 185 |
+
def inspect(_state, _viewpoint):
|
| 186 |
+
return {"detected": True, "visible_fraction": 0.8, "distance_to_valid_goal_m": 1.5}
|
| 187 |
+
|
| 188 |
+
result = evaluate_search(
|
| 189 |
+
episode, truth, {1: 1.0}, start=0.0, goals={1: 2.0},
|
| 190 |
+
distance=lambda a, b: abs(a - b), inspect=inspect, task="n1",
|
| 191 |
+
)
|
| 192 |
+
assert result["success"] is False
|
| 193 |
+
assert result["actions"][-1] == "STOP"
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def test_verified_instance_at_neighbor_state_viewpoint_can_succeed() -> None:
|
| 197 |
+
episode = {
|
| 198 |
+
"base_episode_id": "ep", "episode_budget": {
|
| 199 |
+
"max_candidate_inspections": 1, "max_path_length_m": 10.0,
|
| 200 |
+
},
|
| 201 |
+
"success_spec": {"max_geodesic_distance_m": 1.0},
|
| 202 |
+
}
|
| 203 |
+
truth = {"current_state_id": 2, "oracle_shortest_path_m": 2.0}
|
| 204 |
+
|
| 205 |
+
result = evaluate_search(
|
| 206 |
+
episode, truth, {1: 1.0}, start=0.0, goals={1: 2.0},
|
| 207 |
+
distance=lambda a, b: abs(a - b),
|
| 208 |
+
inspect=lambda _state, _viewpoint: {
|
| 209 |
+
"detected": True, "visible_fraction": 0.8,
|
| 210 |
+
"distance_to_valid_goal_m": 0.4,
|
| 211 |
+
},
|
| 212 |
+
task="n1",
|
| 213 |
+
)
|
| 214 |
+
assert result["success"] is True
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def test_paper_success_requires_twenty_percent_visibility() -> None:
|
| 218 |
+
episode = {
|
| 219 |
+
"base_episode_id": "ep", "episode_budget": {
|
| 220 |
+
"max_candidate_inspections": 1, "max_path_length_m": 10.0,
|
| 221 |
+
},
|
| 222 |
+
"success_spec": {"max_geodesic_distance_m": 1.0, "min_visible_fraction": 0.05},
|
| 223 |
+
}
|
| 224 |
+
truth = {"current_state_id": 1, "oracle_shortest_path_m": 2.0}
|
| 225 |
+
|
| 226 |
+
def inspect(_state, _viewpoint):
|
| 227 |
+
return {"detected": True, "visible_fraction": 0.19, "distance_to_valid_goal_m": 0.0}
|
| 228 |
+
|
| 229 |
+
result = evaluate_search(
|
| 230 |
+
episode, truth, {1: 1.0}, start=0.0, goals={1: 2.0},
|
| 231 |
+
distance=lambda a, b: abs(a - b), inspect=inspect, task="n1",
|
| 232 |
+
)
|
| 233 |
+
assert result["success"] is False
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def test_visible_fraction_uses_target_only_projection_as_denominator() -> None:
|
| 237 |
+
actual = np.array([[7, 7, 0], [0, 7, 0]])
|
| 238 |
+
target_only = np.array([[7, 7, 7], [7, 7, 0]])
|
| 239 |
+
assert visible_fraction_from_masks(actual, target_only, semantic_id=7) == 0.6
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def test_grounded_sam_policy_detection_uses_rgb_depth_and_category_only() -> None:
|
| 243 |
+
from evolvingnav_paper.perception import detect_category
|
| 244 |
+
|
| 245 |
+
def detector(rgb, categories):
|
| 246 |
+
assert rgb.shape == (2, 2, 3)
|
| 247 |
+
assert categories == ("bottle",)
|
| 248 |
+
return (("bottle", 0.9, (0.0, 0.0, 1.0, 1.0)),)
|
| 249 |
+
|
| 250 |
+
def segmenter(rgb, box):
|
| 251 |
+
assert rgb.shape == (2, 2, 3)
|
| 252 |
+
assert box == (0.0, 0.0, 1.0, 1.0)
|
| 253 |
+
return np.ones((2, 2), dtype=bool)
|
| 254 |
+
|
| 255 |
+
assert detect_category(
|
| 256 |
+
detector, segmenter, np.zeros((2, 2, 4), dtype=np.uint8), "bottle"
|
| 257 |
+
) is True
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def test_grounded_sam_returns_mask_for_rgbd_memory_backprojection() -> None:
|
| 261 |
+
from evolvingnav_paper.perception import detect_instances
|
| 262 |
+
|
| 263 |
+
found = detect_instances(
|
| 264 |
+
lambda _rgb, _categories: (("bottle", 0.9, (0., 0., 1., 1.)),),
|
| 265 |
+
lambda _rgb, _box: np.ones((2, 2), dtype=bool),
|
| 266 |
+
np.zeros((2, 2, 4), dtype=np.uint8), "bottle",
|
| 267 |
+
)
|
| 268 |
+
assert len(found) == 1
|
| 269 |
+
assert found[0].confidence == 0.9
|
| 270 |
+
assert found[0].mask.sum() == 4
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def test_grounded_sam_requires_a_nonempty_target_category_mask() -> None:
|
| 274 |
+
from evolvingnav_paper.perception import detect_category
|
| 275 |
+
|
| 276 |
+
rgb = np.zeros((2, 2, 3), dtype=np.uint8)
|
| 277 |
+
def detector(_rgb, _categories):
|
| 278 |
+
return (
|
| 279 |
+
("book", 0.9, (0.0, 0.0, 1.0, 1.0)),
|
| 280 |
+
("bottle", 0.8, (0.0, 0.0, 1.0, 1.0)),
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
def empty_mask(_rgb, _box):
|
| 284 |
+
return np.zeros((2, 2), dtype=bool)
|
| 285 |
+
assert detect_category(detector, empty_mask, rgb, "bottle") is False
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def test_training_cli_exposes_paper_optimizer_parameters(monkeypatch) -> None:
|
| 289 |
+
import sys
|
| 290 |
+
from train_p4d_belief import arguments
|
| 291 |
+
|
| 292 |
+
monkeypatch.setattr(sys, "argv", [
|
| 293 |
+
"train_p4d_belief.py", "--dataset-root", "/data/p4d", "--output", "/tmp/p4d",
|
| 294 |
+
"--weight-decay", "0.01", "--gradient-clip", "1.0",
|
| 295 |
+
])
|
| 296 |
+
args = arguments()
|
| 297 |
+
assert args.weight_decay == 0.01
|
| 298 |
+
assert args.gradient_clip == 1.0
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def test_training_cli_defaults_match_paper_configuration(monkeypatch) -> None:
|
| 302 |
+
import sys
|
| 303 |
+
from train_p4d_belief import arguments
|
| 304 |
+
|
| 305 |
+
monkeypatch.setattr(sys, "argv", [
|
| 306 |
+
"train_p4d_belief.py", "--dataset-root", "/data/p4d", "--output", "/tmp/p4d",
|
| 307 |
+
])
|
| 308 |
+
args = arguments()
|
| 309 |
+
assert args.models == ["p4d"]
|
| 310 |
+
assert args.seeds == [0, 1, 2, 3, 4]
|
| 311 |
+
assert (args.layers, args.hidden_dim, args.heads, args.dropout) == (3, 128, 4, 0.1)
|
| 312 |
+
assert (args.batch_size, args.patience, args.weight_decay) == (64, 10, 0.01)
|
| 313 |
+
assert args.eval_split == "val"
|
| 314 |
+
assert args.use_instance_identity is True
|
| 315 |
+
assert args.use_compatibility is True
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def test_paper_compatibility_head_receives_state_loss_gradient() -> None:
|
| 319 |
+
import torch
|
| 320 |
+
|
| 321 |
+
from readyagent.p4d_belief.data import Catalog
|
| 322 |
+
from readyagent.p4d_belief.models import ModelConfig, P4DBelief, state_nll
|
| 323 |
+
|
| 324 |
+
catalog = Catalog(
|
| 325 |
+
region_category=torch.tensor([0, 0, 0]),
|
| 326 |
+
receptacle_category=torch.tensor([0, 0, 0]),
|
| 327 |
+
center_xyz=torch.tensor([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]]),
|
| 328 |
+
is_unknown=torch.tensor([False, False, True]),
|
| 329 |
+
region_instance_ids=("room", "room", "unknown"),
|
| 330 |
+
schema={
|
| 331 |
+
"region_category_to_id": {"room": 0},
|
| 332 |
+
"receptacle_category_to_id": {"shelf": 0},
|
| 333 |
+
"category_to_id": {"mug": 0},
|
| 334 |
+
},
|
| 335 |
+
)
|
| 336 |
+
batch = {
|
| 337 |
+
"event_type": torch.tensor([[1, 0]]),
|
| 338 |
+
"event_time_days": torch.tensor([[0.0, 0.0]]),
|
| 339 |
+
"observed_state_id": torch.tensor([[0, -1]]),
|
| 340 |
+
"candidate_state_id": torch.tensor([[-1, -1]]),
|
| 341 |
+
"evidence_features": torch.zeros((1, 2, 6)),
|
| 342 |
+
"history_mask": torch.tensor([[True, False]]),
|
| 343 |
+
"candidate_state_ids": torch.tensor([[0, 1, 2]]),
|
| 344 |
+
"candidate_mask": torch.tensor([[True, True, True]]),
|
| 345 |
+
"target_category_id": torch.tensor([0]),
|
| 346 |
+
"query_time_days": torch.tensor([0.1]),
|
| 347 |
+
"query_time_of_day_sin_cos": torch.tensor([[0.0, 1.0]]),
|
| 348 |
+
"query_weekday_id": torch.tensor([0]),
|
| 349 |
+
"elapsed_since_last_positive_days": torch.tensor([0.1]),
|
| 350 |
+
"last_state": torch.tensor([0]),
|
| 351 |
+
"last_candidate_index": torch.tensor([0]),
|
| 352 |
+
"target_candidate_index": torch.tensor([1]),
|
| 353 |
+
}
|
| 354 |
+
model = P4DBelief(catalog, ModelConfig(
|
| 355 |
+
hidden_dim=32, layers=1, heads=4, dropout=0.0, use_compatibility=True,
|
| 356 |
+
))
|
| 357 |
+
loss = state_nll(model(batch)["probabilities"], batch["target_candidate_index"])
|
| 358 |
+
loss.backward()
|
| 359 |
+
gradients = [parameter.grad for parameter in model.compatibility.parameters()]
|
| 360 |
+
assert all(gradient is not None for gradient in gradients)
|
| 361 |
+
assert sum(float(gradient.abs().sum()) for gradient in gradients) > 0
|
editorial.css
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Editorial presentation: research footage, quiet typography, and unaltered paper figures. */
|
| 2 |
+
:root{--ink:#192927;--navy:#192927;--teal:#267b70;--muted:#65736e;--line:#dce4df;--page:#f7f9f6;--orange:#c7966c}
|
| 3 |
+
body{background:var(--page);color:var(--ink);font-family:Inter,"Helvetica Neue",Arial,sans-serif;font-size:17px;line-height:1.75;overflow-x:clip}
|
| 4 |
+
.page-width{width:min(1320px,calc(100% - 80px))}a{color:var(--teal)}
|
| 5 |
+
.site-background{display:none}html{scroll-padding-top:90px}.site-header{position:sticky;top:0;height:66px;background:#f7f9f6f2;backdrop-filter:blur(18px);z-index:20;border-bottom:1px solid var(--line)}
|
| 6 |
+
.site-header .site-name{font-size:21px;font-weight:800;letter-spacing:-.055em;color:#1c3730}.site-name:before{content:"";display:inline-block;width:9px;height:9px;border-radius:50%;background:#579d8b;margin-right:10px}
|
| 7 |
+
.site-header nav{gap:26px}.site-header nav a{font-size:13px;color:#56655f}.site-header nav a:hover,.site-header nav a[aria-current]{color:#207b68}
|
| 8 |
+
.paper-heading{padding:0 0 72px;text-align:left}.cinema-hero,.demo-gallery{position:relative;width:100vw;margin-left:calc(50% - 50vw);background:#112822;color:#fff}
|
| 9 |
+
.cinema-hero{isolation:isolate;overflow:hidden;padding:62px max(40px,calc((100vw - 1320px)/2)) 24px}
|
| 10 |
+
.hero-footage{position:absolute;inset:0;z-index:-2;display:grid;grid-template-columns:1fr 1fr;gap:3px;opacity:.65;background:#253c32}.hero-footage video{width:100%;height:100%;object-fit:cover;min-width:0}.hero-footage video:last-child{object-fit:cover;object-position:right center}
|
| 11 |
+
.cinema-hero:after{content:"";position:absolute;inset:0;z-index:-1;background:linear-gradient(90deg,rgba(9,29,25,.96),rgba(9,29,25,.77) 53%,rgba(9,29,25,.24)),linear-gradient(0deg,#112822,transparent 45%)}
|
| 12 |
+
.hero-content{max-width:1080px}.paper-kicker{display:flex;align-items:center;gap:12px;justify-content:flex-start;margin:0 0 25px;color:#b0d1c1;font-size:11px;letter-spacing:.15em;font-weight:700}.paper-kicker span{color:#9cbcaf}.status-dot{width:7px;height:7px;background:#a6d5ba;border-radius:50%;box-shadow:0 0 0 5px #a6d5ba15;animation:beacon 3s ease-in-out infinite}
|
| 13 |
+
.paper-heading h1.latest-paper-title{text-align:left;max-width:980px;margin:0 0 28px;color:#fff;font-weight:650}.latest-paper-title .title-project{font-size:clamp(42px,5.2vw,76px);line-height:1.02;letter-spacing:-.055em;max-width:880px;color:#fff}.latest-paper-title .title-colon{display:none}.latest-paper-title .title-description{font-size:clamp(20px,2.2vw,29px);letter-spacing:-.025em;line-height:1.35;margin-top:22px;max-width:850px;color:#dbe7e0;font-weight:400}
|
| 14 |
+
.paper-authors{max-width:1060px;margin:0 0 26px;color:#fff}.author-line{justify-content:flex-start;gap:5px 20px;font-size:15px;line-height:1.7;max-width:940px}.author-line span{white-space:nowrap}.author-line sup{color:#b0d3c0}.affiliation-line{margin:0 0 7px!important;max-width:890px!important;color:#c2d1c9!important;font-size:12px!important;line-height:1.7!important}.author-notes{color:#b2c6bb!important;font-size:11px!important;line-height:1.6!important}.author-notes span{margin:0 9px}
|
| 15 |
+
.paper-heading .paper-links{display:flex!important;flex-wrap:wrap;justify-content:flex-start;max-width:none;gap:10px!important;margin:0}.paper-links .resource-button{min-height:48px;padding:9px 18px!important;border-radius:6px!important;border:1px solid #ffffff38!important;background:#ffffff0c!important;backdrop-filter:blur(12px);box-shadow:none;color:#fff!important;gap:9px}.paper-links .resource-button:hover{background:#ffffff20!important;border-color:#a4caba!important;transform:translateY(-2px)}.resource-button .button-symbol{border:none;background:none;color:#c3ded0;font:500 19px/1 Arial;width:22px;height:24px;flex-basis:22px}.button-copy strong{color:#fff;font-size:13px;font-weight:600}.button-copy small{display:none}.paper-links .resource-button.explore-link{background:#d7eddf!important;border-color:#d7eddf!important}.resource-button.explore-link .button-copy strong,.resource-button.explore-link .button-symbol{color:#193b2b;background:none}.paper-links .resource-button.explore-link:hover{background:#eefaf2!important}
|
| 16 |
+
.hero-bottom{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-top:32px;padding-top:16px;border-top:1px solid #ffffff20;color:#abc1b6;font-size:11px;letter-spacing:.02em}#motion-toggle{border:none;background:none;color:#d2e1d8;cursor:pointer;font:inherit;padding:7px 0;white-space:nowrap}#motion-toggle span{margin-left:12px}
|
| 17 |
+
.demo-gallery{padding:0 max(40px,calc((100vw - 1320px)/2)) 48px;background:#112822}.hssd-demo-strip{margin:0;padding:24px 0 0;border-top:none}.hssd-demo-strip+.hssd-demo-strip{margin-top:26px;padding-top:30px;border-top:1px solid #ffffff1c}.hssd-demo-heading{margin-bottom:18px;gap:20px;align-items:center}.hssd-demo-heading h2{font-size:27px;font-weight:750;color:#eef6f0;letter-spacing:-.035em;line-height:1.2}.hssd-demo-heading h2 span{font-weight:400;color:#96b6a6}.hssd-demo-heading .hssd-demo-note{font-size:12px;color:#a6beb0}.hssd-video-grid{gap:16px;grid-template-columns:repeat(4,minmax(0,1fr))}.hssd-video-card{border:1px solid #ffffff26;border-radius:8px;background:#1b342b;box-shadow:0 6px 20px #07180e33;transition:border-color .2s,transform .2s}.hssd-video-card:hover{border-color:#82b99c;transform:translateY(-3px)}.hssd-video-label{position:static;background:#1b342b;color:#e0ebe4;padding:13px 12px;min-height:60px;font-size:12px;line-height:1.45;border-bottom:1px solid #ffffff15;letter-spacing:0}.hssd-video-frame video{object-fit:contain}.hssd-video-card figcaption{padding:11px 12px;font-size:11px;color:#aec7b8}.hssd-video-card figcaption:before{background:#76ad94}
|
| 18 |
+
.paper-highlights{max-width:none;margin:0;padding:36px 0;display:grid;grid-template-columns:repeat(4,1fr);border:none;border-bottom:1px solid var(--line);background:none;border-radius:0;gap:0}.paper-highlights>div{display:flex;flex-direction:column;align-items:flex-start;gap:8px;padding:0 28px;border-right:1px solid var(--line)}.paper-highlights>div:first-child{padding-left:0}.paper-highlights>div:last-child{border:0}.paper-highlights strong{font-size:44px;font-weight:550;letter-spacing:-.055em;line-height:1.1;color:#204c3f}.paper-highlights span{font-size:12px;line-height:1.5;color:#6d7d73}.paper-highlights br{display:none}
|
| 19 |
+
.section-kicker{display:block;color:#458271;font-size:11px;font-weight:700;letter-spacing:.17em;margin-bottom:15px;line-height:1.5}.overview-heading{display:grid;grid-template-columns:1.2fr 1fr;column-gap:90px;align-items:end;margin:0 0 32px}.overview-heading .section-kicker{grid-column:1/-1}.overview-heading h2,.paper-section h2,.core-loop-intro h2{font-size:clamp(30px,3.4vw,46px);line-height:1.13;font-weight:600;letter-spacing:-.045em;color:#203a30;margin:0 0 24px}.overview-heading h2{margin:0}.overview-heading p{font-size:17px;color:#68766e;max-width:410px;margin:0 0 4px}
|
| 20 |
+
.main-figure,.paper-figure,.paper-figure.full,.benchmark-figure{border:1px solid var(--line);border-radius:10px;padding:24px;background:#fff;box-shadow:0 8px 28px #29453605}.main-figure{margin-bottom:65px}.main-figure figcaption,.paper-figure figcaption,.benchmark-figure figcaption{font-size:13px;line-height:1.8;color:#68766e;margin:20px 0 0;padding-top:16px;border-top:1px solid #edf0ec}.paper-figure img,.paper-figure.full img{max-height:none;object-fit:contain}.figure-open{transition:opacity .2s;display:block}.figure-open:hover{opacity:.85}.figure-number{color:#518270!important;line-height:1.5!important}
|
| 21 |
+
.core-loop{display:grid;grid-template-columns:1fr 1.7fr;gap:54px;background:#eaf0e9;border:1px solid #d9e3d8;padding:42px;border-radius:12px;margin:0 0 80px}.core-loop-intro h2{font-size:32px;line-height:1.16}.core-loop-intro p{font-size:15px!important;line-height:1.8!important;color:#607368!important;max-width:none!important}.core-loop-grid{display:grid;grid-template-columns:1fr 1fr;gap:24px}.core-loop-grid article{padding:0 0 18px;background:none;border:0;border-bottom:1px solid #cddacf;border-radius:0;min-height:0}.loop-index{font-size:11px;color:#699480}.core-loop-grid h3{font-size:20px;letter-spacing:-.03em;margin:5px 0 8px;color:#264e3c}.core-loop-grid p{font-size:13px!important;line-height:1.75!important;color:#607368!important}
|
| 22 |
+
.paper-section{margin:0 0 76px;padding:0 0 76px;border-bottom:1px solid var(--line)}.paper-section>p{font-size:17px;line-height:1.85;max-width:1040px;color:#52635a;margin-bottom:22px}.abstract-section{display:grid;grid-template-columns:1fr 2fr;gap:0 60px}.abstract-section>.section-kicker{grid-column:1}.abstract-section h2{grid-column:1}.abstract-section>p{grid-column:2}.abstract-section>p:first-of-type{grid-row:1/3}.abstract-section>p:last-of-type{margin-bottom:0}.equation-note{display:flex;flex-wrap:wrap;gap:10px;align-items:center;font:13px/1.6 "Helvetica Neue",Arial,sans-serif!important;padding:20px 24px;background:#eaf1eb;border-color:#5c947e;border-radius:0 6px 6px 0;max-width:none!important}.equation-note span{font-weight:500}
|
| 23 |
+
.ex-intro{margin-bottom:14px}.ex-eyebrow{color:#548572}.ex-demo-label{font-size:11px}.ex-shell{border-radius:12px;overflow:hidden;box-shadow:0 14px 40px #1c392c0c}.ex-footnote{margin-top:20px!important;font-size:12px!important}.benchmark-figure-grid{gap:24px;align-items:stretch}.benchmark-figure .figure-open{display:flex;align-items:center;flex:1}.benchmark-figure img{height:auto;max-height:none}.protocol-table{border-radius:8px;overflow:hidden}.protocol-table>div{padding:22px 17px}.protocol-table b{color:#42856c;font:600 24px/1.5 Arial}.protocol-table span{font-size:13px}.protocol-table small{font-size:11px}
|
| 24 |
+
.result-row{padding:26px 12px;background:#eaf0e9;border-radius:8px;border:1px solid #d6e2d6;gap:0}.result{padding:0 18px}.result strong{color:#2a6950;font-size:32px;letter-spacing:-.04em;font-weight:600}.result span{font-size:12px;margin-top:10px}.table-scroll{overflow-x:auto;border-radius:8px;border:1px solid var(--line)}.results-table{margin:0;border:0;font-size:13px}.results-table th{background:#e9eee8;font-size:12px;padding:18px 13px}.results-table td{padding:20px 13px;white-space:nowrap}.results-table .ours td{background:#eef5eb;color:#2a5f45}.latest-results-figures{gap:28px}.latest-results-figures .paper-figure{margin:0;padding:24px}.compact-result-figure img{max-height:400px!important}.paper-section h3{font-size:32px;letter-spacing:-.035em;margin:46px 0 16px}.sequence-grid{gap:24px;align-items:start}.real-world-sequence{padding:20px!important;border-radius:10px}.real-world-sequence img{height:auto!important;max-height:none!important}.real-world-sequence figcaption{min-height:0}
|
| 25 |
+
.resources .resource-links{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.resource-links a{background:#fff;border:1px solid var(--line);border-radius:7px;padding:18px 20px;font-size:13px;color:#345d47}.citation-heading{display:flex;align-items:center;gap:20px;justify-content:space-between}.citation-heading h2{margin:0 0 20px}.citation-heading button{padding:11px 17px;border:1px solid #c9d7cb;border-radius:6px;background:#edf3ed;color:#315e45;cursor:pointer;font:500 12px Arial}#copy-status{font-size:12px;color:#477a5c}.bibtex pre{background:#edf1eb;border:1px solid #d7e0d5;color:#425347;padding:28px;border-radius:9px;font:12px/1.9 ui-monospace,monospace;white-space:pre-wrap;overflow-wrap:anywhere}.bibtex{border:none;margin-bottom:0;padding-bottom:70px}
|
| 26 |
+
footer{background:#112822;border:0;padding:46px 0 42px;color:#a5c1b0;font-size:12px}footer .page-width{display:flex;align-items:center;gap:35px}footer a{color:#c8dfce;text-decoration:none}.footer-wordmark{font-size:24px;font-weight:700;letter-spacing:-.05em}.footer-wordmark span{margin-left:18px;font-weight:400}footer p{flex:1;margin:0}
|
| 27 |
+
:focus-visible{outline:3px solid #67ac8a;outline-offset:5px}@keyframes beacon{50%{box-shadow:0 0 0 9px #a6d5ba05}}.motion-paused .status-dot{animation:none}
|
| 28 |
+
@media(min-width:1600px){.cinema-hero{padding-top:72px;padding-bottom:25px}.latest-paper-title .title-project{font-size:80px}}
|
| 29 |
+
@media(max-width:1050px){.page-width{width:calc(100% - 48px)}.cinema-hero{padding:44px 24px 20px}.demo-gallery{padding:0 24px 38px}.site-header nav{gap:16px}.hssd-video-grid{gap:10px}.hssd-video-label{font-size:11px;padding:10px}.hssd-video-card figcaption{font-size:10px}.core-loop{padding:30px;gap:30px}.paper-highlights strong{font-size:36px}.result strong{font-size:26px}.result{padding:0 12px}}
|
| 30 |
+
@media(max-width:760px){.page-width{width:calc(100% - 36px)}.site-header{height:auto;padding:12px 0}.header-inner{gap:10px}.site-header .site-name{font-size:19px}.site-header nav{gap:16px;overflow:auto;max-width:100%;padding:2px 0}.site-header nav a{font-size:11px}.cinema-hero{padding:38px 18px 18px}.hero-footage{grid-template-columns:1fr}.hero-footage video:last-child{display:none}.cinema-hero:after{background:linear-gradient(90deg,#0d271feb,#0d271faa),linear-gradient(0deg,#112822,transparent)}.paper-kicker{font-size:9px;gap:8px;letter-spacing:.09em;margin-bottom:24px}.latest-paper-title .title-project{font-size:44px;line-height:1.03}.latest-paper-title .title-description{font-size:21px;line-height:1.4;margin-top:18px}.paper-authors{margin-bottom:22px}.author-line{font-size:12px;gap:6px 14px}.affiliation-line{font-size:10px!important}.author-notes{font-size:10px!important}.paper-heading .paper-links{display:grid!important;grid-template-columns:1fr 1fr;gap:8px!important}.paper-links .resource-button{padding:11px 12px!important;min-height:46px}.button-copy strong{font-size:12px}.hero-bottom{font-size:10px;margin-top:25px}.hero-bottom>span{max-width:200px}.demo-gallery{padding:0 18px 32px}.hssd-video-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.hssd-demo-heading{align-items:flex-start;flex-wrap:wrap;gap:6px}.hssd-demo-heading h2{font-size:25px}.hssd-demo-heading .hssd-demo-note{font-size:11px;margin:0;width:100%}.hssd-video-label{font-size:11px;min-height:55px}.hssd-video-card figcaption{font-size:10px;line-height:1.5}.paper-heading{padding-bottom:44px}.paper-highlights{grid-template-columns:1fr 1fr;padding:28px 0;gap:28px 0}.paper-highlights>div{padding:0 18px}.paper-highlights>div:nth-child(odd){padding-left:0}.paper-highlights>div:nth-child(even){border:0}.paper-highlights strong{font-size:34px}.paper-highlights span{font-size:11px}.overview-heading{grid-template-columns:1fr;gap:0}.overview-heading p{margin-top:18px;font-size:15px}.overview-heading h2,.paper-section h2{font-size:34px}.main-figure,.paper-figure.full,.benchmark-figure{padding:12px;border-radius:7px}.main-figure figcaption,.paper-figure figcaption,.benchmark-figure figcaption{font-size:11px;padding-top:12px;margin-top:12px}.main-figure{margin-bottom:36px}.core-loop{grid-template-columns:1fr;padding:24px;gap:28px;margin-bottom:50px}.core-loop-intro h2{font-size:30px}.core-loop-intro p{font-size:14px!important}.core-loop-grid{gap:20px}.core-loop-grid p{font-size:12px!important}.paper-section{padding-bottom:45px;margin-bottom:45px}.paper-section>p{font-size:15px;line-height:1.8}.abstract-section{display:block}.benchmark-figure-grid{grid-template-columns:1fr;gap:18px}.protocol-table{grid-template-columns:1fr 1fr}.protocol-table>div{padding:16px;border-bottom:1px solid var(--line)}.protocol-table>div:last-child{grid-column:1/-1}.result-row{grid-template-columns:1fr 1fr;gap:24px 0}.result{padding:0 16px}.result:nth-child(even){border:0}.result strong{font-size:29px}.result span{font-size:11px}.results-table{min-width:700px;font-size:12px}.latest-results-figures .paper-figure{padding:12px}.latest-method-figure figcaption{display:block}.latest-method-figure figcaption>a{display:inline-block;margin-top:10px}.resources .resource-links{grid-template-columns:1fr}.citation-heading{flex-wrap:wrap;gap:8px}.citation-heading h2{margin-bottom:8px}.citation-heading button{margin-bottom:12px}.bibtex pre{padding:18px;font-size:10px}.sequence-grid{grid-template-columns:1fr}.real-world-sequence{padding:12px!important}footer .page-width{flex-wrap:wrap;gap:8px 24px}footer p{flex-basis:100%;order:3}footer{padding:32px 0}.ex-demo-label{font-size:9px}}
|
| 31 |
+
@media(max-width:390px){.latest-paper-title .title-project{font-size:39px}.hssd-video-grid{grid-template-columns:1fr}.paper-kicker{font-size:8px}.core-loop-grid{grid-template-columns:1fr}}
|
| 32 |
+
@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*,*:before,*:after{animation:none!important;transition:none!important}}
|
editorial.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
(() => {
|
| 2 |
+
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
| 3 |
+
const ambient = [...document.querySelectorAll('.ambient-video')];
|
| 4 |
+
const toggle = document.getElementById('motion-toggle');
|
| 5 |
+
let motionPaused = reducedMotion.matches;
|
| 6 |
+
let heroVisible = true;
|
| 7 |
+
const syncMotion = () => {
|
| 8 |
+
document.body.classList.toggle('motion-paused', motionPaused);
|
| 9 |
+
toggle.setAttribute('aria-pressed', String(motionPaused));
|
| 10 |
+
toggle.innerHTML = motionPaused ? 'Play background <span aria-hidden="true">▶</span>' : 'Pause background <span aria-hidden="true">Ⅱ</span>';
|
| 11 |
+
ambient.forEach(video => {
|
| 12 |
+
if (motionPaused || !heroVisible || document.hidden) video.pause();
|
| 13 |
+
else video.play().catch(() => {});
|
| 14 |
+
});
|
| 15 |
+
};
|
| 16 |
+
toggle.addEventListener('click', () => { motionPaused = !motionPaused; syncMotion(); });
|
| 17 |
+
reducedMotion.addEventListener('change', event => { motionPaused = event.matches; syncMotion(); });
|
| 18 |
+
document.addEventListener('visibilitychange', syncMotion);
|
| 19 |
+
if ('IntersectionObserver' in window) {
|
| 20 |
+
new IntersectionObserver(entries => { heroVisible = entries[0].isIntersecting; syncMotion(); }).observe(document.querySelector('.cinema-hero'));
|
| 21 |
+
const demos = new IntersectionObserver(entries => entries.forEach(({target,isIntersecting}) => {
|
| 22 |
+
if (!isIntersecting) target.pause();
|
| 23 |
+
else if (!reducedMotion.matches) target.play().catch(() => {});
|
| 24 |
+
}), {threshold:0.2});
|
| 25 |
+
document.querySelectorAll('.hssd-video-frame video').forEach(video => {
|
| 26 |
+
video.removeAttribute('autoplay');
|
| 27 |
+
video.setAttribute('aria-label', video.closest('figure').querySelector('.hssd-video-label').textContent);
|
| 28 |
+
demos.observe(video);
|
| 29 |
+
});
|
| 30 |
+
}
|
| 31 |
+
syncMotion();
|
| 32 |
+
document.getElementById('copy-citation').addEventListener('click', async () => {
|
| 33 |
+
const status = document.getElementById('copy-status');
|
| 34 |
+
try { await navigator.clipboard.writeText(document.querySelector('#bibtex pre').textContent); status.textContent = 'Copied'; }
|
| 35 |
+
catch { status.textContent = 'Select the citation below to copy.'; }
|
| 36 |
+
});
|
| 37 |
+
})();
|
figures/P4D_Benchmark.pdf
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:0b0967ccd44c6fd97c2ad03f2f8293b9671f285e870539996fd30c89f2c7aabb
|
| 3 |
+
size 405110
|
figures/P4D_CaseStudy.pdf
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:100c8a82e8360383b177372ab14fd1e8f7bcf59200820b735b21e3d1a5b5f97f
|
| 3 |
+
size 989499
|
figures/Real_World_Case_third_1.pdf
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c2b2f08388e1a21704b3320bd737b56e0272f535f763af72ce518875c23b8c3f
|
| 3 |
+
size 8845511
|
figures/Real_World_Case_third_2.pdf
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:99fcf7257b53b336e23a388b05883be9784fd6f75677780969dda9fa72ed3396
|
| 3 |
+
size 2763494
|