File size: 31,172 Bytes
e6f80f3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
"""Immutable multi-source partial supervision for the three-field bbox task.

This module produces a comparison view; the full-condition production task
retains its original room/fixed/support/constraint inputs.

Reference extents describe source structural input, not certified rectangular
physical rooms. Every well-formed parent scene remains in the primary view;
training object/context/active-objective admission is a separate preflight.
"""
from __future__ import annotations

import argparse
from collections import Counter
from contextlib import ExitStack
from copy import deepcopy
from dataclasses import asdict, is_dataclass
import json
import math
from pathlib import Path
import sqlite3
import tempfile

import torch

from .batch import TinyTokenizer, _geometry_rows, room_normalization, tokenize_condition
from .direct_layout import request_to_condition
from .io import HOLDOUT_REASON, ROOMGENBENCH_HOLDOUT_GROUPS, fingerprint, safe_output  # noqa: F401 (re-exported)
from .legacy_bridge import FLOOR_SNAP_M
from .minimal_data import SPATIALLM_IR_SHA256
from .schema import migrate_legacy_row, validate_condition

SPLITS = ("train", "validation", "test")
DEGENERATE_AXIS_M = .003
SOURCES = {
    "HSSD200": "HSSD-200", "IL3D_3dfront": "IL3D", "IL3D_synthetic": "IL3D",
    "InteriorGS": "InteriorGS", "InternScenes_3rscan": "InternScenes",
    "InternScenes_arkit": "InternScenes", "InternScenes_gen": "InternScenes",
    "InternScenes_mp3d": "InternScenes", "InternScenes_scannet": "InternScenes",
    "MansionWorld": "MansionWorld", "MultiScan": "MultiScan",
    "OptiScene_holodeck": "OptiScene", "SAGE-10k": "SAGE-10k",
    "Scan2CAD": "SceneCAD & Scan2CAD", "SpatialLM": "SpatialLM",
    "Structured3D": "Structured3D", "SceneSmith": "SceneSmith", "SpatialGen": "SpatialGen",
}
EVALUATION_SOURCES = {"SceneSmith", "SpatialGen"}
AUXILIARY_FAMILIES = ("3D-FRONT", "3RScan", "ARKitScenes")
UNKNOWN_TYPES = {"", "misc", "other", "other room", "unknown", "undefined", "none"}
PROJECTION = "room_type_reference_extent_furniture_only-v1"
YAW_SEMANTICS = "local_bbox_axes_mod_pi_not_certified_semantic_front"


def _finite(value):
    return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)


def _pairs(items):
    result = {}
    for key, value in items:
        if key in result:
            raise ValueError(f"duplicate JSON key: {key}")
        result[key] = value
    return result


def _nonfinite(value):
    raise ValueError(f"nonfinite JSON literal: {value}")


def _loads(text):
    return json.loads(text, object_pairs_hook=_pairs, parse_constant=_nonfinite)


def _write(stream, value):
    stream.write(json.dumps(value, ensure_ascii=False, sort_keys=True,
                            separators=(",", ":"), allow_nan=False) + "\n")


def _parent_rows(parent, holdout_groups=()):
    """Yield (parent split, line, row) in file order; holdout rows leaving train/validation come last."""
    deferred = []
    for split in SPLITS:
        with (parent / (split + ".jsonl")).open() as stream:
            for number, line in enumerate(stream, 1):
                if line.strip():
                    row = _loads(line)
                    if split != "test" and row["provenance"].get("group") in holdout_groups:
                        deferred.append((split, number, row))
                    else:
                        yield split, number, row
    yield from deferred


def _holdout(provenance, split, holdout_groups):
    """Benchmark rooms are written to test; returns (provenance, change-or-None)."""
    if split == "test" or provenance.get("group") not in holdout_groups:
        return provenance, None
    return ({**provenance, "split": "test", "holdout_reason": HOLDOUT_REASON},
            {"field": "provenance.split", "before": split, "after": "test", "reason": HOLDOUT_REASON})


def _source_role(source, split):
    if source not in SOURCES:
        raise ValueError("source_taxonomy_unknown")
    if split not in SPLITS or source in EVALUATION_SOURCES and split != "test":
        raise ValueError("source_role_split_conflict")
    return "evaluation_only" if source in EVALUATION_SOURCES else "train_family"


def _split_integrity(parent):
    uids, groups, counts = set(), {}, Counter()
    for split, number, row in _parent_rows(parent):
        p = row["provenance"]
        _source_role(p.get("source"), split)
        uid = p.get("scene_id")
        if not isinstance(uid, str) or not uid or uid in uids or p.get("split") != split:
            raise ValueError(f"parent UID/split integrity failure at {split}:{number}")
        uids.add(uid)
        counts[split] += 1
        for alias in (p.get("group"), p.get("house_id")):
            if not isinstance(alias, str) or not alias:
                raise ValueError("parent underlying group identity missing")
            if alias in groups and groups[alias] != split:
                raise ValueError("parent group alias has cross-split conflict")
            groups[alias] = split
    return {"parent_uids_checked": len(uids), "parent_group_aliases_checked": len(groups),
            "duplicate_uids": 0, "group_cross_split_conflicts": 0, "parent_split_samples": dict(counts),
            "split_rule": "inherit parent without re-splitting"}


def _size_policy(model_config):
    values = asdict(model_config) if is_dataclass(model_config) else model_config or {}
    reference = values.get("size_reference", (1., 1., 1.))
    limit = values.get("size_log_limit", 10.)
    if (not isinstance(reference, (list, tuple)) or len(reference) != 3
            or not all(_finite(v) and v > 0 for v in reference)
            or not _finite(limit) or not 0 < limit <= 30):
        raise ValueError("invalid configured size output range")
    low = [v * math.exp(-limit) for v in reference]
    high = [v * math.exp(limit) for v in reference]
    limits = torch.finfo(torch.float32)
    if any(lo < limits.tiny or hi > limits.max for lo, hi in zip(low, high)):
        raise ValueError("configured size output range must preserve positive finite float32 sizes")
    return {"size_reference": list(reference), "size_log_limit": limit,
            "minimum_size_m": low, "maximum_size_m": high,
            "outside_range_policy": "mask whole size vector; preserve original numeric labels"}


def _vertical_reframe(source_room):
    if source_room["source"] != "MultiScan":
        return 0.
    meta = source_room.get("meta", {})
    if meta.get("n_floor_objects_outside_structure", 0):
        raise ValueError("source_boundary_target_expanded")
    if not all(_finite(meta.get(key)) for key in ("floor_z", "floor_height")):
        raise ValueError("source_vertical_reference_missing")
    return float(meta["floor_z"]) - float(meta["floor_height"])


def _normalized_vector(value):
    if not isinstance(value, (list, tuple)) or len(value) != 3:
        raise ValueError("source/parent geometry must contain three coordinates")
    if any(v is not None and not _finite(v) for v in value):
        raise ValueError("source/parent geometry must contain finite numbers or null")
    return list(value)


def _source_geometry(sample, raw):
    p, room = sample["provenance"], sample["condition"]["room"]
    if (raw.get("source") != p.get("source") or raw.get("uid") != p.get("scene_id")
            or raw.get("group") != p.get("group") or raw.get("group") != p.get("house_id")):
        raise ValueError("source_identity_mismatch")
    if raw.get("boundary") != room["floor_polygon_xy_m"]:
        raise ValueError("source_boundary_mismatch")
    if raw.get("room_type") != room.get("room_type"):
        raise ValueError("source_room_type_mismatch")
    dz = _vertical_reframe(raw)
    if p.get("vertical_reframe_m", 0.) != dz:
        raise ValueError("source_vertical_reference_mismatch")
    raw_objects = {obj["id"]: obj for obj in raw.get("objects", [])}
    if len(raw_objects) != len(raw.get("objects", [])):
        raise ValueError("duplicate source object identity")
    targets, ids, fields = sample["target"]["objects"], p.get("target_source_ids", []), p.get("field_evidence", [])
    if len(ids) != len(targets) or len(set(ids)) != len(ids) or len(fields) != len(targets):
        raise ValueError("source_identity_missing_or_duplicate")
    joined, requests = [], {obj["id"]: obj for obj in sample["condition"]["objects"]}
    for target, ident, evidence in zip(targets, ids, fields):
        obj = raw_objects.get(ident)
        if obj is None:
            raise ValueError("source_identity_missing_object")
        pos = _normalized_vector(obj.get("pos"))
        expected_pos = [*pos[:2], pos[2] + dz if pos[2] is not None else None]
        if evidence.get("legacy_z_snap_applied_to_target") is True:
            # legacy_bridge K3: a declared source floor anchor within FLOOR_SNAP_M of the known floor is snapped.
            floor = room.get("floor_z_m")
            if (requests[target["id"]].get("support_parent") != "floor" or not _finite(floor)
                    or not _finite(expected_pos[2]) or abs(expected_pos[2] - floor) > FLOOR_SNAP_M):
                raise ValueError("source_floor_snap_mismatch")
            expected_pos[2] = floor
        yaw = obj.get("yaw")
        expected_yaw = (yaw + math.pi) % (2 * math.pi) - math.pi if _finite(yaw) else None
        if (expected_pos != target.get("bottom_center_m") or obj.get("size") != target.get("target_size_local_m")
                or expected_yaw != target.get("yaw_rad") or bool(obj.get("tilted")) != evidence.get("tilted")):
            raise ValueError("source_geometry_mismatch")
        joined.append(obj)
    return joined


def _frame(sample):
    room = sample["condition"]["room"]
    validate_condition(sample["condition"])
    points = room["floor_polygon_xy_m"]
    low = [min(p[q] for p in points) for q in (0, 1)]
    dimensions = [max(p[q] for p in points) - low[q] for q in (0, 1)]
    if not all(_finite(v) and v > 0 for v in dimensions):
        raise ValueError("source structural reference extent must be positive")
    floor = room.get("floor_z_m")
    if floor is not None and not _finite(floor):
        raise ValueError("input floor reference must be finite or null")
    room_type = room.get("room_type")
    room_type = room_type if isinstance(room_type, str) and room_type.strip().lower() not in UNKNOWN_TYPES else "unknown"
    return [*low, floor if floor is not None else 0.], dimensions, room_type


def _parent_validity(sample):
    targets, validity = sample["target"]["objects"], sample["validity"]
    result = deepcopy(validity)
    if any(key not in result for key in ("position", "size", "yaw")):
        raise ValueError("parent geometry validity fields missing")
    for key, target_field in (("position", "bottom_center_m"), ("size", "target_size_local_m")):
        if len(result[key]) != len(targets):
            raise ValueError(f"invalid parent {key} mask length")
        for mask, target in zip(result[key], targets):
            if not isinstance(mask, list) or len(mask) != 3 or not all(isinstance(v, bool) for v in mask):
                raise ValueError(f"invalid parent {key} mask")
            values = _normalized_vector(target.get(target_field))
            if any(flag and (not _finite(v) or key == "size" and v <= 0) for flag, v in zip(mask, values)):
                raise ValueError(f"valid {key} target must be finite" + (" and positive" if key == "size" else ""))
    if len(result["yaw"]) != len(targets) or not all(isinstance(v, bool) for v in result["yaw"]):
        raise ValueError("invalid parent yaw mask")
    for mask, target in zip(result["yaw"], targets):
        yaw = target.get("yaw_rad")
        if yaw is not None and not _finite(yaw) or mask and not _finite(yaw):
            raise ValueError("valid yaw target must be finite")
    for key, ok in (("yaw_symmetry_order", lambda v: type(v) is int and v >= 1),
                    ("exchangeable_group", lambda v: v is None or isinstance(v, str) and v),
                    ("size_axis_swap_allowed", lambda v: type(v) is bool)):
        if key in result and (len(result[key]) != len(targets) or not all(ok(v) for v in result[key])):
            raise ValueError(f"invalid parent {key} row")
    return result


def qualify_parent_masks(sample, *, model_config=None):
    """Apply only D1/D2 numeric demotions; preserve full-task yaw semantics."""
    policy = _size_policy(model_config)
    masks, changes = _parent_validity(sample), []
    p = sample["provenance"]
    for i, target in enumerate(sample["target"]["objects"]):
        base = {"object_id": target["id"], "target_source_id": p["target_source_ids"][i]}
        if p["source"] == "Scan2CAD" and any(masks["position"][i]):
            changes.append({**base, "field": "position", "before": masks["position"][i], "after": [False] * 3,
                            "reason": "Scan2CAD_estimated_floor_and_upstream_snap_uncertainty"})
            masks["position"][i] = [False] * 3
        size = target["target_size_local_m"]
        if any(masks["size"][i]) and any(flag and not lo <= value <= hi for flag, value, lo, hi in zip(
                masks["size"][i], size, policy["minimum_size_m"], policy["maximum_size_m"])):
            changes.append({**base, "field": "size", "before": masks["size"][i], "after": [False] * 3,
                            "value_m": deepcopy(size), "reason": "outside_configured_size_head_range"})
            masks["size"][i] = [False] * 3
        if any(masks["size"][i]) and any(_finite(value) and value < DEGENERATE_AXIS_M for value in size):
            changes.append({**base, "field": "size", "before": masks["size"][i], "after": [False] * 3,
                            "value_m": deepcopy(size), "reason": "degenerate_axis_lt_3mm"})
            masks["size"][i] = [False] * 3
    return masks, changes


def _masks(sample, joined, policy):
    masks, changes = qualify_parent_masks(sample, model_config=policy)
    p, geometric = sample["provenance"], []
    for i, (target, raw, evidence) in enumerate(zip(sample["target"]["objects"], joined, p["field_evidence"])):
        base = {"object_id": target["id"], "target_source_id": p["target_source_ids"][i]}
        qualified = (p["source"] == "SpatialLM" and raw.get("tilted") is False and evidence.get("tilted") is False
                     and evidence.get("size_semantics") == "canonical_source_IR" and _finite(raw.get("yaw"))
                     and all(_finite(v) and v > 0 for v in raw["size"]) and all(_finite(v) for v in raw["pos"]))
        geometric.append(qualified)
        if qualified and not masks["yaw"][i]:
            changes.append({**base, "field": "yaw", "before": False, "after": True,
                            "reason": "pinned_SpatialLM_full_local_extents_and_geometric_yaw"})
            masks["yaw"][i] = True
    # Bbox-axis yaw is mod pi; the box-symmetry tier (size_axis_swap_allowed) stays order 4.
    return {**masks, "yaw_symmetry_order": [4 if swap else 2 for swap in masks["size_axis_swap_allowed"]]}, changes, geometric


def _floor_evidence(sample):
    room, source = sample["condition"]["room"], sample["provenance"]["source"]
    return {"floor_source": "estimated" if source == "Scan2CAD" else (
                "source_canonical_reference" if room.get("floor_z_m") is not None else "unknown"),
            "parent_floor_known": room.get("floor_known"), "parent_floor_z_m": room.get("floor_z_m"),
            "upstream_z_snap_possible": source == "Scan2CAD",
            "per_object_pre_snap_z": "unavailable_in_frozen_IR" if source == "Scan2CAD" else "not_inferred"}


def project_sample(sample, source_room, *, split, model_config=None, holdout_groups=()):
    """Project input only, preserve numeric labels and explicitly qualify masks."""
    sample = migrate_legacy_row(sample)
    p = sample["provenance"]
    _source_role(p.get("source"), split)
    if p.get("split") != split:
        raise ValueError("source_split_mismatch")
    joined = _source_geometry(sample, source_room)
    origin, dimensions, room_type = _frame(sample)
    requests, targets = sample["condition"]["objects"], sample["target"]["objects"]
    if [o["id"] for o in requests] != [o["id"] for o in targets]:
        raise ValueError("parent request/target correspondence or order mismatch")
    request = {"room_type": room_type, "room_size_m": dimensions, "furniture_list": [
        {"id": obj["id"], "category": obj["category"], "description": obj["description"], "count": 1} for obj in requests]}
    condition = request_to_condition(request, max_objects=max(128, len(requests)), room_size_semantics="reference_extent")
    masks, changes, geometric = _masks(sample, joined, _size_policy(model_config))
    translated = [{**deepcopy(obj), "bottom_center_m": [value - offset if value is not None else None
                   for value, offset in zip(obj["bottom_center_m"], origin)]} for obj in targets]
    if any(v != 0 for v in origin):
        changes.append({"field": "bottom_center_m", "operation": "common_input_reference_translation",
                        "offset_m": [-v for v in origin], "reason": "structural_input_extent_and_explicit_floor_reference"})
    p, moved = _holdout(p, split, holdout_groups)
    changes += [moved] if moved else []
    provenance = {**deepcopy(p), "minimal_request": request, "condition_projection": PROJECTION,
                  "room_size_semantics": "reference_extent", "room_dimension_mode": "xy",
                  "frame_translation_m": [-v for v in origin], "parent_condition": deepcopy(sample["condition"]),
                  "parent_validity": deepcopy(sample["validity"]), "source_floor_evidence": _floor_evidence(sample),
                  "yaw_label_semantics": YAW_SEMANTICS, "geometric_yaw_qualified": geometric,
                  "qualification_changes": changes, "correspondence": "fixed_request_identity",
                  "source_role": _source_role(p["source"], split), "removed_condition_dependencies": _removed_dependencies(sample),
                  "geometry_checks_scope": "source identity/numerics/masks only; no physical legality certification"}
    result = {"schema_version": "fastfill.v2", "condition": condition,
              "target": {"schema_version": "fastfill.v2", "objects": translated}, "validity": masks, "provenance": provenance}
    _geometry_rows(result, *room_normalization(condition["room"]))
    return result


def _strict_rectangle(sample):
    from shapely.geometry import Polygon
    room = sample["provenance"]["parent_condition"]["room"]
    points = room["floor_polygon_xy_m"]
    dimensions = sample["provenance"]["minimal_request"]["room_size_m"]
    area = dimensions[0] * dimensions[1]
    return (sample["provenance"]["minimal_request"]["room_type"] != "unknown"
            and sample["provenance"]["source"] != "Scan2CAD"
            and room.get("boundary_known") is True and room.get("floor_known") is True
            and _finite(room.get("floor_z_m")) and Polygon(points).is_valid
            and abs(Polygon(points).area - area) <= max(1e-8, area * 1e-6))


def _index_ir(connection, ir):
    connection.execute("CREATE TABLE raw (source TEXT, uid TEXT PRIMARY KEY, row TEXT)")
    counts = Counter()
    for source in SOURCES:
        with (ir / (source + ".jsonl")).open() as stream:
            for number, line in enumerate(stream, 1):
                if not line.strip():
                    continue
                raw = _loads(line)
                if raw.get("source") != source or not isinstance(raw.get("uid"), str) or not raw["uid"]:
                    raise ValueError(f"source IR identity invalid: {source}:{number}")
                try:
                    connection.execute("INSERT INTO raw VALUES (?,?,?)", (source, raw["uid"], line))
                except sqlite3.IntegrityError as error:
                    raise ValueError("duplicate source IR UID") from error
                counts[source] += 1
        connection.commit()
    return dict(counts)


def _input_hashes(parent, ir, frozen_manifest):
    paths = [parent / (split + ".jsonl") for split in SPLITS] + [parent / "manifest.json"]
    paths += [ir / (source + ".jsonl") for source in SOURCES] + [frozen_manifest]
    return {str(path): fingerprint(path) for path in paths}



def _frozen_ir_guard(ir, frozen_manifest, before):
    manifest = _loads(frozen_manifest.read_text())
    expected = manifest.get("ir_sha256", {})
    names = {source + ".jsonl" for source in SOURCES}
    if set(expected) != names:
        raise ValueError("frozen manifest must explicitly bind all 18 IR sources")
    for name, digest in expected.items():
        if before[str(ir / name)] != digest:
            raise ValueError(f"frozen IR SHA256 mismatch: {name}")
    return expected


def _removed_dependencies(sample):
    condition = sample["condition"]
    fixed = condition["room"].get("fixed_objects", [])
    fixed_ids = {obj["id"] for obj in fixed}
    objects = condition["objects"]
    return {"fixed_objects": len(fixed), "constraints": len(condition["constraints"]),
            "support_requests": sum(obj.get("support_parent") is not None for obj in objects),
            "fixed_support_requests": sum(obj.get("support_parent") in fixed_ids for obj in objects),
            "exchangeable_requests": sum(g is not None for g in sample["validity"].get("exchangeable_group", [])),
            "interpretation": "omitted comparison-condition dependencies retained verbatim in parent_condition; not a complete-room physical task"}


def _record_stats(stats, sample, byte_length):
    p, masks = sample["provenance"], sample["validity"]
    split, source, count = p["split"], p["source"], len(sample["target"]["objects"])
    for key, ident, value in (("split_samples", split, 1), ("split_objects", split, count),
                              ("source_split_samples", f"{source}:{split}", 1),
                              ("source_split_objects", f"{source}:{split}", count),
                              ("object_count_histogram", str(count), 1), ("utf8_context_histogram", str(byte_length), 1)):
        stats[key][ident] += value
    for field in ("position", "size", "yaw"):
        n = sum(all(v) if isinstance(v, list) else v for v in masks[field])
        stats["source_split_validity"][f"{source}:{split}:{field}"] += n
        stats["validity_counts"][field] += n
    stats["validity_counts"]["geometric_yaw"] += sum(p["geometric_yaw_qualified"])
    stats["diagnostics"]["unknown_room_type_scenes"] += p["minimal_request"]["room_type"] == "unknown"
    stats["diagnostics"]["estimated_floor_scenes"] += source == "Scan2CAD"
    stats["diagnostics"]["above_default_128_object_scenes"] += count > 128
    for field in ("fixed_objects", "constraints", "support_requests", "fixed_support_requests", "exchangeable_requests"):
        n = p["removed_condition_dependencies"][field]
        stats["removed_dependency_counts"][field] += n
        stats["removed_dependency_counts"][field + "_scenes"] += n > 0
    stats["diagnostics"]["no_active_geometry_scenes"] += not any(
        all(pos) or all(size) or yaw for pos, size, yaw in zip(masks["position"], masks["size"], masks["yaw"]))


def _build_rows(parent, stage, connection, model_config, strict_rectangle, holdout_groups):
    names = ("split_samples", "split_objects", "source_split_samples", "source_split_objects", "source_split_validity",
             "object_count_histogram", "utf8_context_histogram", "validity_counts", "diagnostics", "strict_rectangle_samples",
             "removed_dependency_counts")
    stats = {name: Counter() for name in names}
    with ExitStack() as stack:
        streams = {split: stack.enter_context((stage / (split + ".jsonl")).open("x")) for split in SPLITS}
        views = {split: stack.enter_context((stage / ("strict_rectangle_" + split + ".jsonl")).open("x"))
                 for split in SPLITS} if strict_rectangle else {}
        changes = stack.enter_context((stage / "changes.jsonl").open("x"))
        stack.enter_context((stage / "exclusions.jsonl").open("x"))
        for split, number, parent_row in _parent_rows(parent, holdout_groups):
            p = parent_row["provenance"]
            source = connection.execute("SELECT row FROM raw WHERE uid=? AND source=?", (p["scene_id"], p["source"])).fetchone()
            if source is None:
                raise ValueError("parent source UID missing from frozen IR")
            row = project_sample(parent_row, _loads(source[0]), split=split, model_config=model_config, holdout_groups=holdout_groups)
            written = row["provenance"]["split"]
            _write(streams[written], row)
            for change in row["provenance"]["qualification_changes"]:
                _write(changes, {"uid": p["scene_id"], "source": p["source"], "split": split, "parent_line": number, **change})
            byte_length = len(tokenize_condition(row["condition"], TinyTokenizer())[0])
            _record_stats(stats, row, byte_length)
            if strict_rectangle and _strict_rectangle(row):
                _write(views[written], row)
                stats["strict_rectangle_samples"][written] += 1
    return {key: dict(value) for key, value in stats.items()}


def build_dataset(parent_root, ir_root, output, *, model_config=None, strict_rectangle=False,
                  expected_spatiallm_sha256=SPATIALLM_IR_SHA256, frozen_manifest=None,
                  holdout_groups=ROOMGENBENCH_HOLDOUT_GROUPS):
    """Stream all 18 frozen IR sources through disk index; publish a new view."""
    parent, ir, target = Path(parent_root).resolve(), Path(ir_root).resolve(), safe_output(output)
    if any(target == root or root in target.parents or target in root.parents for root in (parent, ir)):
        raise ValueError("output must be new and outside parent/source roots and ancestors")
    frozen = Path(frozen_manifest).resolve() if frozen_manifest else ir.parent / "data/v3.2/MANIFEST.json"
    if not frozen.is_file():
        raise ValueError("explicit frozen manifest is required to bind every source IR")
    if target == frozen or target in frozen.parents:
        raise ValueError("output must stay outside frozen manifest and its ancestors")
    policy = _size_policy(model_config)
    before = _input_hashes(parent, ir, frozen)
    frozen_hashes = _frozen_ir_guard(ir, frozen, before)
    if before[str(ir / "SpatialLM.jsonl")] != expected_spatiallm_sha256:
        raise ValueError("SpatialLM IR SHA256 differs from pinned geometric-yaw evidence")
    parent_manifest = _loads((parent / "manifest.json").read_text())
    integrity = _split_integrity(parent)
    target.parent.mkdir(parents=True, exist_ok=True)
    with tempfile.TemporaryDirectory(prefix=".multisource-build-", dir=target.parent) as work:
        stage = Path(work) / "dataset"
        stage.mkdir()
        with sqlite3.connect(Path(work) / "raw.sqlite3") as connection:
            ir_counts = _index_ir(connection, ir)
            stats = _build_rows(parent, stage, connection, model_config, strict_rectangle, tuple(holdout_groups))
        after = _input_hashes(parent, ir, frozen)
        if before != after:
            raise ValueError("parent/source inputs changed during immutable build")
        manifest = {"schema_version": "fastfill.v2", "builder": "multi-source-minimal-partial-v1",
                    "parent_root": str(parent), "ir_root": str(ir), "parent_manifest": parent_manifest,
                    "input_sha256": before, "inputs_unchanged": True, "split_integrity": integrity,
                    "source_ir_sha256": frozen_hashes, "frozen_manifest": str(frozen),
                    "parent_sha256": {name: before[str(parent / name)] for name in
                                      [split + ".jsonl" for split in SPLITS] + ["manifest.json"]},
                    "source_IR_rows": ir_counts, "source_families": SOURCES,
                    "auxiliary_families": list(AUXILIARY_FAMILIES), "evaluation_only_sources": sorted(EVALUATION_SOURCES),
                    "condition_projection": PROJECTION, "room_size_semantics": "reference_extent",
                    "room_dimension_mode": "xy", "yaw_label_semantics": YAW_SEMANTICS,
                    "spatiallm_geometric_yaw_IR_sha256": expected_spatiallm_sha256, "size_output_policy": policy,
                    "size_reference": policy["size_reference"], "size_log_limit": policy["size_log_limit"],
                    "task_role": "minimal_reference_extent_comparison_not_full_condition_main",
                    "condition_boundary_quality": "source_reference_extent",
                    "exclusions": {}, "admission_policy": "preserve every parent scene; separate whole-scene tokenizer/object/active-loss preflight",
                    "roomgenbench_holdout_groups": sorted(holdout_groups), "holdout_reason": HOLDOUT_REASON,
                    "degenerate_axis_m": DEGENERATE_AXIS_M,
                    "strict_rectangle_view": "metadata-only subset; not physical legality certification" if strict_rectangle else None,
                    "geometry_modified": "common structural-input XY and explicit input-floor reference translation only",
                    "source_data_modified": False, "parent_data_modified": False, **stats}
        manifest["output_sha256"] = {path.name: fingerprint(path) for path in sorted(stage.iterdir())}
        manifest["implementation_sha256"] = fingerprint(Path(__file__))
        with (stage / "manifest.json").open("x") as stream:
            _write(stream, manifest)
        target.mkdir(exist_ok=False)
        for path in stage.iterdir():
            path.rename(target / path.name)
    return manifest


def main(argv=None):
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--parent-root", required=True)
    parser.add_argument("--ir-root", required=True)
    parser.add_argument("--output", required=True)
    parser.add_argument("--frozen-manifest", required=True, help="canonical sealed v3.2 manifest binding all 18 IR files")
    parser.add_argument("--model-config", help="JSON model config or train config containing model")
    parser.add_argument("--strict-rectangle", action="store_true", help="also emit metadata-qualified rectangle subsets")
    parser.add_argument("--holdout-group", action="append", dest="holdout_groups",
                        help="provenance.group written to test (repeatable); default: RoomGenBench benchmark rooms")
    args = parser.parse_args(argv)
    config = _loads(Path(args.model_config).read_text()) if args.model_config else None
    if config and "model" in config:
        config = config["model"]
    holdout = ROOMGENBENCH_HOLDOUT_GROUPS if args.holdout_groups is None else tuple(args.holdout_groups)
    manifest = build_dataset(args.parent_root, args.ir_root, args.output, model_config=config,
                             strict_rectangle=args.strict_rectangle, frozen_manifest=args.frozen_manifest, holdout_groups=holdout)
    print(json.dumps({"output": str(Path(args.output).resolve()), "split_samples": manifest["split_samples"]}))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())