diff --git a/CHANGELOG.md b/CHANGELOG.md index 52d459b..efbb5c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,37 @@ +## [Unreleased] — 0.3.6-dev + +### Aggressive PRUNE_ALPHA (JPEG / opaque-constant Alpha unlink) +- Aggressive stills Make it Fast now plans, applies, and journals already-coded + `DEAD_CLOSURE_PRUNE` for **PRUNE_ALPHA only** (JPEG / no-alpha / IGNORE / + Value=1.0 Alpha unlink). No new Cycles RNA. `time_factor` stays 1.0. +- Preserve Look default, Balanced, and Video withhold it. Other PRUNE_* + (VOLUME / MIX / SSS / EMISSION / TRANSMISSION / BUMP / BEVEL / DISPLACE) + stay Manual-later at tier 2. +- Apply registers `_HANDLERS["DEAD_CLOSURE_PRUNE"]`; revert is existing + `NODE_UNLINK`. 0.3.5 `visual_guard` isolates the kind in its own group and + fail-closes (mean/p95 stills 0.003 / 0.012). +- Prior inventory, not a measured cut: loft DNA FIRED=7 JPEG, Classroom + FIRED=0. Manifest stays 0.3.5. Store plates stay Classroom 41% / loft 52%. + +### Aggressive CAMERA_CULL + distance cull (independent object sets) +- Aggressive `CAMERA_CULL` enables scene `use_camera_cull` and + `use_distance_cull` when the distance RNA exists, but uses **two disjoint + object sets** (kind name stays `CAMERA_CULL`). Cycles `object_cull.cpp` + ANDs when both object flags are on the same name; dual flags on the + CAMERA_CULL scatter list would keep nearby off-frustum chairs and regress + the Classroom cull slice of the 41% plate. +- `payload["objects"]`: camera-cull only (`use_camera_cull=True`). + `payload["distance_objects"]`: distance-cull only (`use_distance_cull=True`) + for tiny/scatter with `min_camera_distance` ≥ the margin that will be + written. Never both flags on the same object. +- Sets `distance_cull_margin` only when missing/None/0 to + `max(50.0, camera clip_end)` (else 50.0); never lowers a positive user + margin. Missing distance RNA still applies camera cull alone and ignores + `distance_objects`. Scene distance cull is enabled whenever RNA exists. +- Protects lights/volumes/cameras/heroes/emitters/shadow-catchers; linked + scatter remains eligible. No measured %; store plates stay Classroom 41% / + loft 52%. + ## [0.3.5] — 2026-08-30 ### Automatic visual rollback and Preserve Look benchmark lane diff --git a/docs/research/NEXT-LEVER.md b/docs/research/NEXT-LEVER.md index f6bf399..31f73ce 100644 --- a/docs/research/NEXT-LEVER.md +++ b/docs/research/NEXT-LEVER.md @@ -1,3 +1,50 @@ +# Next lever (2026-08-30 2pm ET PlugWalk) + +## 2pm — Aggressive CAMERA_CULL + distance cull (independent object sets) + +Build-order #1 locally: enable Cycles **distance cull** alongside camera cull +under the existing Aggressive `CAMERA_CULL` kind (no new kind name). + +**Cycles semantics (`intern/cycles/blender/object_cull.cpp`):** scene flags are +independent, but **both object flags on the same name → AND** (keep nearby +off-frustum objects for reflections). Camera-only and distance-only object +flags give independent cull. OR-on-same-object is wrong and would regress the +Classroom cull slice of the 41% plate. + +``` +return (camera_culled && distance_culled) // both object flags → AND + || (camera_culled && !use_distance_cull_) // camera-only object + || (distance_culled && !use_camera_cull_); // distance-only object +``` + +**Writes (journaled, speed tag):** +- Scene: `render.use_simplify`, `cycles.use_camera_cull`, and + `cycles.use_distance_cull` whenever distance RNA exists; + `cycles.distance_cull_margin` only when missing/None/0 → + `max(50.0, camera.data.clip_end)` when camera+clip_end finite, else `50.0`. + Never lower a positive user margin. Existing camera margin + simplify high + caps unchanged. +- `payload["objects"]` (camera-only set): `object.cycles.use_camera_cull=True` + only. Never `use_distance_cull` on these names. +- `payload["distance_objects"]` (distance-only set): tiny/scatter with + `min_camera_distance` ≥ the margin that will be written; same protections; + **disjoint** from the camera set; `object.cycles.use_distance_cull=True` + only. +- If distance RNA is missing, camera cull alone still succeeds and + `distance_objects` is ignored. + +**Protections unchanged:** lights / volumes / cameras / heroes / emitters / +shadow-catchers stay out; linked scatter is allowed. Preserve Look and +Balanced still withhold `CAMERA_CULL`. + +Authoritative report: `docs/research/PLUGWALK-2026-08-30-14.md` §1. + +No store claim change (Classroom **41%** / loft **52%**). No zip. No +`blender_manifest.toml` bump (stays **0.3.5**). Branch +`plugwalk/distance-cull-aggressive`. + +--- + # Next lever (2026-08-26 7pm ET PlugWalk) ## 7pm — clean demo bill (docs only; no 5th leftover lever) diff --git a/docs/research/PLUGWALK-2026-08-30-14.md b/docs/research/PLUGWALK-2026-08-30-14.md new file mode 100644 index 0000000..08aab41 --- /dev/null +++ b/docs/research/PLUGWALK-2026-08-30-14.md @@ -0,0 +1,335 @@ +# PlugWalk 2026-08-30 14:00 ET — next lever after distance-cull + +Docs only. Addon not modified. No zip. No invented %. No QuantTrace. +Store plates stay Classroom 41% / loft 52% until a measured GPU pair. +Official files: blender.org Classroom, loft, BMW27. + +--- + +## Ranked next lever + +**Wire already-coded `DEAD_CLOSURE_PRUNE` / `PRUNE_ALPHA` into Aggressive +(0.3.5 `visual_guard` fail-closed). Do not invent a new Cycles RNA.** + +Distance-cull is this hour's CAMERA_CULL work (see §1). After that lands, +the highest expected official-file cut is **not** another integrator +knob. It is the loft JPEG Alpha unlink that is already classified, +already apply/revert-capable, and still Auto-off waiting on Nick's 2080. + +Honest GPU-gate: **yes — PRUNE_ALPHA has a higher expected loft cut than +any new RNA in this inventory.** Classroom PRUNE_ALPHA=0, so this will +not move the Classroom 41% plate by itself. MESH_EMIT_SHADOW_SKIP +(Classroom=2) is the Classroom-side sibling, also GPU-gated, smaller +surface. + +--- + +## 1. `use_camera_cull` vs `use_distance_cull` — Cycles semantics + +Source: Blender `intern/cycles/blender/object_cull.cpp` (`BlenderObjectCulling`, +mainline). Manual: Object Properties → Visibility → Culling +(https://docs.blender.org/manual/en/latest/render/cycles/object_settings/object_data.html). + +Both tests are **sync-time exclusion** (`BlenderSync::sync_object` returns +`nullptr`; the object never enters `scene->objects` / the top-level BVH). +Lights are never culled (`object_is_light` branch). Panorama / multiview +disable both. `render.use_simplify` (`R_SIMPLIFY`) is required. Distance +margin `0` disables scene distance cull. + +### Scene flags are independent + +``` +use_scene_camera_cull_ = simplify && persp/ortho && !multiview && cycles.use_camera_cull +use_scene_distance_cull_ = simplify && persp/ortho && !multiview && cycles.use_distance_cull + && distance_cull_margin != 0 +``` + +Per object: + +``` +use_camera_cull_ = use_scene_camera_cull_ && object.cycles.use_camera_cull +use_distance_cull_ = use_scene_distance_cull_ && object.cycles.use_distance_cull +``` + +### Combination is OR of one-flag objects, AND of two-flag objects + +``` +camera_culled = use_camera_cull_ && test_camera(bb) // AABB vs worldtondc + margin +distance_culled = use_distance_cull_ && test_distance(bb) // AABB vs camera origin, squared + +return (camera_culled && distance_culled) // both object flags ON → AND + || (camera_culled && !use_distance_cull_) // camera-only object + || (distance_culled && !use_camera_cull_); // distance-only object +``` + +So: + +| Object flags | Culled when | +|---|---| +| camera only | outside frustum (margin) | +| distance only | farther than `distance_cull_margin` | +| **both** | **outside frustum AND farther than margin** | +| neither | never (scene flags are not enough) | + +AND-when-both is intentional (manual: keep nearby off-frustum objects +that still show in reflections). It is **not** independent OR on the +same object. + +### SceneQuant comment is half-right — do not AND the existing scatter list + +`speed_solver._camera_cull_actions` / `speed_apply._apply_camera_cull`: + +> Distance cull is AND with camera cull; we do not enable both. + +Correct **if both object flags are set on the same names**. Wrong as a +reason to leave the **scene** `use_distance_cull` off. + +**Independent OR (the useful shape):** + +1. Scene: `render.use_simplify`, `cycles.use_camera_cull`, **and** + `cycles.use_distance_cull` (margin > 0, factory 50). +2. Camera-cull scatter (today's tiny/off-frustum chairs): + `object.cycles.use_camera_cull = True` only. +3. Distance-cull far in-frustum tiny: `object.cycles.use_distance_cull = True` + only. +4. **Do not** set `use_distance_cull` on the existing CAMERA_CULL object + list. That would AND, and nearby off-frustum chairs that currently + drop would stay for reflections — a regression of the 41% plate's + cull slice. + +Distance-cull this hour belongs in Aggressive CAMERA_CULL **as a second +object set + the scene flag**, not as a second flag on the same objects. +Preserve Look / Balanced already block `CAMERA_CULL`. + +--- + +## 2. RNA / docs inventory vs owned kinds + +Cross-check: `planning/speed_solver.py` + `apply/speed_apply.py` `_HANDLERS`. +Do not re-propose these. + +### Already owned (Auto / profile-gated) + +`DEVICE_GPU`, `PERSISTENT_DATA`, `LOCK_INTERFACE`, `DEFORM_MBLUR_OFF`, +`ADAPTIVE_ON`, `MIN_SAMPLES`, `THRESHOLD_CAP`, `SAMPLES_CAP`, `DENOISE_ON`, +`AUTO_SCRAMBLE`, `PATH_GUIDING_OFF`, `WORLD_MIS_NONE`, `VOLUME_BOUNCES_ZERO`, +`HOMOGENEOUS_VOLUME`, `LIGHT_TREE`, `CAUSTICS_OFF`, `LIGHT_SAMPLING_THRESHOLD`, +`APPLY_PERCEPTUAL_PATHS`, `TRANSPARENT_SHADOW_CAP`, `FILTER_GLOSSY`, +`GPU_DENOISE`, `DENOISE_PREFILTER`, `COMPOSITOR_GPU`, `ANIMATED_SEED`, +`TRIM_OFFSCREEN`, `HIDE_OFFSCREEN_INSTANCES`, `SUBDIV_TRIM`, +`ADAPTIVE_SUBDIV_CAP`, `OFFSCREEN_DICING`, `MICRO_EMITTERS`, `CAMERA_CULL`, +`HAIR_RIBBONS`, `OPAQUE_CUTOUT_SHADOWS`, `PASS_PRUNE`, `CRYPTO_PRUNE`. + +Preserve Look allowlist is a subset (no lights/shadows/materials/visibility/ +bounces/caustics/glossy filter/emitter sampling/denoiser quality). +Balanced additionally blocks cull / subdiv / cutout shadows / path caps. + +### Already coded, Auto-off, waiting GPU (do not re-implement) + +| Kind | Classroom | loft | BMW27 | Notes | +|---|---|---|---|---| +| `DEAD_CLOSURE_PRUNE` / **PRUNE_ALPHA** | **0** | **7** JPEG | 0 | L1 graph write. Apply exists (`apply_dead_closures`). **Not in `_HANDLERS`.** | +| `UNUSED_SLOTS` | 0 | 4865 extra-attr apply of 21077 | 0 | VRAM/attr, not PT bill | +| `MESH_EMIT_SHADOW_SKIP` | **2** (`dayLight_portal`) | 0 | 0 | shadow vis off; never `is_portal` | +| `CLAMP_INDIRECT` | 0 (already 3.0) | 0 (key missing) | 0 | leftover-zero; Nick: stop stacking | +| `ZERO_ENERGY_LIGHT` / `ZERO_SHADER_LIGHT` / `ZERO_WORLD_BG` | 0 | 0 | 0 | leftover-zero; FIRED=0 | + +PRUNE_MIX / VOLUME / SSS / EMISSION / TRANSMISSION / BUMP / BEVEL / AOV = +0 on both interiors (DNA). L1.10 `PRUNE_NORMALMAP` Strength-0 = 0 +(loft 83 live maps). + +### Unowned Cycles RNA checked against Classroom / loft structure + +Sources: `intern/cycles/blender/addon/properties.py` (5.2/main), +Cycles 5.2 manual Sampling / Volumes / Performance / GPU, +release notes 5.0 rendering + GPU limitations. + +| Candidate | Why it is not the next lever | +|---|---| +| **Path guiding** (`use_guiding`, surface/volume, PAVMM/RIS) | GPU unsupported (manual: "not supported on any GPU"). Already `PATH_GUIDING_OFF` when on. Cannot move OptiX Classroom/loft. | +| **Light tree heuristics** | Owned `LIGHT_TREE` (off if ≤4 simple and no mesh lights; on if ≥16 / linking / mesh). Classroom 4 lamps + 2 mesh emit → tree on. loft 4 area. No new RNA. | +| **Volume step** (`volume_step_rate`, per-material step, `volume_biased`) | Classroom volumes 0. loft live glass (`Realistic_Glass_01` scatter 0.05 / absorb 1.0) — coarsening changes look. Preserve Look forbids. Aggressive-risky, not a bulk-interior bill. | +| **Shadow linking** | Classroom live 5.2 sets = 0. Policy-heavy (`INTERNALS` §2.5). Manual-later. | +| **Scrambling distance multiplier** | `AUTO_SCRAMBLE` already owns `auto_scrambling_distance` (GPU). Solver never writes `scrambling_distance`. Writing <1 is a look-risk duplicate, not a new class. Incompatible with blue-noise. | +| **Sample pattern** (Automatic / Classic tabulated Sobol / Blue-Noise) | Noise *appearance*, not wall-clock. Knee already owns spp. | +| **Filter glossy gates** | Owned `FILTER_GLOSSY` (`blur_glossy` 0→1.0). Preserve Look forbids. | +| **Transparent bounce waste** | Owned `TRANSPARENT_SHADOW_CAP` (MODE_MIN 8→4) + `APPLY_PERCEPTUAL_PATHS` (`transparent_max_bounces`). `min_transparent_bounces` default 0; raising it *increases* time. | +| **Portal Manual** | `PORTAL_MESH` / `BACKFACE_EMIT_OPAQUE` already inventory Classroom `dayLight_portal`. Never `Light.cycles.is_portal` (drops emission). | +| **Persistent data** | Owned `PERSISTENT_DATA`. First cold F12 still pays BVH. Does not move store plates. | +| **OptiX vs CUDA** | Prefs, not scene RNA. Kitchen is OptiX. `DEVICE_GPU` already owns scene device. | +| **Film / transparent** (`film_transparent_glass`) | Compositing look, not PT speed. | +| **Dicing camera** | Stills default = scene camera. loft dicing already owned (`ADAPTIVE_SUBDIV_CAP`, `OFFSCREEN_DICING`). | +| **Simplify AO / Fast GI** (`use_fast_gi`, `ao_bounces_render`) | Explicitly out of Auto. Changes GI. Never. | +| **`min_light_bounces`** | Default 0. Raising increases time. No leftover-high fire documented on official files. | +| **`emission_sampling` FRONT/BACK** | Classroom 170 mats AUTO; only two portal cards emit. NONE on a live window is forbidden. FRONT/BACK is Manual-first orientation proof — runner-up RNA, not this hour. | +| **`denoising_quality` BALANCED/FAST** | Preserve Look forbids denoiser quality. Aggressive look change; unmeasured. | +| **`direct_light_sampling_type`** | Unproven on interiors; noise distribution. | +| **`debug_use_spatial_splits` / adaptive kernel compile** | BVH/compile tradeoffs. Cold F12 may get *slower*. | +| **`texture_resolution_render` / `texture_limit_render`** | `TEX_LIMIT` class. Forbidden on the speed plan. | +| **World `max_bounces` 1024 (Classroom)** | World Strength proven 0 + `sampling_method=NONE`. Not sampled. RNA leftover, not a class. | + +Recent Blender 5.0 notes (shader closures/bundles, pass rename, "Use Nodes" +removed) do not add a stills speed RNA that fires on these files. +Path guiding remains CPU-only through 5.2. + +--- + +## 3. Why PRUNE_ALPHA, not a new RNA + +### Why it fires on loft (not Classroom) + +`PrincipledBsdfNode::has_surface_transparent()` +(`intern/cycles/scene/shader_nodes.cpp`): + +``` +return (alpha_in->link != NULL || alpha < (1.0f - CLOSURE_WEIGHT_CUTOFF)); +``` + +A JPEG Alpha → Principled Alpha **link** latches transparent even when +the file has no alpha. `simplify_settings` does not disconnect it. + +loft DNA (`BLENDER-v292`, `docs/research/NEXT-LEVER.md` + RECIPE R10/L1): +**PRUNE_ALPHA=7** (Carpet Low Grey + six bed mats; packed JPEG +`Baxter marilyn-Dirt 2.jpg`). KEEP_REAL_CUTOUT=5 plants stay. +Classroom PRUNE_ALPHA=0. BMW27=0. + +Scene-wide `transparent_shadows` stays on because loft has real plant +cutouts (R6 already wrote `OPAQUE_CUTOUT_SHADOWS` ×11). The leftover is +**per-primitive** `SD_HAS_TRANSPARENT_SHADOW` on those seven bulk +surfaces: shadow rays record, sort, shade, continue +(`intersect_filter.h`). Interior Shade Shadow + Intersect Shadow is +~1/4 of the PT bill (`INTERNALS-LEVERS` §0). That is a path-tracing +cut, not a leftover-zero hide. + +R6 already paid opaque-shadow RNA on some of the same names. PRUNE_ALPHA +is the **graph-flag** sibling (unlink Alpha, default 1.0), not a +duplicate RNA write. It can still be a no-op on the clock if R6 already +stopped those shadow walks — that is what the GPU pair answers. Do not +invent a %. + +### Profile placement + +| Profile | Place? | +|---|---| +| **Preserve Look** | **No.** Graph write. Materials / shadow response. | +| **Balanced** | **No.** Balanced blocks visibility / transparent-shadow / material look. | +| **Aggressive** | **Yes**, under 0.3.5 automatic visual_guard (mean/p95 stills 0.003 / 0.012, fail-closed). | +| **Manual** | Already the planner hook (`dead_closure_prune_actions`, not called from `build_speed_plan`). | + +Do not turn Auto on for Preserve Look. Do not ship a new store % from this +doc. + +### Look risk + +False-positive Alpha unlink on a real cutout → holes in camera *and* +shadows (worse than `use_transparent_shadow`). Classifier is already +conservative: JPEG / no-alpha / IGNORE, skip GROUP / glass / Light Path / +ramp / unknown channels. KEEP_REAL_CUTOUT=5. Revert = `NODE_UNLINK` +relink (journal already knows the kind). + +If visual_guard rejects the group, Aggressive keeps the current stack. +Fail-closed is the point of 0.3.5. + +### Official A/B (when the 2080 is free — warn Nick first) + +Kitchen = RECIPE-LOG: RTX 2080 Super Max-Q 8 GB, Blender 4.5.5 LTS OptiX, +factory-startup, **persistent off**, official loft (sha256 +`96d31b9c0df55592bde4a82d875e05acece67201d5df9cac40ef9d164a7c1840`), +native 1080×1350. + +1. Baseline = current Make it Fast **Aggressive** (existing stack + knee; + distance-cull if it has landed). +2. Fast = baseline + journaled **PRUNE_ALPHA only** (7 JPEG unlinks). + Same spp / denoise / paths. +3. Report: wall-clock (median of repeats), HDR-FLIP mean/p95, MAE/255, + visual_guard accept/rollback. Optional CPU `--cycles-print-stats` + Shade Shadow + Intersect Shadow on a 25% plate to *map* the bill; + claim clock stays the GPU pair. +4. Same pair on Classroom (expect FIRED=0 — documents the miss). +5. Revert smoke: every unlinked socket restored. + +Do not replace loft 52% unless the cut jumps a good amount on this pair. +Do not ping gibby. Do not zip. + +### Minimal implementation sketch (do not do it this hour) + +Already exists: `classify_dead_closures`, `apply_dead_closures`, +`restore_node_unlink`, planner `dead_closure_prune_actions`, tests +`test_dead_closures.py`. + +Missing glue: + +1. `build_speed_plan` / `_dead_actions`: if profile is Aggressive (and + stills), `actions.extend(dead_closure_prune_actions(...))` **or** + filter records to `PRUNE_ALPHA` only for the first GPU pair. +2. `speed_apply._HANDLERS["DEAD_CLOSURE_PRUNE"]` → + `dead_closures.apply_dead_closures(scene, jrnl, records)`. + **Not in `_HANDLERS` today** — a plan action would skip as unknown. +3. Visual-guard group = that one kind, independent journal run id + (0.3.5 already groups this way). +4. No new classifier. No leftover-zero. No QuantTrace. No Auto on + Preserve Look. + +--- + +## 4. GPU-gate note (honest) + +**Enabling already-coded PRUNE_ALPHA for Aggressive under 0.3.5 +visual_guard is a higher expected loft cut than any new RNA in §2.** + +Reasons, not a measured %: + +- It **fires** on an official interior (loft=7). New RNA either does not + fire (path guiding GPU, shadow linking 0, volume_step on Classroom, + leftover-zeros FIRED=0) or is already owned (light tree, filter glossy, + transparent cap, scramble auto, persistent, dicing). +- It hits the leftover **per-sample shadow** bill after the loft recipe + (paths + dicing + denoise, spp stays 512 on the cold 52% plate). A + settings dump cannot clear `has_surface_transparent` from a JPEG link. +- Fast GI / bounce nukes / texture limits are faster on paper and + forbidden (look). +- `UNUSED_SLOTS` fires bigger counts on loft but is an attribute/VRAM + union, not Shade Shadow. `MESH_EMIT_SHADOW_SKIP` fires on Classroom=2 + and is the right Classroom GPU-gate sibling; loft=0. + +Caveat: R6 already wrote `OPAQUE_CUTOUT_SHADOWS` on overlapping loft +names. PRUNE_ALPHA might add little clock. That is still a better first +GPU question than implementing `emission_sampling` or `volume_step_rate`. +If the pair is a miss, keep Manual and do not Auto. + +Classroom 41% will not move from PRUNE_ALPHA. Distance-cull (independent +object set, not AND on CAMERA_CULL names) is the Classroom-shaped +complement in Aggressive this hour; MESH_EMIT_SHADOW_SKIP is the next +Classroom GPU-gate after that. + +--- + +## 5. Do not do + +- Stack another Auto-off leftover-zero (`CLAMP_INDIRECT` / + `ZERO_ENERGY_*` / `ZERO_WORLD_BG`). FIRED=0 on official files. +- QuantTrace native slices (Nick: skip; push SceneQuant addon). +- Fast GI / Draft / `ao_bounces_render` / film tricks / TEX_LIMIT. +- `Light.cycles.is_portal` on Classroom window cards. +- Both object cull flags on the same CAMERA_CULL scatter list. +- Invent a loft or Classroom % . Store plates stay 41% / 52%. +- Modify the addon this hour. This file is the report. + +--- + +## Owned-kind checklist (so the next agent does not duplicate) + +Auto/profile: DEVICE_GPU PERSISTENT_DATA LOCK_INTERFACE DEFORM_MBLUR_OFF +ADAPTIVE_ON MIN_SAMPLES THRESHOLD_CAP SAMPLES_CAP DENOISE_ON AUTO_SCRAMBLE +PATH_GUIDING_OFF WORLD_MIS_NONE VOLUME_BOUNCES_ZERO HOMOGENEOUS_VOLUME +LIGHT_TREE CAUSTICS_OFF LIGHT_SAMPLING_THRESHOLD APPLY_PERCEPTUAL_PATHS +TRANSPARENT_SHADOW_CAP FILTER_GLOSSY GPU_DENOISE DENOISE_PREFILTER +COMPOSITOR_GPU ANIMATED_SEED TRIM_OFFSCREEN HIDE_OFFSCREEN_INSTANCES +SUBDIV_TRIM ADAPTIVE_SUBDIV_CAP OFFSCREEN_DICING MICRO_EMITTERS CAMERA_CULL +HAIR_RIBBONS OPAQUE_CUTOUT_SHADOWS PASS_PRUNE CRYPTO_PRUNE. + +Manual-later (not in `build_speed_plan`): DEAD_CLOSURE_PRUNE UNUSED_SLOTS +UNUSED_COLOR_ATTRS PORTAL_MESH MESH_EMIT_SHADOW_SKIP BACKFACE_EMIT_OPAQUE +CLAMP_INDIRECT ZERO_ENERGY_LIGHT ZERO_SHADER_LIGHT ZERO_WORLD_BG. + +Forbidden: QUANTIZE TEX_LIMIT DEDUP HALF_FLOAT DRAFT PARANOID_CULL Fast GI. diff --git a/docs/research/PLUGWALK-2026-08-30-15.md b/docs/research/PLUGWALK-2026-08-30-15.md new file mode 100644 index 0000000..9ee7ea9 --- /dev/null +++ b/docs/research/PLUGWALK-2026-08-30-15.md @@ -0,0 +1,110 @@ +# PlugWalk 2026-08-30 15:00 ET — Aggressive PRUNE_ALPHA under visual_guard + +Wire already-coded L1 dead-closure classifier into Aggressive Make it Fast. +No new Cycles RNA. No QuantTrace. No zip. No invented %. Manifest stays 0.3.5. +Store plates stay Classroom 41% / loft 52%. + +Authoritative prior: `docs/research/PLUGWALK-2026-08-30-14.md` §3 (not rewritten). + +--- + +## What landed + +Aggressive + Still Auto now plans **one** `DEAD_CLOSURE_PRUNE` whose payload +records are **PRUNE_ALPHA only** (JPEG / no-alpha / IGNORE / Value=1.0 Alpha +unlink). Apply uses payload records; Revert is existing `NODE_UNLINK`. +0.3.5 `visual_guard` puts the kind in its own group (`dead_closure_prune`) +and fail-closes. `time_factor` is 1.0. Auto label does not say "manual". +Auto tier is 1 so it clears `DEFAULT_TIER_MAX = 1`. + +Preserve Look default / missing profile: withheld (not on the allowlist). +Balanced: `BALANCED_BLOCKED_KINDS`. Video: `VIDEO_BLOCKED_KINDS` and an +Aggressive+VIDEO special-case so the graph write cannot flicker a shot. +Other PRUNE_* stay Manual-later (`dead_closure_prune_actions` default, tier 2). + +CAMERA_CULL disjoint-set logic on this branch is untouched. No leftover-zero +stacking, Fast GI, Draft, TEX_LIMIT, or `is_portal`. + +## Files + +- `scenequant/planning/speed_solver.py` — Auto call from `_dead_actions` + (`auto_alpha=True`, tier 1); policy blocks; manual hook unchanged (tier 2). +- `scenequant/apply/speed_apply.py` — `_HANDLERS["DEAD_CLOSURE_PRUNE"]`. +- `scenequant/analysis/visual_guard.py` — explicit isolated group. +- `scenequant/analysis/dead_closures.py` — comments only (classifier/apply + already skip linked / GROUP / glass / Light Path / ramp / unknown; + KEEP_REAL_CUTOUT unchanged). +- `tests/test_dead_closures.py`, `tests/test_speed_solver.py`, + `tests/test_visual_guard.py`. +- `CHANGELOG.md` under existing `## [Unreleased] — 0.3.6-dev`. + +## Tests run + +``` +cd /workspace/scenequant-public +python3 tests/test_dead_closures.py tests/test_speed_solver.py tests/test_visual_guard.py tests/test_preserve_look.py tests/test_portal_meshes.py +``` + +Results (box, no Nick GPU): + +- `tests/test_dead_closures.py`: ALL TESTS PASSED (216 ok / 0 fail) +- `tests/test_speed_solver.py`: ALL TESTS PASSED (260 ok / 0 fail) +- `tests/test_visual_guard.py`: ALL TESTS PASSED (15 ok / 0 fail) +- `tests/test_portal_meshes.py`: ALL TESTS PASSED (98 ok / 0 fail) +- `tests/test_preserve_look.py`: **not run** — `python3` has no `bpy`; + headless Blender invoke was blocked in this environment. Factory-scene + Preserve Look contract is unchanged (DEAD_CLOSURE_PRUNE is not on the + allowlist). + +Total headless: 589 ok / 0 fail. + +KEEP_REAL_CUTOUT / HASHED / glass / GROUP tests unchanged. + +## Why PRUNE_ALPHA-only + +`PrincipledBsdfNode::has_surface_transparent()` latches transparent from an +Alpha **link** even when the file has no alpha. loft DNA FIRED=7 JPEG +(Carpet Low Grey + six bed mats; packed `Baxter marilyn-Dirt 2.jpg`). +Classroom FIRED=0. BMW27=0. KEEP_REAL_CUTOUT=5 plants stay. + +This is a graph-flag sibling of R6 `OPAQUE_CUTOUT_SHADOWS`, not a new RNA. +VOLUME/MIX/SSS/EMISSION/TRANSMISSION/BUMP/BEVEL/DISPLACE are 0 on official +interiors and stay Manual. + +No store % from this wiring. R6 already wrote opaque-shadow RNA on overlapping +loft names; the GPU pair answers whether PRUNE_ALPHA still moves the clock. + +## GPU-gate remaining + +Official loft pair when the 2080 is free — **warn Nick first**. + +Kitchen = RECIPE-LOG: RTX 2080 Super Max-Q 8 GB, Blender 4.5.5 LTS OptiX, +factory-startup, persistent off, official loft +(sha256 `96d31b9c0df55592bde4a82d875e05acece67201d5df9cac40ef9d164a7c1840`), +native 1080×1350. + +1. Baseline = current Make it Fast Aggressive (existing stack + knee + + distance-cull if this branch is the baseline). +2. Fast = baseline + journaled PRUNE_ALPHA only (7 JPEG unlinks). Same spp / + denoise / paths. +3. Report wall-clock (median of repeats), HDR-FLIP mean/p95, MAE/255, + visual_guard accept/rollback. +4. Same pair on Classroom (expect FIRED=0). +5. Revert smoke: every unlinked socket restored. + +Do not replace loft 52% unless the cut jumps a good amount on this pair. +Do not zip. Do not ping gibby. + +## Next Classroom-shaped lever + +Already-coded `MESH_EMIT_SHADOW_SKIP` (Classroom=2 portal cards, loft=0) +still Manual. Never `Light.cycles.is_portal`. GPU-gated after this loft pair. + +## Do not + +- Zip / Downloads drop / gibby ping / listing swap. +- Merge to main. Leave PR 1 (CAMERA_CULL) open. +- Bump `scenequant/blender_manifest.toml` (stay 0.3.5). +- Invent a Classroom or loft %. Store plates stay 41% / 52%. +- Add `tools/_quanttrace_slice2bf_census.py`. +- Rewrite the 14:00 report. diff --git a/scenequant/analysis/dead_closures.py b/scenequant/analysis/dead_closures.py index 52f874c..93cf68f 100644 --- a/scenequant/analysis/dead_closures.py +++ b/scenequant/analysis/dead_closures.py @@ -7,7 +7,8 @@ # # Mix Shader Fac proven 0/1 + unused Transparent BSDF is # PRUNE_MIX_TRANSPARENT (L1.5). Apply unlinks the dead shader input -# (NODE_UNLINK passthrough). It is NOT wired into Make it Fast Auto. +# (NODE_UNLINK passthrough). VOLUME/MIX/SSS/EMISSION/TRANSMISSION/BUMP/ +# BEVEL/DISPLACE stay Manual. PRUNE_ALPHA is Auto on Aggressive stills. # Displacement proven-zero is PRUNE_DISPLACE (L1.6). Unlink the # Displacement socket so Cycles drops has_displacement. Same apply. # Subsurface Weight proven-zero is PRUNE_SSS (L1.7). Unlink Weight @@ -204,7 +205,11 @@ def _sock(owner, *names, collection="inputs"): sock = getter(name) if sock is not None: return sock - for sock in socks or (): + try: + iterator = iter(socks) + except TypeError: + return None + for sock in iterator: ident = getattr(sock, "identifier", None) name = getattr(sock, "name", None) if ident in names or name in names: @@ -216,7 +221,11 @@ def _iter_socks(owner, collection="inputs"): socks = getattr(owner, collection, None) if socks is None: return - for sock in socks or (): + try: + iterator = iter(socks) + except TypeError: + return + for sock in iterator: yield sock @@ -1343,7 +1352,8 @@ def _unlink_socket(tree, sock): def apply_dead_closures(scene, jrnl, records=None, tag="speed"): """Unlink proven-dead sockets. Journal one NODE_UNLINK per write. - Only PRUNE_* records are written. Not called by Make it Fast Auto. + Only PRUNE_* records are written. Aggressive Auto passes PRUNE_ALPHA + records via DEAD_CLOSURE_PRUNE; other classes remain Manual. Never writes scene.cycles.* or use_transparent_shadow. """ if records is None: diff --git a/scenequant/analysis/visual_guard.py b/scenequant/analysis/visual_guard.py index 360ec6b..2860a1c 100644 --- a/scenequant/analysis/visual_guard.py +++ b/scenequant/analysis/visual_guard.py @@ -27,6 +27,7 @@ "WORLD_MIS_NONE": ("proven_noop", "Proven dead render work"), "VOLUME_BOUNCES_ZERO": ("proven_noop", "Proven dead render work"), "HOMOGENEOUS_VOLUME": ("proven_noop", "Proven dead render work"), + "DEAD_CLOSURE_PRUNE": ("dead_closure_prune", "JPEG / opaque Alpha unlink"), } diff --git a/scenequant/apply/speed_apply.py b/scenequant/apply/speed_apply.py index e378057..c6cef5f 100644 --- a/scenequant/apply/speed_apply.py +++ b/scenequant/apply/speed_apply.py @@ -653,7 +653,10 @@ def _cull_object_skip(obj, scene, guard_cache): def _apply_camera_cull(scene, settings, jrnl, payload, cache, skipped, progress): jrnl.set_prop(scene, "cycles.use_camera_cull", True, SPEED_TAG) - # Distance cull is AND with camera cull — do not enable it here. + # Cycles object_cull.cpp: both object flags ON → AND (keep nearby + # off-frustum for reflections). Camera-only and distance-only sets are + # independent; never set both flags on the same object. Scene may enable + # both; object flags choose which test runs. # Fast GI stays out. Draft simplify caps (subdiv 2, particles 0.5) stay out. jrnl.set_prop(scene, "render.use_simplify", True, SPEED_TAG) rend = getattr(scene, "render", None) @@ -668,9 +671,25 @@ def _apply_camera_cull(scene, settings, jrnl, payload, cache, skipped, progress) margin = getattr(cycles, "camera_cull_margin", None) if cycles is not None else None if margin is None or margin == 0: jrnl.set_prop(scene, "cycles.camera_cull_margin", 0.1, SPEED_TAG) + distance_enabled = ( + cycles is not None and hasattr(cycles, "use_distance_cull")) + if distance_enabled: + # Prefer scene distance on whenever RNA exists (Manual path / later + # objects). Object flags still drive which objects actually cull. + jrnl.set_prop(scene, "cycles.use_distance_cull", True, SPEED_TAG) + if hasattr(cycles, "distance_cull_margin"): + d_margin = getattr(cycles, "distance_cull_margin", None) + if d_margin is None or d_margin == 0: + default_margin = speed_solver._distance_cull_margin_to_write( + scene, cycles) + jrnl.set_prop( + scene, "cycles.distance_cull_margin", default_margin, + SPEED_TAG) guard_cache = {} - tagged = 0 - for name in payload.get("objects") or (): + camera_names = list(payload.get("objects") or ()) + camera_set = set(camera_names) + camera_tagged = 0 + for name in camera_names: obj = scene.objects.get(name) if obj is None: skipped.append(_skip("CAMERA_CULL", name, "object missing")) @@ -680,14 +699,48 @@ def _apply_camera_cull(scene, settings, jrnl, payload, cache, skipped, progress) skipped.append(_skip("CAMERA_CULL", name, reason)) continue if jrnl.set_prop(obj, "cycles.use_camera_cull", True, SPEED_TAG): + # Never set use_distance_cull on camera-only names (AND regression). update = getattr(obj, "update_tag", None) if callable(update): update() - tagged += 1 + camera_tagged += 1 elif speed_solver._is_linked(obj): skipped.append(_skip( "CAMERA_CULL", name, "write did not stick (linked?)")) - return "camera cull on %d objects" % tagged + distance_tagged = 0 + if distance_enabled: + for name in payload.get("distance_objects") or (): + if name in camera_set: + skipped.append(_skip( + "CAMERA_CULL", name, + "distance+camera overlap blocked (Cycles AND-when-both)")) + continue + obj = scene.objects.get(name) + if obj is None: + skipped.append(_skip("CAMERA_CULL", name, "object missing")) + continue + reason = _cull_object_skip(obj, scene, guard_cache) + if reason: + skipped.append(_skip("CAMERA_CULL", name, reason)) + continue + oc = getattr(obj, "cycles", None) + if oc is None or not hasattr(oc, "use_distance_cull"): + skipped.append(_skip( + "CAMERA_CULL", name, "no object distance cull RNA")) + continue + if jrnl.set_prop(obj, "cycles.use_distance_cull", True, SPEED_TAG): + # Never set use_camera_cull on distance-only names. + update = getattr(obj, "update_tag", None) + if callable(update): + update() + distance_tagged += 1 + elif speed_solver._is_linked(obj): + skipped.append(_skip( + "CAMERA_CULL", name, "write did not stick (linked?)")) + if distance_enabled: + return ("camera cull on %d + distance cull on %d objects" + % (camera_tagged, distance_tagged)) + return "camera cull on %d objects" % camera_tagged def _apply_offscreen_dicing(scene, settings, jrnl, payload, cache, skipped, progress): @@ -837,6 +890,22 @@ def _apply_device_gpu(scene, settings, jrnl, payload, cache, skipped, progress): return None +def _apply_dead_closure_prune(scene, settings, jrnl, payload, cache, skipped, progress): + """Unlink proven-dead sockets from payload records. Revert is NODE_UNLINK.""" + try: + from ..analysis import dead_closures + except Exception: + skipped.append(_skip("DEAD_CLOSURE_PRUNE", "-", "dead_closures module missing")) + return None + records = payload.get("records") + if not records: + return None + applied = dead_closures.apply_dead_closures(scene, jrnl, records=records) + if applied: + return "unlinked %d dead-closure socket(s)" % len(applied) + return None + + def _object_write_skip(obj, scene, guard_cache): from .. import compat override = getattr(getattr(obj, "scenequant", None), "override", "AUTO") @@ -891,4 +960,5 @@ def _object_write_skip(obj, scene, guard_cache): "PASS_PRUNE": _apply_pass_prune, "CRYPTO_PRUNE": _apply_crypto_prune, "DEVICE_GPU": _apply_device_gpu, + "DEAD_CLOSURE_PRUNE": _apply_dead_closure_prune, } diff --git a/scenequant/planning/speed_solver.py b/scenequant/planning/speed_solver.py index 91595c6..9a1461c 100644 --- a/scenequant/planning/speed_solver.py +++ b/scenequant/planning/speed_solver.py @@ -58,17 +58,20 @@ "CAMERA_CULL", "HAIR_RIBBONS", "OPAQUE_CUTOUT_SHADOWS", + "DEAD_CLOSURE_PRUNE", } # Animation is less forgiving than a still: small per-frame sampling changes # become flicker, crawling edges, or denoiser smearing. Aggressive remains an -# explicit escape hatch; the two safer profiles obey this additional block. +# explicit escape hatch for sampling levers; DEAD_CLOSURE_PRUNE is withheld +# on video even in Aggressive. The two safer profiles obey this block fully. VIDEO_BLOCKED_KINDS = { "ANIMATED_SEED", "DENOISE_ON", "DENOISE_PREFILTER", "AUTO_SCRAMBLE", "LIGHT_TREE", + "DEAD_CLOSURE_PRUNE", } # Never in the default Make it Fast plan (VRAM / draft / opt-in). FORBIDDEN_KINDS = { @@ -298,6 +301,12 @@ def resolve_render_intent(scene, settings): def _allowed_by_policy(action, profile, intent): if profile == PROFILE_PRESERVE_LOOK: return action.kind in PRESERVE_LOOK_KINDS + # Graph-write JPEG/opaque Alpha unlink is Aggressive stills only. + # Aggressive remains the video escape hatch for sampling levers, but + # DEAD_CLOSURE_PRUNE must not flicker a stills-only material write. + if action.kind == "DEAD_CLOSURE_PRUNE" and ( + profile != PROFILE_AGGRESSIVE or intent == INTENT_VIDEO): + return False if profile == PROFILE_BALANCED and action.kind in BALANCED_BLOCKED_KINDS: return False if profile != PROFILE_AGGRESSIVE and intent == INTENT_VIDEO: @@ -797,9 +806,10 @@ def _dead_actions(scene, coverage, caveats): actions.extend(_crypto_actions(scene)) actions.extend(_pass_prune_actions(scene)) actions.extend(_opaque_cutout_shadow_actions(scene, caveats)) - # DEAD_CLOSURE_PRUNE lives in dead_closure_prune_actions (manual-later). - # Not in the default Auto plan until official Classroom/loft inventory - # proves candidates. No time claim. + # Auto Aggressive stills: PRUNE_ALPHA only (tier 1). Other PRUNE_* stay + # in the manual-later hook at tier 2. Preserve Look / Balanced / Video + # withhold via policy. No time claim. + actions.extend(dead_closure_prune_actions(scene, caveats, auto_alpha=True)) # UNUSED_SLOTS lives in unused_slots_actions (manual-later). # UNUSED_COLOR_ATTRS lives in unused_color_attrs_actions (manual-later). # PORTAL_MESH lives in portal_mesh_actions (manual-later). @@ -1148,49 +1158,119 @@ def _emission_strength_socket(node): return None +def _distance_cull_margin_to_write(scene, cycles): + """Margin Aggressive CAMERA_CULL will journal for scene distance cull. + + Positive user margins are kept. Missing/None/0 → max(50.0, camera + clip_end) when clip_end is a finite number, else 50.0. + """ + current = getattr(cycles, "distance_cull_margin", None) if cycles is not None else None + if isinstance(current, (int, float)) and current > 0: + return float(current) + default_margin = 50.0 + cam = getattr(scene, "camera", None) + data = getattr(cam, "data", None) if cam is not None else None + clip_end = getattr(data, "clip_end", None) if data is not None else None + if isinstance(clip_end, (int, float)): + try: + finite = clip_end == clip_end and abs(clip_end) != float("inf") + except Exception: + finite = False + if finite: + default_margin = max(50.0, float(clip_end)) + return default_margin + + +def _cull_scatter_eligible(obj): + """Shared protections for CAMERA_CULL camera-only and distance-only sets.""" + if obj is None or getattr(obj, "hide_render", False) or _protected(obj): + return False + if getattr(obj, "type", "") in ("LIGHT", "VOLUME", "CAMERA"): + return False + if _is_emissive(obj) or _instance_carries_light(obj): + return False + if getattr(obj, "is_shadow_catcher", False): + return False + oc = getattr(obj, "cycles", None) + if oc is not None and getattr(oc, "is_shadow_catcher", False): + return False + return True + + def _camera_cull_actions(scene, coverage, caveats): """Scatter/tiny only. Scene flag alone does nothing — objects listed too. - Never lights, heroes, volumes, shadow catchers. Linked scatter/tiny ARE - listed (Cycles per-object flag, not hide_render). Shared across local - helper scenes is fine: the flag is evaluated against the rendering camera. - Distance cull is AND with camera cull; we do not enable both. + Never lights, heroes, volumes, cameras, shadow catchers, or emitters. + Linked scatter/tiny ARE listed (Cycles per-object flag, not hide_render). + Shared across local helper scenes is fine: the flag is evaluated against + the rendering camera. + + Cycles ``object_cull.cpp``: both object flags ON → AND (keep nearby + off-frustum for reflections). Camera-only and distance-only flags on + separate objects give independent cull. Aggressive CAMERA_CULL therefore + uses two disjoint sets (kind name stays CAMERA_CULL): + + - ``objects``: camera-cull only (today's tiny/scatter remainder) + - ``distance_objects``: distance-cull only (tiny/scatter with + ``min_camera_distance`` ≥ the margin that will be written) + + Never put the same name in both lists. Scene may enable both cull flags; + object flags choose which test runs. Missing distance RNA → camera path + alone (``distance_objects`` ignored). """ if not coverage: return [] cycles = _cycles(scene) if cycles is None or not _has_attr(cycles, "use_camera_cull"): return [] - names = [] + has_distance = _has_attr(cycles, "use_distance_cull") + margin = _distance_cull_margin_to_write(scene, cycles) if has_distance else None + camera_names = [] + distance_names = [] for name, info in _sorted_coverage(coverage): if _cov_attr(info, "max_coverage", 1.0) >= TINY_COVERAGE: continue obj = _get_object(scene, name) - if obj is None or getattr(obj, "hide_render", False) or _protected(obj): - continue - if getattr(obj, "type", "") in ("LIGHT", "VOLUME", "CAMERA"): - continue - if _is_emissive(obj) or _instance_carries_light(obj): - continue - if getattr(obj, "is_shadow_catcher", False): + if not _cull_scatter_eligible(obj): continue oc = getattr(obj, "cycles", None) - if oc is not None and getattr(oc, "is_shadow_catcher", False): + # Partition: far tiny → distance-only; remainder → camera-only. + # Disjoint so Cycles never ANDs both flags on the same scatter name + # (would keep nearby off-frustum chairs and regress Classroom cull). + far_enough = False + if has_distance and margin is not None: + min_dist = _cov_attr(info, "min_camera_distance", 0.0) + try: + far_enough = ( + isinstance(min_dist, (int, float)) + and min_dist == min_dist + and abs(min_dist) != float("inf") + and float(min_dist) >= float(margin) + ) + except Exception: + far_enough = False + if far_enough: + if getattr(oc, "use_distance_cull", False): + continue + distance_names.append(name) continue # use_camera_cull is evaluated against the rendering camera, so a # chair also linked into a helper scene (Classroom dustParticules) # is still safe to tag. hide_render / trim keep used-outside. if getattr(oc, "use_camera_cull", False): continue - names.append(name) - if not names and getattr(cycles, "use_camera_cull", False): - return [] - if not names: + camera_names.append(name) + if not camera_names and not distance_names: return [] + payload = {"objects": camera_names} + if has_distance: + payload["distance_objects"] = distance_names return [SpeedAction( "CAMERA_CULL", - "%d scatter/tiny object(s) → camera cull, including linked" % len(names), - "dead", 1, 0.88, 1, {"objects": names})] + ("%d camera-cull + %d distance-cull scatter/tiny " + "(independent sets; Cycles AND-when-both)") + % (len(camera_names), len(distance_names)), + "dead", 1, 0.88, 1, payload)] def _hair_ribbon_actions(scene, coverage): @@ -1990,16 +2070,34 @@ def _load_dead_closures(): return None -def dead_closure_prune_actions(scene, caveats=None): - """Manual-later planner hook for L1 DEAD_CLOSURE_PRUNE. +def dead_closure_prune_actions(scene, caveats=None, *, auto_alpha=False): + """Planner hook for L1 DEAD_CLOSURE_PRUNE. - NOT called from build_speed_plan / _dead_actions. Auto stays off until - official-file inventory proves candidates. time_factor is 1.0 (no claim). + Manual-later (default): all PRUNE_CLASSES, tier 2, label says "manual". + Auto Aggressive stills: pass auto_alpha=True from _dead_actions — + PRUNE_ALPHA records only, tier 1, label does not say "manual". + time_factor is 1.0 (no claim). Policy withholds Preserve Look / Balanced + / Video; other PRUNE_* stay Manual. """ dead_closures = _load_dead_closures() if dead_closures is None: return [] - records = dead_closures.classify_dead_closures(scene) + try: + records = dead_closures.classify_dead_closures(scene) + except Exception: + return [] + if auto_alpha: + prunes = [r for r in records + if r.get("class") == dead_closures.PRUNE_ALPHA] + n = len(prunes) + if n < 1: + return [] + return [SpeedAction( + "DEAD_CLOSURE_PRUNE", + "%d JPEG / opaque-constant Alpha socket(s) -> unlink (PRUNE_ALPHA)" + % n, + "dead", 1, 1.0, 1, + {"records": prunes})] prunes = [r for r in records if r.get("class") in dead_closures.PRUNE_CLASSES] n_alpha = sum(1 for r in prunes if r.get("class") == dead_closures.PRUNE_ALPHA) n_vol = sum(1 for r in prunes if r.get("class") == dead_closures.PRUNE_VOLUME) diff --git a/tests/test_dead_closures.py b/tests/test_dead_closures.py index d6d7f3f..8ab7fce 100644 --- a/tests/test_dead_closures.py +++ b/tests/test_dead_closures.py @@ -815,8 +815,8 @@ def test_not_in_default_auto_plan(): "speed_solver.py") with open(path, encoding="utf-8") as handle: src = handle.read() - check(src.count("dead_closure_prune_actions(") == 1, - "dead_closure_prune_actions is defined once and not called from build_speed_plan") + check(src.count("dead_closure_prune_actions(") == 2, + "dead_closure_prune_actions is defined once and called from _dead_actions for Auto PRUNE_ALPHA") def speed_solver_scene(objects): @@ -1453,6 +1453,211 @@ def test_glass_zero_bump_skipped(): "glass material has no PRUNE_* writes") + +def _plan_mem(): + return Obj(total_mb=400.0, caveats=[], per_object_geo_mb={}, per_image_mb={}) + + +def _plan_settings(profile=None, intent="STILL"): + kw = dict(vram_budget_gb=8.0, min_texture_size=256, + coverage_frame_samples=5, quality_factor=2.0, + speed_render_intent=intent) + if profile is not None: + kw["speed_profile"] = profile + return Obj(**kw) + + +def _alpha_plan_scene(): + jpeg = _image_alpha_mat("WallPaint", 3, "//wall.jpg", file_format="JPEG") + val = _value_alpha_mat("Paint") + return speed_solver_scene([ + _mesh("Wall", material_slots=[Obj(material=jpeg)]), + _mesh("Wall2", material_slots=[Obj(material=val)]), + ]) + + +def test_aggressive_still_prunes_alpha_only(): + section("Aggressive still JPEG/Value-1.0 Alpha -> DEAD_CLOSURE_PRUNE PRUNE_ALPHA only") + scene = _alpha_plan_scene() + plan = speed_solver.build_speed_plan( + scene, {}, _plan_mem(), _plan_settings("AGGRESSIVE", "STILL")) + hits = [a for a in plan.actions if a.kind == "DEAD_CLOSURE_PRUNE"] + check(len(hits) == 1, "Aggressive still has one DEAD_CLOSURE_PRUNE") + recs = hits[0].payload.get("records") or [] + check(len(recs) >= 2 and all(r.get("class") == dc.PRUNE_ALPHA for r in recs), + "payload records are PRUNE_ALPHA only") + check(hits[0].time_factor == 1.0, "time_factor stays 1.0") + check(hits[0].tier <= 1, "Auto PRUNE_ALPHA is tier <= 1") + check("manual" not in (hits[0].label or "").lower(), + "Auto label does not say manual") + other = {dc.PRUNE_VOLUME, dc.PRUNE_MIX_TRANSPARENT, dc.PRUNE_DISPLACE, + dc.PRUNE_SSS, dc.PRUNE_EMISSION, dc.PRUNE_TRANSMISSION, + dc.PRUNE_BUMP, dc.PRUNE_BEVEL} + check(not any(r.get("class") in other for r in recs), + "Auto payload does not include non-alpha PRUNE_*") + + +def test_preserve_look_and_missing_profile_withhold_prune_alpha(): + section("Preserve Look / missing profile withhold DEAD_CLOSURE_PRUNE") + scene = _alpha_plan_scene() + for label, settings in ( + ("PRESERVE_LOOK", _plan_settings("PRESERVE_LOOK", "STILL")), + ("missing profile", _plan_settings(None, "STILL")), + ): + plan = speed_solver.build_speed_plan(scene, {}, _plan_mem(), settings) + kinds = [a.kind for a in plan.actions] + check("DEAD_CLOSURE_PRUNE" not in kinds, + "%s plan does not include DEAD_CLOSURE_PRUNE" % label) + + +def test_balanced_withholds_prune_alpha(): + section("Balanced withholds DEAD_CLOSURE_PRUNE") + scene = _alpha_plan_scene() + plan = speed_solver.build_speed_plan( + scene, {}, _plan_mem(), _plan_settings("BALANCED", "STILL")) + kinds = [a.kind for a in plan.actions] + check("DEAD_CLOSURE_PRUNE" not in kinds, + "Balanced plan does not include DEAD_CLOSURE_PRUNE") + check("DEAD_CLOSURE_PRUNE" in speed_solver.BALANCED_BLOCKED_KINDS, + "DEAD_CLOSURE_PRUNE is in BALANCED_BLOCKED_KINDS") + check("DEAD_CLOSURE_PRUNE" in (plan.withheld_kinds or []), + "Balanced reports DEAD_CLOSURE_PRUNE as withheld") + + +def test_aggressive_video_withholds_prune_alpha(): + section("Aggressive VIDEO withholds DEAD_CLOSURE_PRUNE") + scene = _alpha_plan_scene() + plan = speed_solver.build_speed_plan( + scene, {}, _plan_mem(), _plan_settings("AGGRESSIVE", "VIDEO")) + kinds = [a.kind for a in plan.actions] + check("DEAD_CLOSURE_PRUNE" not in kinds, + "Aggressive VIDEO plan does not include DEAD_CLOSURE_PRUNE") + check("DEAD_CLOSURE_PRUNE" in speed_solver.VIDEO_BLOCKED_KINDS, + "DEAD_CLOSURE_PRUNE is in VIDEO_BLOCKED_KINDS") + check("DEAD_CLOSURE_PRUNE" in (plan.withheld_kinds or []), + "Aggressive VIDEO reports DEAD_CLOSURE_PRUNE as withheld") + + +def test_aggressive_non_alpha_prunes_stay_manual(): + section("Aggressive Auto skips VOLUME/SSS/etc; manual hook still returns them") + vol = _empty_volume_mat("Hollow") + sss = _value_sss_mat("Skin") + emit = _value_emission_mat("DarkEmit") + scene = speed_solver_scene([ + _mesh("Box", material_slots=[Obj(material=vol)]), + _mesh("Arm", material_slots=[Obj(material=sss)]), + _mesh("Card", material_slots=[Obj(material=emit)]), + ]) + plan = speed_solver.build_speed_plan( + scene, {}, _plan_mem(), _plan_settings("AGGRESSIVE", "STILL")) + kinds = [a.kind for a in plan.actions] + check("DEAD_CLOSURE_PRUNE" not in kinds, + "no Auto DEAD_CLOSURE_PRUNE without PRUNE_ALPHA") + manual = speed_solver.dead_closure_prune_actions(scene) + check(len(manual) == 1 and manual[0].kind == "DEAD_CLOSURE_PRUNE", + "manual hook still returns non-alpha PRUNE_*") + recs = manual[0].payload.get("records") or [] + classes = {r.get("class") for r in recs} + check(dc.PRUNE_VOLUME in classes and dc.PRUNE_SSS in classes + and dc.PRUNE_EMISSION in classes, + "manual hook payload includes VOLUME/SSS/EMISSION") + check(dc.PRUNE_ALPHA not in classes, "this scene has no PRUNE_ALPHA") + check(manual[0].tier == 2, "manual-all-classes hook stays tier 2") + check("manual" in (manual[0].label or "").lower(), + "manual hook label still says manual") + + +def test_empty_aggressive_has_no_dead_closure_prune(): + section("empty scene Aggressive has no DEAD_CLOSURE_PRUNE") + scene = speed_solver_scene([]) + plan = speed_solver.build_speed_plan( + scene, {}, _plan_mem(), _plan_settings("AGGRESSIVE", "STILL")) + kinds = [a.kind for a in plan.actions] + check("DEAD_CLOSURE_PRUNE" not in kinds, + "empty Aggressive plan has no DEAD_CLOSURE_PRUNE") + + +def _load_speed_apply(): + import importlib.util + import types + bpy_mod = types.ModuleType("bpy") + bpy_mod.types = types.SimpleNamespace() + saved = sys.modules.get("bpy") + sys.modules["bpy"] = bpy_mod + created = [] + + def _ensure(name, rel): + if name in sys.modules and getattr(sys.modules[name], "__path__", None): + return sys.modules[name] + mod = types.ModuleType(name) + mod.__path__ = [os.path.join(PROJECT_ROOT, *rel.split("/"))] + mod.__package__ = name + sys.modules[name] = mod + created.append(name) + return mod + + try: + _ensure("scenequant", "scenequant") + _ensure("scenequant.analysis", "scenequant/analysis") + _ensure("scenequant.planning", "scenequant/planning") + _ensure("scenequant.apply", "scenequant/apply") + cov = types.ModuleType("scenequant.analysis.coverage") + sys.modules["scenequant.analysis.coverage"] = cov + presets = types.ModuleType("scenequant.planning.presets") + presets.TIER_PERCEPTUAL = () + presets.TIER_LOSSLESS = () + presets.MODE_SET = "set" + presets.MODE_MIN = "min" + presets.MODE_MAX = "max" + sys.modules["scenequant.planning.presets"] = presets + sys.modules["scenequant.planning.speed_solver"] = speed_solver + for name in ("guards", "objects_apply", "settings_apply"): + full = "scenequant.apply.%s" % name + sys.modules[full] = types.ModuleType(full) + path = os.path.join(PROJECT_ROOT, "scenequant", "apply", "speed_apply.py") + spec = importlib.util.spec_from_file_location( + "scenequant.apply.speed_apply", path) + mod = importlib.util.module_from_spec(spec) + mod.__package__ = "scenequant.apply" + sys.modules["scenequant.apply.speed_apply"] = mod + spec.loader.exec_module(mod) + finally: + if saved is None: + sys.modules.pop("bpy", None) + else: + sys.modules["bpy"] = saved + return mod + + +def test_handler_unlinks_and_revert_restores(): + section("DEAD_CLOSURE_PRUNE handler unlinks; revert restores NODE_UNLINK") + speed_apply = _load_speed_apply() + check("DEAD_CLOSURE_PRUNE" in speed_apply._HANDLERS, + "DEAD_CLOSURE_PRUNE is registered in _HANDLERS") + mat = _value_alpha_mat("Paint") + scene = _with_obj(mat, "Wall") + alpha = None + for node in mat.node_tree.nodes: + if node.type == "BSDF_PRINCIPLED": + alpha = node.inputs.get("Alpha") + check(alpha is not None and alpha.is_linked, "fixture Alpha starts linked") + records = [r for r in dc.classify_dead_closures(scene) + if r.get("class") == dc.PRUNE_ALPHA] + check(len(records) == 1, "one PRUNE_ALPHA record for handler") + jrnl = _Journal() + skipped = [] + msg = speed_apply._HANDLERS["DEAD_CLOSURE_PRUNE"]( + scene, Obj(), jrnl, {"records": records}, {}, skipped, None) + check(not skipped, "handler did not skip") + check(msg and "1" in msg, "handler returns a short unlink summary") + check(not alpha.is_linked, "handler unlinked Alpha") + check(jrnl.entries and jrnl.entries[0]["kind"] == "NODE_UNLINK", + "handler journals NODE_UNLINK") + restored = dc.revert_dead_closures(scene, jrnl) + check(restored == 1 and alpha.is_linked, "revert restores the Alpha link") + check(not jrnl.entries, "successful revert consumes the NODE_UNLINK entry") + + def main(): test_value_one_prunes_alpha() test_jpeg_prunes_alpha() @@ -1502,6 +1707,13 @@ def main(): test_not_in_default_auto_plan() test_inventory_print_shape() test_default_plan_kind_absent_empty() + test_aggressive_still_prunes_alpha_only() + test_preserve_look_and_missing_profile_withhold_prune_alpha() + test_balanced_withholds_prune_alpha() + test_aggressive_video_withholds_prune_alpha() + test_aggressive_non_alpha_prunes_stay_manual() + test_empty_aggressive_has_no_dead_closure_prune() + test_handler_unlinks_and_revert_restores() finish() diff --git a/tests/test_speed_solver.py b/tests/test_speed_solver.py index a62f9ee..94f0db5 100644 --- a/tests/test_speed_solver.py +++ b/tests/test_speed_solver.py @@ -51,6 +51,8 @@ def _scene(**cycles_kw): use_guiding=False, use_animated_seed=True, use_camera_cull=False, + use_distance_cull=False, + distance_cull_margin=0.0, denoising_use_gpu=True, ) cycles_kw_full.update(cycles_kw) @@ -490,6 +492,8 @@ def test_linked_cull_is_loud(): "linked scatter object is camera-culled") check(all("not camera-culled (linked" not in c for c in plan.caveats), "linked cull is not caveated as skipped") + check("distance" in (culls[0].label or "").lower(), + "CAMERA_CULL description mentions distance cull") def test_hero_tiny_not_camera_culled(): @@ -1356,7 +1360,9 @@ def test_linked_cull_ignores_library_scenes(): check("Catch" not in culled, "shadow catchers stay out of CAMERA_CULL") check("ceilingLamp" not in culled, "light instances stay out of CAMERA_CULL") check(all(a.kind != "DISTANCE_CULL" for a in plan.actions), - "distance cull stays off") + "no separate DISTANCE_CULL kind (folded into CAMERA_CULL)") + check(culls and "distance" in (culls[0].label or "").lower(), + "Aggressive CAMERA_CULL description includes distance cull") check(all("not camera-culled (used outside" not in c for c in plan.caveats), "library scenes do not caveat used-outside") @@ -1605,6 +1611,264 @@ def test_auto_scramble_gpu_only(): "both stay at tier 1") + +def _load_speed_apply_fake_bpy(): + """Load apply/speed_apply.py against a stubbed package (no Blender).""" + import importlib.util + import types + bpy_mod = types.ModuleType("bpy") + bpy_mod.types = types.SimpleNamespace() + saved_bpy = sys.modules.get("bpy") + sys.modules["bpy"] = bpy_mod + + created = [] + + def _ensure_pkg(name, rel): + if name in sys.modules and getattr(sys.modules[name], "__path__", None): + return sys.modules[name] + mod = types.ModuleType(name) + mod.__path__ = [os.path.join(PROJECT_ROOT, *rel.split("/"))] + mod.__package__ = name + sys.modules[name] = mod + created.append(name) + return mod + + try: + _ensure_pkg("scenequant", "scenequant") + _ensure_pkg("scenequant.analysis", "scenequant/analysis") + _ensure_pkg("scenequant.planning", "scenequant/planning") + _ensure_pkg("scenequant.apply", "scenequant/apply") + + cov = types.ModuleType("scenequant.analysis.coverage") + sys.modules["scenequant.analysis.coverage"] = cov + created.append("scenequant.analysis.coverage") + + presets = types.ModuleType("scenequant.planning.presets") + presets.TIER_PERCEPTUAL = () + presets.TIER_LOSSLESS = () + presets.MODE_SET = "set" + presets.MODE_MIN = "min" + presets.MODE_MAX = "max" + sys.modules["scenequant.planning.presets"] = presets + created.append("scenequant.planning.presets") + + # Reuse the already-loaded duck-friendly speed_solver under package name. + sys.modules["scenequant.planning.speed_solver"] = speed_solver + created.append("scenequant.planning.speed_solver") + + for name in ("guards", "objects_apply", "settings_apply"): + full = "scenequant.apply.%s" % name + sys.modules[full] = types.ModuleType(full) + created.append(full) + + path = os.path.join(PROJECT_ROOT, "scenequant", "apply", "speed_apply.py") + spec = importlib.util.spec_from_file_location( + "scenequant.apply.speed_apply", path) + mod = importlib.util.module_from_spec(spec) + mod.__package__ = "scenequant.apply" + sys.modules["scenequant.apply.speed_apply"] = mod + created.append("scenequant.apply.speed_apply") + spec.loader.exec_module(mod) + finally: + if saved_bpy is None: + sys.modules.pop("bpy", None) + else: + sys.modules["bpy"] = saved_bpy + return mod + + +class _DuckJournal: + """Dotted RNA set_prop for speed_apply duck tests (no real journal/bpy).""" + + def __init__(self): + self.entries = [] + + def set_prop(self, datablock, rna_path, value, tag=None, **kwargs): + owner = datablock + parts = rna_path.split(".") + for part in parts[:-1]: + owner = getattr(owner, part, None) + if owner is None: + return False + attr = parts[-1] + if not hasattr(owner, attr): + return False + old = getattr(owner, attr) + if old == value: + return False + setattr(owner, attr, value) + self.entries.append({ + "path": rna_path, "old": old, "new": value, "tag": tag, + "owner": datablock, + }) + return True + + +class _ObjMap(dict): + def get(self, name, default=None): + return dict.get(self, name, default) + + +def test_apply_camera_distance_cull_when_present(): + section("apply CAMERA_CULL uses disjoint camera/distance object sets") + speed_apply = _load_speed_apply_fake_bpy() + tags = [] + chair = Obj( + name="Chair.001", type="MESH", + scenequant=Obj(override="AUTO"), is_shadow_catcher=False, + cycles=Obj(use_camera_cull=False, use_distance_cull=False), + update_tag=lambda: tags.append("Chair.001")) + far = Obj( + name="FarProp", type="MESH", + scenequant=Obj(override="AUTO"), is_shadow_catcher=False, + cycles=Obj(use_camera_cull=False, use_distance_cull=False), + update_tag=lambda: tags.append("FarProp")) + scene = Obj( + cycles=Obj( + use_camera_cull=False, use_distance_cull=False, + camera_cull_margin=0.0, distance_cull_margin=0.0), + render=Obj( + use_simplify=False, simplify_subdivision_render=6, + simplify_child_particles_render=1.0), + camera=Obj(data=Obj(clip_end=100.0)), + objects=_ObjMap({"Chair.001": chair, "FarProp": far}), + ) + jrnl = _DuckJournal() + msg = speed_apply._apply_camera_cull( + scene, Obj(), jrnl, + {"objects": ["Chair.001"], "distance_objects": ["FarProp"]}, + {}, [], None) + check(scene.cycles.use_camera_cull is True, "scene camera cull on") + check(scene.cycles.use_distance_cull is True, "scene distance cull on") + check(scene.cycles.distance_cull_margin == 100.0, + "distance margin = max(50, clip_end=100)") + check(chair.cycles.use_camera_cull is True, "camera object gets use_camera_cull") + check(chair.cycles.use_distance_cull is False, + "camera object must NOT get use_distance_cull") + check(far.cycles.use_distance_cull is True, + "distance object gets use_distance_cull") + check(far.cycles.use_camera_cull is False, + "distance object must NOT get use_camera_cull") + check(tags == ["Chair.001", "FarProp"], "update_tag after each object write") + paths = [e["path"] for e in jrnl.entries] + check("cycles.use_distance_cull" in paths, "journaled scene distance cull") + check(paths.count("cycles.use_distance_cull") == 2, + "journaled scene + one distance-only object (not camera list)") + check("distance" in (msg or "").lower(), "return message mentions distance") + check(msg == "camera cull on 1 + distance cull on 1 objects", + "return message reports both set counts") + + # Never lower a positive user distance margin. + scene2 = Obj( + cycles=Obj( + use_camera_cull=False, use_distance_cull=False, + camera_cull_margin=0.1, distance_cull_margin=75.0), + render=Obj( + use_simplify=True, simplify_subdivision_render=6, + simplify_child_particles_render=1.0), + camera=Obj(data=Obj(clip_end=200.0)), + objects=_ObjMap({"Chair.001": Obj( + name="Chair.001", type="MESH", + scenequant=Obj(override="AUTO"), is_shadow_catcher=False, + cycles=Obj(use_camera_cull=False, use_distance_cull=False), + update_tag=lambda: None)}), + ) + jrnl2 = _DuckJournal() + speed_apply._apply_camera_cull( + scene2, Obj(), jrnl2, {"objects": ["Chair.001"]}, {}, [], None) + check(scene2.cycles.distance_cull_margin == 75.0, + "positive user distance margin not lowered") + check(all(e["path"] != "cycles.distance_cull_margin" for e in jrnl2.entries), + "no distance_cull_margin write when user margin > 0") + check(scene2.cycles.use_distance_cull is True, + "scene distance cull still enabled with empty distance_objects") + + +def test_apply_camera_cull_ok_without_distance_rna(): + section("apply CAMERA_CULL succeeds when distance RNA missing") + speed_apply = _load_speed_apply_fake_bpy() + chair = Obj( + name="Chair.001", type="MESH", + scenequant=Obj(override="AUTO"), is_shadow_catcher=False, + cycles=Obj(use_camera_cull=False), + update_tag=lambda: None) + far = Obj( + name="FarProp", type="MESH", + scenequant=Obj(override="AUTO"), is_shadow_catcher=False, + cycles=Obj(use_camera_cull=False), + update_tag=lambda: None) + scene = Obj( + cycles=Obj(use_camera_cull=False, camera_cull_margin=0.0), + render=Obj( + use_simplify=False, simplify_subdivision_render=6, + simplify_child_particles_render=1.0), + camera=Obj(data=Obj(clip_end=50.0)), + objects=_ObjMap({"Chair.001": chair, "FarProp": far}), + ) + jrnl = _DuckJournal() + msg = speed_apply._apply_camera_cull( + scene, Obj(), jrnl, + {"objects": ["Chair.001"], "distance_objects": ["FarProp"]}, + {}, [], None) + check(scene.cycles.use_camera_cull is True, "camera cull alone still on") + check(not hasattr(scene.cycles, "use_distance_cull"), + "distance attr remains absent on scene") + check(chair.cycles.use_camera_cull is True, "object camera cull on") + check(not hasattr(chair.cycles, "use_distance_cull"), + "distance attr remains absent on camera object") + check(far.cycles.use_camera_cull is False, + "distance_objects ignored without distance RNA") + check(all(e["path"] != "cycles.use_distance_cull" for e in jrnl.entries), + "no distance journal writes without RNA") + check("distance" not in (msg or "").lower(), + "return message is camera-only without distance RNA") + check("camera cull on 1 objects" == msg, "camera-only message shape") + + +def test_camera_cull_partitions_far_tiny_to_distance_only(): + section("planner splits far tiny into distance_objects (no overlap)") + scene = _scene() + scene.camera = Obj(data=Obj(clip_end=50.0)) + near = _mesh("NearChair") + far = _mesh("FarScatter") + scene.objects = [near, far] + cov = { + "NearChair": { + "max_coverage": 0.002, "in_frustum_ever": False, + "near_frustum_ever": False, "min_camera_distance": 10.0, + }, + "FarScatter": { + "max_coverage": 0.002, "in_frustum_ever": True, + "near_frustum_ever": True, "min_camera_distance": 80.0, + }, + } + plan = speed_solver.build_speed_plan(scene, cov, _mem(), _settings()) + culls = [a for a in plan.actions if a.kind == "CAMERA_CULL"] + check(len(culls) == 1, "one CAMERA_CULL action") + cam_objs = culls[0].payload.get("objects") or [] + dist_objs = culls[0].payload.get("distance_objects") or [] + check(cam_objs == ["NearChair"], "near tiny is camera-only") + check(dist_objs == ["FarScatter"], "far tiny is distance-only") + check(not (set(cam_objs) & set(dist_objs)), "object sets are disjoint") + check("independent" in (culls[0].label or "").lower() + or "and-when-both" in (culls[0].label or "").lower(), + "description names independent sets / AND-when-both") + + + +def test_dead_closure_prune_policy_and_handler(): + section("DEAD_CLOSURE_PRUNE policy and handler registration") + check("DEAD_CLOSURE_PRUNE" not in speed_solver.PRESERVE_LOOK_KINDS, + "Preserve Look allowlist excludes DEAD_CLOSURE_PRUNE") + check("DEAD_CLOSURE_PRUNE" in speed_solver.BALANCED_BLOCKED_KINDS, + "Balanced blocks DEAD_CLOSURE_PRUNE") + check("DEAD_CLOSURE_PRUNE" in speed_solver.VIDEO_BLOCKED_KINDS, + "Video blocks DEAD_CLOSURE_PRUNE") + speed_apply = _load_speed_apply_fake_bpy() + check("DEAD_CLOSURE_PRUNE" in speed_apply._HANDLERS, + "DEAD_CLOSURE_PRUNE is in _HANDLERS") + + def main(): test_independence() test_default_plan_filters() @@ -1632,6 +1896,10 @@ def main(): test_used_outside_ignores_library_scenes() test_linked_cull_ignores_library_scenes() test_two_local_scenes_still_skip_camera_cull() + test_apply_camera_distance_cull_when_present() + test_apply_camera_cull_ok_without_distance_rna() + test_camera_cull_partitions_far_tiny_to_distance_only() + test_dead_closure_prune_policy_and_handler() test_filter_glossy_proven_only() test_auto_scramble_gpu_only() finish() diff --git a/tests/test_visual_guard.py b/tests/test_visual_guard.py index 90f32da..0cb869f 100644 --- a/tests/test_visual_guard.py +++ b/tests/test_visual_guard.py @@ -52,6 +52,29 @@ def main(): check(missing["passed"] is False, "incomplete video evidence fails closed") + section("DEAD_CLOSURE_PRUNE has its own fail-closed group") + mixed = [ + {"kind": "ADAPTIVE_ON"}, + {"kind": "DEVICE_GPU"}, + {"kind": "LOCK_INTERFACE"}, + {"kind": "DEAD_CLOSURE_PRUNE"}, + {"kind": "MIN_SAMPLES"}, + {"kind": "PERSISTENT_DATA"}, + ] + grouped = visual_guard.group_speed_actions(mixed) + by_key = {g["key"]: g for g in grouped} + check("dead_closure_prune" in by_key, + "DEAD_CLOSURE_PRUNE has an explicit isolated group") + prune_kinds = [a["kind"] for a in by_key["dead_closure_prune"]["actions"]] + check(prune_kinds == ["DEAD_CLOSURE_PRUNE"], + "dead_closure_prune group contains only DEAD_CLOSURE_PRUNE") + for key in ("sampling", "backend", "runtime"): + kinds = [a["kind"] for a in by_key[key]["actions"]] + check("DEAD_CLOSURE_PRUNE" not in kinds, + "DEAD_CLOSURE_PRUNE is not bundled with %s" % key) + check(by_key["sampling"]["actions"][0]["kind"] == "ADAPTIVE_ON", + "sampling group still coalesces known sampling actions") + section("temporal residual") temporal = visual_guard.temporal_residual_metrics( truth, truth, truth, local)