File size: 12,060 Bytes
ad91e86
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
"""Leakage-safe loader for packed P4D-HSSD query records."""

from __future__ import annotations

import bisect
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any

import numpy as np
import torch
from torch.utils.data import Dataset


WORLD_NAMES = {0: "routine", 1: "random", 2: "static"}
QUALITY_NAMES = {0: "exact", 1: "fallback", 2: "counterfactual", 3: "no_transition"}

# This is the complete allowlist passed to neural models. In particular, no
# meta_world_variant_id, meta_grounding_quality_id, activity or supervision field
# is included.
MODEL_INPUT_KEYS = (
    "event_type",
    "event_time_days",
    "observed_state_id",
    "candidate_state_id",
    "evidence_features",
    "history_mask",
    "candidate_state_ids",
    "candidate_mask",
    "target_category_id",
    "query_time_days",
    "query_time_of_day_sin_cos",
    "query_weekday_id",
    "elapsed_since_last_positive_days",
)

OPTIONAL_MODEL_INPUT_KEYS = (
    "context_time_days",
    "context_category_counts",
    "context_observation_features",
    "context_mask",
)

DERIVED_MODEL_INPUT_KEYS = ("target_instance_id",)


@dataclass(frozen=True)
class Catalog:
    """Static scene catalog used by the shared semantic candidate encoder."""

    region_category: torch.Tensor
    receptacle_category: torch.Tensor
    center_xyz: torch.Tensor
    is_unknown: torch.Tensor
    region_instance_ids: tuple[str, ...]
    schema: dict[str, Any]

    @property
    def state_count(self) -> int:
        return int(self.region_category.shape[0])


class PackedQueries(Dataset[dict[str, torch.Tensor]]):
    """Selected packed records with metadata kept outside model inputs."""

    def __init__(self, packed_path: Path, indices: np.ndarray | None = None) -> None:
        data = np.load(packed_path, allow_pickle=False)
        self.arrays = {key: data[key] for key in data.files}
        instance_values = sorted(str(value) for value in np.unique(self.arrays["instance_uuid"]))
        instance_to_id = {value: index for index, value in enumerate(instance_values)}
        self.target_instance_id = np.asarray(
            [instance_to_id[str(value)] for value in self.arrays["instance_uuid"]],
            dtype=np.int64,
        )
        total = len(self.arrays["y_current_state"])
        self.indices = (
            np.arange(total, dtype=np.int64)
            if indices is None
            else np.asarray(indices, dtype=np.int64)
        )
        self.last_state = derive_last_state(self.arrays)
        self.target_candidate_index = map_state_to_candidate_index(
            self.arrays["candidate_state_ids"], self.arrays["y_current_state"]
        )
        self.last_candidate_index = map_state_to_candidate_index(
            self.arrays["candidate_state_ids"], self.last_state
        )

    def __len__(self) -> int:
        return len(self.indices)

    def __getitem__(self, item: int) -> dict[str, torch.Tensor]:
        index = int(self.indices[item])
        result: dict[str, torch.Tensor] = {}
        for key in MODEL_INPUT_KEYS:
            result[key] = torch.as_tensor(self.arrays[key][index])
        for key in OPTIONAL_MODEL_INPUT_KEYS:
            if key in self.arrays:
                result[key] = torch.as_tensor(self.arrays[key][index])
        result["target_instance_id"] = torch.tensor(
            self.target_instance_id[index], dtype=torch.long
        )
        result["last_state"] = torch.tensor(self.last_state[index], dtype=torch.long)
        result["last_candidate_index"] = torch.tensor(
            self.last_candidate_index[index], dtype=torch.long
        )
        result["target_candidate_index"] = torch.tensor(
            self.target_candidate_index[index], dtype=torch.long
        )
        result["y_current_state"] = torch.tensor(
            self.arrays["y_current_state"][index], dtype=torch.long
        )
        result["y_persist"] = torch.tensor(
            self.last_state[index] == self.arrays["y_current_state"][index],
            dtype=torch.float32,
        )
        result["source_index"] = torch.tensor(index, dtype=torch.long)
        return result


def load_catalog(dataset_root: Path) -> Catalog:
    packed = dataset_root / "records/packed"
    schema = json.loads((packed / "feature_schema.json").read_text(encoding="utf-8"))
    with np.load(packed / "train.npz", allow_pickle=False) as data:
        region = torch.from_numpy(data["candidate_region_category_id"].astype(np.int64))
        receptacle = torch.from_numpy(
            data["candidate_receptacle_category_id"].astype(np.int64)
        )
        center = torch.from_numpy(data["candidate_center_xyz"].astype(np.float32))
        unknown = torch.from_numpy(data["candidate_is_unknown"].astype(np.bool_))
        instance_values = sorted(str(value) for value in np.unique(data["instance_uuid"]))
    schema["instance_uuid_to_id"] = {
        value: index for index, value in enumerate(instance_values)
    }
    candidate_json = json.loads(
        (dataset_root / "scene/candidate_states.json").read_text(encoding="utf-8")
    )
    states = sorted(candidate_json["states"], key=lambda row: row["state_id"])
    if len(states) != len(region):
        raise ValueError("candidate catalog and packed arrays have different lengths")
    region_ids = tuple(str(row["region_id"]) for row in states)
    return Catalog(region, receptacle, center, unknown, region_ids, schema)


def derive_last_state(arrays: dict[str, np.ndarray]) -> np.ndarray:
    positive = (arrays["event_type"] == 1) & arrays["history_mask"]
    positions = np.where(positive, np.arange(positive.shape[1])[None, :], -1)
    last_index = positions.max(axis=1)
    if np.any(last_index < 0):
        raise ValueError("every query must contain a positive observation anchor")
    last_state = arrays["observed_state_id"][np.arange(len(last_index)), last_index]
    if np.any(last_state < 0):
        raise ValueError("positive observation has an invalid state")
    return last_state.astype(np.int64)


def map_state_to_candidate_index(candidates: np.ndarray, states: np.ndarray) -> np.ndarray:
    matches = candidates == np.asarray(states)[:, None]
    if not np.all(matches.any(axis=1)):
        raise ValueError("target or last state is absent from candidate set")
    return matches.argmax(axis=1).astype(np.int64)


def subset_indices(
    arrays: dict[str, np.ndarray],
    *,
    world: str,
    quality: str = "all",
) -> np.ndarray:
    reverse_world = {value: key for key, value in WORLD_NAMES.items()}
    if world == "main":
        # Main training/validation excludes semantically-fallback transitions:
        # Routine exact/no-transition plus all Static records.
        keep = (
            ((arrays["meta_world_variant_id"] == reverse_world["routine"])
             & np.isin(arrays["meta_grounding_quality_id"], (0, 3)))
            | (arrays["meta_world_variant_id"] == reverse_world["static"])
        )
    else:
        keep = arrays["meta_world_variant_id"] == reverse_world[world]
    if quality == "exact":
        # Exact Routine includes unchanged queries, for which grounding is not
        # applicable and is explicitly labelled no_transition.
        keep &= np.isin(arrays["meta_grounding_quality_id"], (0, 3))
    elif quality == "fallback":
        keep &= arrays["meta_grounding_quality_id"] == 1
    elif quality != "all":
        raise ValueError(f"unknown quality subset: {quality}")
    return np.flatnonzero(keep)


def load_arrays(dataset_root: Path, split: str) -> dict[str, np.ndarray]:
    path = dataset_root / "records/packed" / f"{split}.npz"
    with np.load(path, allow_pickle=False) as data:
        return {key: data[key] for key in data.files}


def audit_training_contract(dataset_root: Path) -> dict[str, Any]:
    """Fail fast on label, chronology, candidate and leakage assumptions."""

    report: dict[str, Any] = {
        "splits": {},
        "model_input_keys": list(
            MODEL_INPUT_KEYS + OPTIONAL_MODEL_INPUT_KEYS + DERIVED_MODEL_INPUT_KEYS
        ),
    }
    forbidden = {
        "meta_world_variant_id",
        "meta_grounding_quality_id",
        "activity_type_id",
        "activity_history",
        "y_current_state",
        "y_moved",
    }
    report["forbidden_keys_absent_from_model_input"] = not bool(
        forbidden.intersection(
            MODEL_INPUT_KEYS + OPTIONAL_MODEL_INPUT_KEYS + DERIVED_MODEL_INPUT_KEYS
        )
    )
    total_disagreement = 0
    scheduled_events: dict[str, list[float]] = {}
    schedule_path = dataset_root / "schedules/routine_events.jsonl"
    if schedule_path.exists():
        for line in schedule_path.read_text(encoding="utf-8").splitlines():
            event = json.loads(line)
            scheduled_events.setdefault(str(event["instance_uuid"]), []).append(
                float(event["event_time_s"]) / 86400.0
            )
        for timestamps in scheduled_events.values():
            timestamps.sort()
    total_return_to_last = 0
    for split in ("train", "val", "test"):
        arrays = load_arrays(dataset_root, split)
        last = derive_last_state(arrays)
        target_index = map_state_to_candidate_index(
            arrays["candidate_state_ids"], arrays["y_current_state"]
        )
        last_index = map_state_to_candidate_index(arrays["candidate_state_ids"], last)
        valid_time = arrays["event_time_days"] <= arrays["query_time_days"][:, None] + 1e-6
        valid_time |= ~arrays["history_mask"]
        persist = last == arrays["y_current_state"]
        disagreement = int(np.sum(arrays["y_moved"] != ~persist))
        total_disagreement += disagreement
        routine = arrays["meta_world_variant_id"] == 0
        routine_indices = np.flatnonzero(routine)
        hidden_event_count = np.zeros(len(routine_indices), dtype=np.int64)
        for output_index, record_index in enumerate(routine_indices):
            positive = (
                (arrays["event_type"][record_index] == 1)
                & arrays["history_mask"][record_index]
            )
            last_time = float(
                arrays["event_time_days"][record_index, np.flatnonzero(positive)[-1]]
            )
            query_time = float(arrays["query_time_days"][record_index])
            timestamps = scheduled_events.get(str(arrays["instance_uuid"][record_index]), [])
            hidden_event_count[output_index] = bisect.bisect_right(
                timestamps, query_time + 1e-6
            ) - bisect.bisect_right(timestamps, last_time + 1e-6)
        routine_persist = persist[routine_indices]
        returns = int(np.sum((hidden_event_count > 0) & routine_persist))
        total_return_to_last += returns
        report["splits"][split] = {
            "records": int(len(last)),
            "target_present": bool(np.all(target_index >= 0)),
            "last_present": bool(np.all(last_index >= 0)),
            "no_future_history": bool(np.all(valid_time)),
            "persist_fraction": float(np.mean(persist)),
            "moved_vs_nonpersist_disagreement": disagreement,
            "routine_no_hidden_event_since_last_positive": int(
                np.sum(hidden_event_count == 0)
            ),
            "routine_event_and_return_to_last": returns,
            "routine_event_and_changed": int(
                np.sum((hidden_event_count > 0) & ~routine_persist)
            ),
            "unknown_targets": int(
                np.sum(arrays["y_current_state"] == int(arrays["candidate_is_unknown"].argmax()))
            ),
        }
    report["moved_label_semantics"] = (
        "y_moved is net current-state difference from last positive, not whether any "
        "hidden transition occurred"
    )
    report["routine_return_to_last_records"] = total_return_to_last
    report["passed"] = bool(
        report["forbidden_keys_absent_from_model_input"]
        and all(
            row["target_present"] and row["last_present"] and row["no_future_history"]
            for row in report["splits"].values()
        )
    )
    return report