cortex-solver 3.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,792 @@
1
+ """Decomposition engine — breaks subjects into part hierarchies and validates research data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from cortex.types import (
8
+ DecomposeResult,
9
+ HierarchyNode,
10
+ MissingField,
11
+ ResearchItem,
12
+ ResearchResult,
13
+ ResearchWarning,
14
+ )
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Sanity ranges per category (min, max in metres)
18
+ # ---------------------------------------------------------------------------
19
+
20
+ SANITY_RANGES: dict[str, tuple[float, float]] = {
21
+ "furniture": (0.1, 3.0),
22
+ "electronics": (0.01, 1.0),
23
+ "architecture": (1.0, 100.0),
24
+ "vehicle": (1.0, 20.0),
25
+ "small_props": (0.005, 0.5),
26
+ }
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Template database — ~50 common objects
30
+ # ---------------------------------------------------------------------------
31
+
32
+ _T = dict[str, Any] # shorthand for a template dict
33
+
34
+
35
+ def _tpl(
36
+ category: str,
37
+ parts: dict[str, dict[str, Any]],
38
+ ) -> _T:
39
+ """Build a template entry.
40
+
41
+ *parts* maps name → {"parent": str|None, "connection": str, "children": []}.
42
+ The root part has parent=None.
43
+ """
44
+ return {"category": category, "parts": parts}
45
+
46
+
47
+ def _p(
48
+ parent: str | None = None, connection: str = "", children: list[str] | None = None
49
+ ) -> dict[str, Any]:
50
+ return {"parent": parent, "connection": connection, "children": children or []}
51
+
52
+
53
+ # fmt: off
54
+ TEMPLATES: dict[str, _T] = {
55
+ # ── Furniture ──────────────────────────────────────────────
56
+ "chair": _tpl("furniture", {
57
+ "seat": _p(None, "", ["backrest", "leg_front_L", "leg_front_R", "leg_back_L", "leg_back_R"]),
58
+ "backrest": _p("seat", "stacked_top"),
59
+ "leg_front_L": _p("seat", "stacked_bottom"),
60
+ "leg_front_R": _p("seat", "stacked_bottom"),
61
+ "leg_back_L": _p("seat", "stacked_bottom"),
62
+ "leg_back_R": _p("seat", "stacked_bottom"),
63
+ }),
64
+ "table": _tpl("furniture", {
65
+ "tabletop": _p(None, "", ["leg_1", "leg_2", "leg_3", "leg_4"]),
66
+ "leg_1": _p("tabletop", "stacked_bottom"),
67
+ "leg_2": _p("tabletop", "stacked_bottom"),
68
+ "leg_3": _p("tabletop", "stacked_bottom"),
69
+ "leg_4": _p("tabletop", "stacked_bottom"),
70
+ }),
71
+ "desk": _tpl("furniture", {
72
+ "desktop": _p(None, "", ["leg_L", "leg_R", "drawer_unit", "back_panel"]),
73
+ "leg_L": _p("desktop", "stacked_bottom"),
74
+ "leg_R": _p("desktop", "stacked_bottom"),
75
+ "drawer_unit": _p("desktop", "stacked_bottom"),
76
+ "back_panel": _p("desktop", "flush_back"),
77
+ }),
78
+ "bench": _tpl("furniture", {
79
+ "seat": _p(None, "", ["backrest", "leg_front_L", "leg_front_R", "leg_back_L", "leg_back_R", "support_bar_front", "support_bar_back"]),
80
+ "backrest": _p("seat", "stacked_top"),
81
+ "leg_front_L": _p("seat", "stacked_bottom"),
82
+ "leg_front_R": _p("seat", "stacked_bottom"),
83
+ "leg_back_L": _p("seat", "stacked_bottom"),
84
+ "leg_back_R": _p("seat", "stacked_bottom"),
85
+ "support_bar_front": _p("leg_front_L", "stacked_bottom"),
86
+ "support_bar_back": _p("leg_back_L", "stacked_bottom"),
87
+ }),
88
+ "shelf": _tpl("furniture", {
89
+ "frame": _p(None, "", ["shelf_1", "shelf_2", "shelf_3", "side_L", "side_R"]),
90
+ "shelf_1": _p("frame", "inside"),
91
+ "shelf_2": _p("frame", "inside"),
92
+ "shelf_3": _p("frame", "inside"),
93
+ "side_L": _p("frame", "flush"),
94
+ "side_R": _p("frame", "flush"),
95
+ }),
96
+ "cabinet": _tpl("furniture", {
97
+ "body": _p(None, "", ["top_panel", "bottom_panel", "door_L", "door_R", "shelf_inner", "back_panel"]),
98
+ "top_panel": _p("body", "stacked_top"),
99
+ "bottom_panel": _p("body", "stacked_bottom"),
100
+ "door_L": _p("body", "flush_front"),
101
+ "door_R": _p("body", "flush_front"),
102
+ "shelf_inner": _p("body", "inside"),
103
+ "back_panel": _p("body", "flush_back"),
104
+ }),
105
+ "bed": _tpl("furniture", {
106
+ "frame": _p(None, "", ["headboard", "footboard", "mattress", "slats", "leg_1", "leg_2", "leg_3", "leg_4"]),
107
+ "headboard": _p("frame", "flush_back"),
108
+ "footboard": _p("frame", "flush_front"),
109
+ "mattress": _p("frame", "stacked_top"),
110
+ "slats": _p("frame", "inside"),
111
+ "leg_1": _p("frame", "stacked_bottom"),
112
+ "leg_2": _p("frame", "stacked_bottom"),
113
+ "leg_3": _p("frame", "stacked_bottom"),
114
+ "leg_4": _p("frame", "stacked_bottom"),
115
+ }),
116
+ "sofa": _tpl("furniture", {
117
+ "seat_cushion": _p(None, "", ["backrest_cushion", "armrest_L", "armrest_R", "frame_base"]),
118
+ "backrest_cushion": _p("seat_cushion", "stacked_top"),
119
+ "armrest_L": _p("seat_cushion", "flush_left"),
120
+ "armrest_R": _p("seat_cushion", "flush_right"),
121
+ "frame_base": _p("seat_cushion", "stacked_bottom"),
122
+ }),
123
+ "stool": _tpl("furniture", {
124
+ "seat": _p(None, "", ["leg_1", "leg_2", "leg_3", "footrest_ring"]),
125
+ "leg_1": _p("seat", "stacked_bottom"),
126
+ "leg_2": _p("seat", "stacked_bottom"),
127
+ "leg_3": _p("seat", "stacked_bottom"),
128
+ "footrest_ring": _p("leg_1", "centered"),
129
+ }),
130
+ "bookcase": _tpl("furniture", {
131
+ "frame": _p(None, "", ["shelf_1", "shelf_2", "shelf_3", "shelf_4", "side_L", "side_R", "back_panel"]),
132
+ "shelf_1": _p("frame", "inside"),
133
+ "shelf_2": _p("frame", "inside"),
134
+ "shelf_3": _p("frame", "inside"),
135
+ "shelf_4": _p("frame", "inside"),
136
+ "side_L": _p("frame", "flush_left"),
137
+ "side_R": _p("frame", "flush_right"),
138
+ "back_panel": _p("frame", "flush_back"),
139
+ }),
140
+
141
+ # ── Electronics ────────────────────────────────────────────
142
+ "headphones": _tpl("electronics", {
143
+ "headband_frame": _p(None, "", ["headband_cushion", "slider_L", "slider_R"]),
144
+ "headband_cushion": _p("headband_frame", "stacked_bottom"),
145
+ "slider_L": _p("headband_frame", "stacked_bottom"),
146
+ "slider_R": _p("headband_frame", "stacked_bottom"),
147
+ "hinge_L": _p("slider_L", "stacked_bottom"),
148
+ "hinge_R": _p("slider_R", "stacked_bottom"),
149
+ "earcup_shell_L": _p("hinge_L", "stacked_bottom"),
150
+ "earcup_shell_R": _p("hinge_R", "stacked_bottom"),
151
+ "cushion_L": _p("earcup_shell_L", "flush"),
152
+ "cushion_R": _p("earcup_shell_R", "flush"),
153
+ }),
154
+ "speaker": _tpl("electronics", {
155
+ "enclosure": _p(None, "", ["driver_woofer", "driver_tweeter", "grille", "port_tube", "base_pad"]),
156
+ "driver_woofer": _p("enclosure", "inside"),
157
+ "driver_tweeter": _p("enclosure", "inside"),
158
+ "grille": _p("enclosure", "flush_front"),
159
+ "port_tube": _p("enclosure", "flush_back"),
160
+ "base_pad": _p("enclosure", "stacked_bottom"),
161
+ }),
162
+ "monitor": _tpl("electronics", {
163
+ "panel": _p(None, "", ["bezel", "stand_neck", "backplate"]),
164
+ "bezel": _p("panel", "flush_front"),
165
+ "stand_neck": _p("panel", "stacked_bottom"),
166
+ "stand_base": _p("stand_neck", "stacked_bottom"),
167
+ "backplate": _p("panel", "flush_back"),
168
+ }),
169
+ "keyboard": _tpl("electronics", {
170
+ "base_plate": _p(None, "", ["key_cluster_main", "key_cluster_numpad", "top_cover", "feet"]),
171
+ "key_cluster_main": _p("base_plate", "stacked_top"),
172
+ "key_cluster_numpad": _p("base_plate", "stacked_top"),
173
+ "top_cover": _p("base_plate", "flush"),
174
+ "feet": _p("base_plate", "stacked_bottom"),
175
+ }),
176
+ "mouse": _tpl("electronics", {
177
+ "shell_top": _p(None, "", ["shell_bottom", "scroll_wheel", "button_L", "button_R"]),
178
+ "shell_bottom": _p("shell_top", "stacked_bottom"),
179
+ "scroll_wheel": _p("shell_top", "inside"),
180
+ "button_L": _p("shell_top", "flush_top"),
181
+ "button_R": _p("shell_top", "flush_top"),
182
+ }),
183
+ "laptop": _tpl("electronics", {
184
+ "base_body": _p(None, "", ["keyboard_area", "trackpad", "screen_lid", "hinge"]),
185
+ "keyboard_area": _p("base_body", "stacked_top"),
186
+ "trackpad": _p("base_body", "stacked_top"),
187
+ "screen_lid": _p("hinge", "stacked_top"),
188
+ "hinge": _p("base_body", "flush_back"),
189
+ }),
190
+ "phone": _tpl("electronics", {
191
+ "body": _p(None, "", ["screen", "back_cover", "camera_module"]),
192
+ "screen": _p("body", "flush_front"),
193
+ "back_cover": _p("body", "flush_back"),
194
+ "camera_module": _p("back_cover", "stacked_top"),
195
+ }),
196
+ "tablet": _tpl("electronics", {
197
+ "body": _p(None, "", ["screen", "back_panel", "camera"]),
198
+ "screen": _p("body", "flush_front"),
199
+ "back_panel": _p("body", "flush_back"),
200
+ "camera": _p("back_panel", "flush"),
201
+ }),
202
+ "camera": _tpl("electronics", {
203
+ "body": _p(None, "", ["lens_mount", "viewfinder", "grip", "top_plate", "bottom_plate"]),
204
+ "lens_mount": _p("body", "flush_front"),
205
+ "viewfinder": _p("body", "stacked_top"),
206
+ "grip": _p("body", "flush_right"),
207
+ "top_plate": _p("body", "stacked_top"),
208
+ "bottom_plate": _p("body", "stacked_bottom"),
209
+ }),
210
+ "microphone": _tpl("electronics", {
211
+ "capsule": _p(None, "", ["body_tube", "grille"]),
212
+ "body_tube": _p("capsule", "stacked_bottom"),
213
+ "grille": _p("capsule", "stacked_top"),
214
+ "mount_ring": _p("body_tube", "centered"),
215
+ "connector": _p("body_tube", "stacked_bottom"),
216
+ }),
217
+
218
+ # ── Architecture ───────────────────────────────────────────
219
+ "house": _tpl("architecture", {
220
+ "foundation": _p(None, "", ["walls", "floor"]),
221
+ "floor": _p("foundation", "stacked_top"),
222
+ "walls": _p("floor", "stacked_top", ["roof", "door_front", "window_1", "window_2"]),
223
+ "roof": _p("walls", "stacked_top"),
224
+ "door_front": _p("walls", "flush_front"),
225
+ "window_1": _p("walls", "inside"),
226
+ "window_2": _p("walls", "inside"),
227
+ }),
228
+ "building": _tpl("architecture", {
229
+ "foundation": _p(None, "", ["ground_floor", "upper_floors", "roof"]),
230
+ "ground_floor": _p("foundation", "stacked_top"),
231
+ "upper_floors": _p("ground_floor", "stacked_top"),
232
+ "roof": _p("upper_floors", "stacked_top"),
233
+ "entrance": _p("ground_floor", "flush_front"),
234
+ }),
235
+ "room": _tpl("architecture", {
236
+ "floor": _p(None, "", ["wall_N", "wall_S", "wall_E", "wall_W", "ceiling"]),
237
+ "wall_N": _p("floor", "stacked_top"),
238
+ "wall_S": _p("floor", "stacked_top"),
239
+ "wall_E": _p("floor", "stacked_top"),
240
+ "wall_W": _p("floor", "stacked_top"),
241
+ "ceiling": _p("wall_N", "stacked_top"),
242
+ }),
243
+ "wall": _tpl("architecture", {
244
+ "structure": _p(None, "", ["surface_front", "surface_back", "insulation"]),
245
+ "surface_front": _p("structure", "flush_front"),
246
+ "surface_back": _p("structure", "flush_back"),
247
+ "insulation": _p("structure", "inside"),
248
+ }),
249
+ "door": _tpl("architecture", {
250
+ "frame": _p(None, "", ["panel", "hinge_top", "hinge_bottom", "handle"]),
251
+ "panel": _p("frame", "inside"),
252
+ "hinge_top": _p("frame", "flush"),
253
+ "hinge_bottom": _p("frame", "flush"),
254
+ "handle": _p("panel", "flush"),
255
+ }),
256
+ "window": _tpl("architecture", {
257
+ "frame": _p(None, "", ["glass_pane", "sill", "latch"]),
258
+ "glass_pane": _p("frame", "inside"),
259
+ "sill": _p("frame", "stacked_bottom"),
260
+ "latch": _p("frame", "flush"),
261
+ }),
262
+ "stairs": _tpl("architecture", {
263
+ "stringer_L": _p(None, "", ["step_1", "step_2", "step_3", "step_4", "step_5", "handrail"]),
264
+ "stringer_R": _p("stringer_L", "symmetric"),
265
+ "step_1": _p("stringer_L", "inside"),
266
+ "step_2": _p("step_1", "stacked_top"),
267
+ "step_3": _p("step_2", "stacked_top"),
268
+ "step_4": _p("step_3", "stacked_top"),
269
+ "step_5": _p("step_4", "stacked_top"),
270
+ "handrail": _p("stringer_L", "stacked_top"),
271
+ }),
272
+ "roof": _tpl("architecture", {
273
+ "ridge_beam": _p(None, "", ["rafter_L", "rafter_R", "sheathing", "covering"]),
274
+ "rafter_L": _p("ridge_beam", "flush"),
275
+ "rafter_R": _p("ridge_beam", "flush"),
276
+ "sheathing": _p("rafter_L", "stacked_top"),
277
+ "covering": _p("sheathing", "stacked_top"),
278
+ }),
279
+ "column": _tpl("architecture", {
280
+ "shaft": _p(None, "", ["base_plinth", "capital"]),
281
+ "base_plinth": _p("shaft", "stacked_bottom"),
282
+ "capital": _p("shaft", "stacked_top"),
283
+ }),
284
+ "arch": _tpl("architecture", {
285
+ "keystone": _p(None, "", ["voussoir_L", "voussoir_R", "impost_L", "impost_R"]),
286
+ "voussoir_L": _p("keystone", "flush"),
287
+ "voussoir_R": _p("keystone", "flush"),
288
+ "impost_L": _p("voussoir_L", "stacked_bottom"),
289
+ "impost_R": _p("voussoir_R", "stacked_bottom"),
290
+ }),
291
+
292
+ # ── Vehicles ───────────────────────────────────────────────
293
+ "car": _tpl("vehicle", {
294
+ "chassis": _p(None, "", ["body_shell", "wheel_FL", "wheel_FR", "wheel_RL", "wheel_RR"]),
295
+ "body_shell": _p("chassis", "stacked_top", ["windshield", "door_L", "door_R"]),
296
+ "wheel_FL": _p("chassis", "stacked_bottom"),
297
+ "wheel_FR": _p("chassis", "stacked_bottom"),
298
+ "wheel_RL": _p("chassis", "stacked_bottom"),
299
+ "wheel_RR": _p("chassis", "stacked_bottom"),
300
+ "windshield": _p("body_shell", "flush_front"),
301
+ "door_L": _p("body_shell", "flush_left"),
302
+ "door_R": _p("body_shell", "flush_right"),
303
+ }),
304
+ "bicycle": _tpl("vehicle", {
305
+ "frame": _p(None, "", ["wheel_front", "wheel_rear", "seat", "handlebars", "pedals"]),
306
+ "wheel_front": _p("frame", "flush_front"),
307
+ "wheel_rear": _p("frame", "flush_back"),
308
+ "seat": _p("frame", "stacked_top"),
309
+ "handlebars": _p("frame", "stacked_top"),
310
+ "pedals": _p("frame", "centered"),
311
+ }),
312
+ "motorcycle": _tpl("vehicle", {
313
+ "frame": _p(None, "", ["engine", "wheel_front", "wheel_rear", "seat", "handlebars", "fuel_tank"]),
314
+ "engine": _p("frame", "inside"),
315
+ "wheel_front": _p("frame", "flush_front"),
316
+ "wheel_rear": _p("frame", "flush_back"),
317
+ "seat": _p("frame", "stacked_top"),
318
+ "handlebars": _p("frame", "stacked_top"),
319
+ "fuel_tank": _p("frame", "stacked_top"),
320
+ }),
321
+ "truck": _tpl("vehicle", {
322
+ "chassis": _p(None, "", ["cab", "cargo_bed", "wheel_FL", "wheel_FR", "wheel_RL", "wheel_RR"]),
323
+ "cab": _p("chassis", "stacked_top"),
324
+ "cargo_bed": _p("chassis", "stacked_top"),
325
+ "wheel_FL": _p("chassis", "stacked_bottom"),
326
+ "wheel_FR": _p("chassis", "stacked_bottom"),
327
+ "wheel_RL": _p("chassis", "stacked_bottom"),
328
+ "wheel_RR": _p("chassis", "stacked_bottom"),
329
+ }),
330
+ "bus": _tpl("vehicle", {
331
+ "chassis": _p(None, "", ["body", "wheel_FL", "wheel_FR", "wheel_RL", "wheel_RR"]),
332
+ "body": _p("chassis", "stacked_top", ["windshield", "door_front", "door_rear"]),
333
+ "wheel_FL": _p("chassis", "stacked_bottom"),
334
+ "wheel_FR": _p("chassis", "stacked_bottom"),
335
+ "wheel_RL": _p("chassis", "stacked_bottom"),
336
+ "wheel_RR": _p("chassis", "stacked_bottom"),
337
+ "windshield": _p("body", "flush_front"),
338
+ "door_front": _p("body", "flush_left"),
339
+ "door_rear": _p("body", "flush_left"),
340
+ }),
341
+ "airplane": _tpl("vehicle", {
342
+ "fuselage": _p(None, "", ["wing_L", "wing_R", "tail_vertical", "tail_horizontal", "nose_cone", "landing_gear"]),
343
+ "wing_L": _p("fuselage", "flush_left"),
344
+ "wing_R": _p("fuselage", "flush_right"),
345
+ "tail_vertical": _p("fuselage", "flush_back"),
346
+ "tail_horizontal": _p("fuselage", "flush_back"),
347
+ "nose_cone": _p("fuselage", "flush_front"),
348
+ "landing_gear": _p("fuselage", "stacked_bottom"),
349
+ }),
350
+ "boat": _tpl("vehicle", {
351
+ "hull": _p(None, "", ["deck", "cabin", "keel", "rudder"]),
352
+ "deck": _p("hull", "stacked_top"),
353
+ "cabin": _p("deck", "stacked_top"),
354
+ "keel": _p("hull", "stacked_bottom"),
355
+ "rudder": _p("hull", "flush_back"),
356
+ }),
357
+ "helicopter": _tpl("vehicle", {
358
+ "fuselage": _p(None, "", ["main_rotor", "tail_boom", "skid_L", "skid_R"]),
359
+ "main_rotor": _p("fuselage", "stacked_top"),
360
+ "tail_boom": _p("fuselage", "flush_back"),
361
+ "tail_rotor": _p("tail_boom", "flush"),
362
+ "skid_L": _p("fuselage", "stacked_bottom"),
363
+ "skid_R": _p("fuselage", "stacked_bottom"),
364
+ }),
365
+ "train": _tpl("vehicle", {
366
+ "chassis": _p(None, "", ["body", "bogie_front", "bogie_rear"]),
367
+ "body": _p("chassis", "stacked_top", ["windshield", "door_L", "door_R"]),
368
+ "bogie_front": _p("chassis", "stacked_bottom"),
369
+ "bogie_rear": _p("chassis", "stacked_bottom"),
370
+ "windshield": _p("body", "flush_front"),
371
+ "door_L": _p("body", "flush_left"),
372
+ "door_R": _p("body", "flush_right"),
373
+ }),
374
+ "scooter": _tpl("vehicle", {
375
+ "frame": _p(None, "", ["deck", "wheel_front", "wheel_rear", "handlebar", "stem"]),
376
+ "deck": _p("frame", "stacked_top"),
377
+ "wheel_front": _p("frame", "flush_front"),
378
+ "wheel_rear": _p("frame", "flush_back"),
379
+ "handlebar": _p("stem", "stacked_top"),
380
+ "stem": _p("frame", "stacked_top"),
381
+ }),
382
+
383
+ # ── Small Props ────────────────────────────────────────────
384
+ "cup": _tpl("small_props", {
385
+ "body": _p(None, "", ["handle", "base_ring"]),
386
+ "handle": _p("body", "flush"),
387
+ "base_ring": _p("body", "stacked_bottom"),
388
+ }),
389
+ "bottle": _tpl("small_props", {
390
+ "body": _p(None, "", ["neck", "base", "cap"]),
391
+ "neck": _p("body", "stacked_top"),
392
+ "base": _p("body", "stacked_bottom"),
393
+ "cap": _p("neck", "stacked_top"),
394
+ }),
395
+ "pen": _tpl("small_props", {
396
+ "barrel": _p(None, "", ["tip", "cap", "clip"]),
397
+ "tip": _p("barrel", "flush_front"),
398
+ "cap": _p("barrel", "flush_back"),
399
+ "clip": _p("cap", "flush"),
400
+ }),
401
+ "book": _tpl("small_props", {
402
+ "cover_front": _p(None, "", ["pages", "cover_back", "spine"]),
403
+ "pages": _p("cover_front", "stacked_bottom"),
404
+ "cover_back": _p("pages", "stacked_bottom"),
405
+ "spine": _p("cover_front", "flush"),
406
+ }),
407
+ "lamp": _tpl("small_props", {
408
+ "base": _p(None, "", ["stem", "shade"]),
409
+ "stem": _p("base", "stacked_top"),
410
+ "shade": _p("stem", "stacked_top"),
411
+ "bulb": _p("shade", "inside"),
412
+ }),
413
+ "clock": _tpl("small_props", {
414
+ "frame": _p(None, "", ["face", "glass", "hands"]),
415
+ "face": _p("frame", "inside"),
416
+ "glass": _p("frame", "flush_front"),
417
+ "hands": _p("face", "stacked_top"),
418
+ }),
419
+ "vase": _tpl("small_props", {
420
+ "body": _p(None, "", ["neck", "base", "rim"]),
421
+ "neck": _p("body", "stacked_top"),
422
+ "base": _p("body", "stacked_bottom"),
423
+ "rim": _p("neck", "stacked_top"),
424
+ }),
425
+ "plate": _tpl("small_props", {
426
+ "body": _p(None, "", ["rim", "base_ring"]),
427
+ "rim": _p("body", "flush"),
428
+ "base_ring": _p("body", "stacked_bottom"),
429
+ }),
430
+ "fork": _tpl("small_props", {
431
+ "handle": _p(None, "", ["neck", "head"]),
432
+ "neck": _p("handle", "flush_front"),
433
+ "head": _p("neck", "flush_front"),
434
+ }),
435
+ "knife": _tpl("small_props", {
436
+ "handle": _p(None, "", ["blade", "guard"]),
437
+ "blade": _p("handle", "flush_front"),
438
+ "guard": _p("handle", "flush"),
439
+ }),
440
+ }
441
+ # fmt: on
442
+
443
+ # Variant modifiers — extra parts added for known variants
444
+ VARIANT_MODS: dict[str, dict[str, dict[str, Any]]] = {
445
+ "gaming": {
446
+ "headrest": _p("backrest", "stacked_top"),
447
+ "armrest_pad_L": _p("armrest_L", "stacked_top")
448
+ if False
449
+ else _p("seat", "flush_left"),
450
+ "armrest_pad_R": _p("seat", "flush_right"),
451
+ "lumbar_support": _p("backrest", "flush_back"),
452
+ },
453
+ "ergonomic": {
454
+ "armrest_L": _p("seat", "flush_left"),
455
+ "armrest_R": _p("seat", "flush_right"),
456
+ "lumbar_support": _p("backrest", "inside"),
457
+ },
458
+ "modern": {}, # no extra parts — just a style tag
459
+ "industrial": {},
460
+ "minimalist": {},
461
+ }
462
+
463
+ # Required research fields per detail level
464
+ RESEARCH_FIELDS: dict[str, list[str]] = {
465
+ "basic": ["dimensions"],
466
+ "detailed": ["dimensions", "material", "thickness"],
467
+ "exhaustive": [
468
+ "dimensions",
469
+ "material",
470
+ "thickness",
471
+ "construction_method",
472
+ "edge_treatment",
473
+ "source",
474
+ ],
475
+ }
476
+
477
+
478
+ # ---------------------------------------------------------------------------
479
+ # Template lookup
480
+ # ---------------------------------------------------------------------------
481
+
482
+
483
+ def _find_template(subject: str) -> _T | None:
484
+ """Find the best-matching template for a subject string (case-insensitive)."""
485
+ subj = subject.lower().strip()
486
+ # Exact match first
487
+ if subj in TEMPLATES:
488
+ return TEMPLATES[subj]
489
+ # Plural → singular
490
+ if subj.endswith("s") and subj[:-1] in TEMPLATES:
491
+ return TEMPLATES[subj[:-1]]
492
+ # Substring match
493
+ for key, tpl in TEMPLATES.items():
494
+ if key in subj or subj in key:
495
+ return tpl
496
+ return None
497
+
498
+
499
+ def _get_category(subject: str) -> str:
500
+ """Infer category from template match or fall back to 'furniture'."""
501
+ tpl = _find_template(subject)
502
+ return tpl["category"] if tpl else "furniture"
503
+
504
+
505
+ # ---------------------------------------------------------------------------
506
+ # Hierarchy building
507
+ # ---------------------------------------------------------------------------
508
+
509
+
510
+ def _build_hierarchy(
511
+ parts: dict[str, dict[str, Any]],
512
+ detail_level: str,
513
+ ) -> dict[str, HierarchyNode]:
514
+ """Convert template parts dict into HierarchyNode dict, respecting detail depth."""
515
+ max_depth = {"basic": 2, "detailed": 3, "exhaustive": 99}.get(detail_level, 3)
516
+
517
+ # Find root (parent=None)
518
+ root_name = next((n for n, p in parts.items() if p["parent"] is None), None)
519
+ if root_name is None:
520
+ return {}
521
+
522
+ nodes: dict[str, HierarchyNode] = {}
523
+ depth_map: dict[str, int] = {root_name: 0}
524
+
525
+ # BFS to honour max depth
526
+ queue = [root_name]
527
+ while queue:
528
+ name = queue.pop(0)
529
+ d = depth_map[name]
530
+ if d > max_depth:
531
+ continue
532
+ info = parts[name]
533
+ nodes[name] = HierarchyNode(
534
+ name=name,
535
+ parent=info["parent"],
536
+ connection=info["connection"],
537
+ children=[
538
+ c
539
+ for c in info.get("children", [])
540
+ if c in parts and depth_map.get(c, d + 1) <= max_depth
541
+ ],
542
+ )
543
+ for child_name in info.get("children", []):
544
+ if child_name in parts and child_name not in depth_map:
545
+ depth_map[child_name] = d + 1
546
+ queue.append(child_name)
547
+
548
+ # Fill in children for nodes whose children were added during BFS but not in the original template children list
549
+ for name, node in nodes.items():
550
+ for cname, cinfo in parts.items():
551
+ if (
552
+ cinfo["parent"] == name
553
+ and cname in nodes
554
+ and cname not in node.children
555
+ ):
556
+ node.children.append(cname)
557
+
558
+ return nodes
559
+
560
+
561
+ def _generic_hierarchy(subject: str, detail_level: str) -> dict[str, HierarchyNode]:
562
+ """Generate a generic hierarchy for an unknown subject."""
563
+ root = subject.lower().replace(" ", "_")
564
+ children = ["main_body", "base", "top"]
565
+ if detail_level in ("detailed", "exhaustive"):
566
+ children.extend(["detail_A", "detail_B"])
567
+ if detail_level == "exhaustive":
568
+ children.extend(["detail_C", "accent_1", "accent_2"])
569
+
570
+ nodes: dict[str, HierarchyNode] = {
571
+ root: HierarchyNode(name=root, parent=None, connection="", children=children),
572
+ }
573
+ for c in children:
574
+ nodes[c] = HierarchyNode(name=c, parent=root, connection="attached")
575
+ return nodes
576
+
577
+
578
+ def _topological_order(hierarchy: dict[str, HierarchyNode]) -> list[str]:
579
+ """Return build order (parent before children) via BFS."""
580
+ root = next((n for n, h in hierarchy.items() if h.parent is None), None)
581
+ if root is None:
582
+ return list(hierarchy.keys())
583
+ order: list[str] = []
584
+ queue = [root]
585
+ visited: set[str] = set()
586
+ while queue:
587
+ name = queue.pop(0)
588
+ if name in visited or name not in hierarchy:
589
+ continue
590
+ visited.add(name)
591
+ order.append(name)
592
+ for child in hierarchy[name].children:
593
+ if child not in visited:
594
+ queue.append(child)
595
+ # Add any remaining
596
+ for name in hierarchy:
597
+ if name not in visited:
598
+ order.append(name)
599
+ return order
600
+
601
+
602
+ # ---------------------------------------------------------------------------
603
+ # Public API: decompose
604
+ # ---------------------------------------------------------------------------
605
+
606
+
607
+ def decompose(
608
+ subject: str,
609
+ variant: str = "",
610
+ scope: str = "object",
611
+ detail_level: str = "detailed",
612
+ ) -> DecomposeResult:
613
+ """Decompose a subject into a hierarchical part tree."""
614
+ if scope == "scene":
615
+ return _decompose_scene(subject, detail_level)
616
+
617
+ tpl = _find_template(subject)
618
+ if tpl is not None:
619
+ parts = dict(tpl["parts"])
620
+ # Apply variant modifications
621
+ if variant and variant.lower() in VARIANT_MODS:
622
+ for pname, pinfo in VARIANT_MODS[variant.lower()].items():
623
+ if pname not in parts:
624
+ parts[pname] = pinfo
625
+ # Add to parent's children list
626
+ parent = pinfo["parent"]
627
+ if parent and parent in parts:
628
+ if pname not in parts[parent].get("children", []):
629
+ parts[parent].setdefault("children", []).append(pname)
630
+
631
+ hierarchy = _build_hierarchy(parts, detail_level)
632
+ else:
633
+ hierarchy = _generic_hierarchy(subject, detail_level)
634
+
635
+ category = tpl["category"] if tpl else "furniture"
636
+ detail = detail_level if detail_level in RESEARCH_FIELDS else "detailed"
637
+ fields = RESEARCH_FIELDS[detail]
638
+
639
+ research_required = [
640
+ ResearchItem(part=name, needed=list(fields)) for name in hierarchy
641
+ ]
642
+
643
+ build_order = _topological_order(hierarchy)
644
+
645
+ return DecomposeResult(
646
+ subject=subject,
647
+ hierarchy=hierarchy,
648
+ research_required=research_required,
649
+ total_parts=len(hierarchy),
650
+ build_order=build_order,
651
+ )
652
+
653
+
654
+ def _decompose_scene(subject: str, detail_level: str) -> DecomposeResult:
655
+ """Decompose a scene subject into object-level hierarchy."""
656
+ # Generic scene decomposition
657
+ root = subject.lower().replace(" ", "_")
658
+ objects = ["ground_plane", "main_structure", "props", "lighting"]
659
+ if detail_level in ("detailed", "exhaustive"):
660
+ objects.extend(["vegetation", "furniture"])
661
+ if detail_level == "exhaustive":
662
+ objects.extend(["vehicles", "characters", "effects"])
663
+
664
+ hierarchy: dict[str, HierarchyNode] = {
665
+ root: HierarchyNode(name=root, parent=None, connection="", children=objects),
666
+ }
667
+ for obj in objects:
668
+ hierarchy[obj] = HierarchyNode(name=obj, parent=root, connection="placed_in")
669
+
670
+ build_order = [root] + objects
671
+ research_required = [
672
+ ResearchItem(part=name, needed=["dimensions"]) for name in hierarchy
673
+ ]
674
+
675
+ return DecomposeResult(
676
+ subject=subject,
677
+ hierarchy=hierarchy,
678
+ research_required=research_required,
679
+ total_parts=len(hierarchy),
680
+ build_order=build_order,
681
+ )
682
+
683
+
684
+ # ---------------------------------------------------------------------------
685
+ # Public API: research
686
+ # ---------------------------------------------------------------------------
687
+
688
+
689
+ def research(
690
+ hierarchy: dict[str, Any],
691
+ filled_data: dict[str, Any] | None = None,
692
+ ) -> ResearchResult:
693
+ """Validate completeness and sanity of research data against a hierarchy."""
694
+ filled = filled_data or {}
695
+ missing: list[MissingField] = []
696
+ warnings: list[ResearchWarning] = []
697
+
698
+ # Determine category for sanity checks
699
+ category = "furniture" # default
700
+ for cat, (lo, hi) in SANITY_RANGES.items():
701
+ # Heuristic: if any filled dimension fits this range, use it
702
+ for part_data in filled.values():
703
+ dims = part_data.get("dimensions", {})
704
+ for dim_key in ("w", "d", "h", "width", "depth", "height"):
705
+ val = dims.get(dim_key, 0)
706
+ if isinstance(val, (int, float)) and lo <= val <= hi:
707
+ category = cat
708
+ break
709
+
710
+ lo, hi = SANITY_RANGES.get(category, (0.001, 100.0))
711
+
712
+ # Determine required fields
713
+ detail_fields = ["dimensions", "material", "thickness"]
714
+
715
+ for part_name in hierarchy:
716
+ part_data = filled.get(part_name, {})
717
+
718
+ if not part_data:
719
+ missing.append(
720
+ MissingField(
721
+ part=part_name,
722
+ field="dimensions",
723
+ hint=f"Provide width, depth, height for {part_name}",
724
+ )
725
+ )
726
+ continue
727
+
728
+ # Check dimensions
729
+ dims = part_data.get("dimensions", {})
730
+ if not dims:
731
+ missing.append(
732
+ MissingField(
733
+ part=part_name,
734
+ field="dimensions",
735
+ hint=f"Provide width, depth, height for {part_name}",
736
+ )
737
+ )
738
+ else:
739
+ for dim_key, dim_label in [("w", "width"), ("d", "depth"), ("h", "height")]:
740
+ val = dims.get(dim_key, dims.get(dim_label, None))
741
+ if val is None:
742
+ missing.append(
743
+ MissingField(
744
+ part=part_name,
745
+ field=f"dimensions.{dim_label}",
746
+ hint=f"Missing {dim_label} for {part_name}",
747
+ )
748
+ )
749
+ elif isinstance(val, (int, float)):
750
+ if val <= 0:
751
+ warnings.append(
752
+ ResearchWarning(
753
+ part=part_name,
754
+ field=f"dimensions.{dim_label}",
755
+ value=val,
756
+ concern=f"{dim_label} must be positive",
757
+ )
758
+ )
759
+ elif val < lo or val > hi:
760
+ warnings.append(
761
+ ResearchWarning(
762
+ part=part_name,
763
+ field=f"dimensions.{dim_label}",
764
+ value=val,
765
+ concern=f"{dim_label}={val}m outside expected range [{lo}, {hi}]m for {category}",
766
+ )
767
+ )
768
+
769
+ # Check other fields
770
+ for field_name in detail_fields[1:]:
771
+ if field_name not in part_data:
772
+ missing.append(
773
+ MissingField(
774
+ part=part_name,
775
+ field=field_name,
776
+ hint=f"Provide {field_name} for {part_name}",
777
+ )
778
+ )
779
+
780
+ # ready_for_recipe = all parts have at minimum dimensions
781
+ dims_missing = any(
782
+ m.field == "dimensions" or m.field.startswith("dimensions.") for m in missing
783
+ )
784
+ complete = len(missing) == 0
785
+ ready = not dims_missing
786
+
787
+ return ResearchResult(
788
+ complete=complete,
789
+ missing=missing,
790
+ warnings=warnings,
791
+ ready_for_recipe=ready,
792
+ )