fengnian1678 commited on
Commit
ad91e86
·
verified ·
1 Parent(s): f246299

Mirror ZJU4EmbodiedAI/EvolvingNav at dfa6872

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +16 -0
  2. .nojekyll +0 -0
  3. README.md +81 -4
  4. academic.css +1 -0
  5. assets/explorer/deeprobot.pdf +0 -0
  6. assets/explorer/hssd_104348511_topdown.png +3 -0
  7. assets/explorer/scene-provenance.json +337 -0
  8. assets/explorer/zju.pdf +3 -0
  9. code/.gitignore +7 -0
  10. code/README.md +101 -0
  11. code/configs/perception.yaml +11 -0
  12. code/evolvingnav_paper/__init__.py +1 -0
  13. code/evolvingnav_paper/agent.py +249 -0
  14. code/evolvingnav_paper/backend.py +188 -0
  15. code/evolvingnav_paper/calibration.py +66 -0
  16. code/evolvingnav_paper/controller.py +65 -0
  17. code/evolvingnav_paper/coverage.py +81 -0
  18. code/evolvingnav_paper/evaluate.py +67 -0
  19. code/evolvingnav_paper/filter.py +101 -0
  20. code/evolvingnav_paper/habitat_utils.py +67 -0
  21. code/evolvingnav_paper/memory.py +106 -0
  22. code/evolvingnav_paper/perception.py +120 -0
  23. code/evolvingnav_paper/policy.py +126 -0
  24. code/evolvingnav_paper/run.py +272 -0
  25. code/evolvingnav_paper/transition.py +32 -0
  26. code/evolvingnav_paper/transition_model.py +177 -0
  27. code/evolvingnav_paper/verify_visual.py +77 -0
  28. code/evolvingnav_paper/world.py +202 -0
  29. code/requirements.txt +8 -0
  30. code/scripts/collect_calibration.py +125 -0
  31. code/scripts/fit_calibration.py +27 -0
  32. code/scripts/pack_p4d_hssd_records.py +334 -0
  33. code/scripts/train_p4d_belief.py +269 -0
  34. code/scripts/train_transition.py +169 -0
  35. code/src/readyagent/__init__.py +1 -0
  36. code/src/readyagent/p4d_belief/__init__.py +15 -0
  37. code/src/readyagent/p4d_belief/baselines.py +74 -0
  38. code/src/readyagent/p4d_belief/data.py +294 -0
  39. code/src/readyagent/p4d_belief/metrics.py +119 -0
  40. code/src/readyagent/p4d_belief/models.py +457 -0
  41. code/src/readyagent/p4d_belief/training.py +422 -0
  42. code/tests/test_agent_core.py +286 -0
  43. code/tests/test_package_layout.py +55 -0
  44. code/tests/test_paper_code.py +361 -0
  45. editorial.css +32 -0
  46. editorial.js +37 -0
  47. figures/P4D_Benchmark.pdf +3 -0
  48. figures/P4D_CaseStudy.pdf +3 -0
  49. figures/Real_World_Case_third_1.pdf +3 -0
  50. 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: purple
5
- colorTo: indigo
6
  sdk: static
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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

  • SHA256: a53944b1c072121c0dde1f1629193e33249fddcef6ee4fa6a084493916227a83
  • Pointer size: 132 Bytes
  • Size of remote file: 1.56 MB
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