feat(dispatch): analyze_with_dispatch_plan — stages 0+1.5 (#53) - #54
feat(dispatch): analyze_with_dispatch_plan — stages 0+1.5 (#53)#54lilith wants to merge 9 commits into
Conversation
585d355 to
5c73277
Compare
…, multi-metric/RD-time guidance
Adds a 600-line cross-codec reference at zentrain/PRINCIPLES.md
covering what every picker / scorer consumer (zenjpeg, zenwebp,
zenavif, zenjxl, zenpng, zengif, zenpicker, zensim) must follow.
Sections:
1. Crate map (one-line orientation per crate)
2. What a picker is (the fn shape + bin contract)
3. Data discipline:
- sweep four dimensions
- per-(image, size) zensim ceiling (#51)
- sample-count floors → NaN dropout (#49)
- pixel_budget train↔runtime match (#48)
- OOD bounds in every bake
- re-bake triggers
4. Argmin objective (composing scores at codec edge):
- default size-optimal recipe
- hard time cap
- soft RD-vs-time (μ bytes/ms)
- time-to-percent-saved
- multi-metric: one bake per metric vs single multi-output
vs metric-as-input — recommendation A (one bake per metric)
- RD-vs-time bake (#56) protocol
5. Runtime contract (codec-side pseudocode template)
6. Per-codec adoption notes:
zenjpeg / zenwebp / zenavif / zenjxl (lossy + lossless) /
zenpng / zengif / zenpicker / zensim
7. Default settings (trainer flags, bake flags, sweep harness,
runtime). Headlines: --activation leakyrelu, --dtype f16,
align(16) wrapper, OOD always-on, schema-hash gate always-on.
8. Validation gates (block release): 7-item checklist
9. Known landmines: budget-mismatch, OOD-necessity,
corpus-shift-needs-rebake, composite-features-stability,
metadata-necessity, post-hoc-fix-impossible, metric-shipping.
10. Cross-references + live tracking table of open work.
Cross-links:
- zentrain/README.md docmap entry → PRINCIPLES.md
- zentrain/FOR_NEW_CODECS.md preamble → 'read PRINCIPLES first'
- top-level README.md companion-crates section → PRINCIPLES.md
Sourced from #1, #6, #9, #41, #43, #44 (PR), #45, #46, #47, #48,
#49, #50, #51, #52 (PR), #53, #54 (PR), #55, #56, #57 (PR).
Doctrine encoded matches the global rules in
~/work/claudehints/CLAUDE.md (sweep discipline, source-informing
benchmarks). Live tracking table at the doc tail.
…dispatch tree (#53) Adds public adaptive analyzer entry that: - Skips all work for empty-feature requests (stage 0) - Uses exhaustive budget for ≤64K-pixel images (stage 0) - Drops chroma-tier features when is_grayscale=true (stage 1.5) - Drops saturating Tier3 features when uniformity>0.95 (stage 1.5) - Stores extended-pass flag for ≥8MP images for stage 2 (deferred) New `analyze_with_dispatch_plan(slice, query, hints)` and `DispatchHints` ship alongside the existing `analyze_features`, which is unchanged. Internally the new entry runs Tier 1 + the strict-grayscale classifier first, then narrows the remaining query by subtracting two new crate-internal const FeatureSets (`CHROMA_DROP_FEATURES`, `SATURATING_DROP_FEATURES`) before dispatching Tier 2 / Tier 3 / palette. Dropped features come back as `None` from `AnalysisResults::get` — the layered defense in `RawAnalysis::into_results` guarantees no caller ever sees garbage for a dropped feature. `DispatchHints.target_zq` / `content_hash` are accepted but not consumed in this PR; they're reserved for the corpus#47-gated Stage 2 follow-up and a future content-hash result cache, both of which can land without a public-signature change. Tests cover each gate: empty-query short-circuit, grayscale chroma-drop, photo no-drop parity vs analyze_features, tiny-image exhaustive-budget smoke, ≥8MP smoke, flat-image dual-drop. Closes part of #50 (sub-D); enables #46 stage 2 follow-up after #47's corpus sweep validates the thresholds.
Returning None for a feature the caller asked for breaks any consumer that fills a fixed-shape input vector — the picker MLP, regression baselines, anything trained on a column-positional layout. The picker's training distribution already includes grayscale and flat content; the MLP knows what those features look like there. Dropping them puts the consumer off-distribution. Reframe the dispatch tree as purely additive: - Remove `CHROMA_DROP_FEATURES` + `SATURATING_DROP_FEATURES` and the Stage 1.5 query-narrowing logic from `analyze_with_dispatch_plan`. - Restore the canonical `IsGrayscale`-only gate for the strict-grayscale scan (matches `analyze_features` exactly — sub-µs early-exit on coloured content stays unchanged for picker callers that don't ask for the signal). - Mirror the 16-arm const-bool dispatch from `analyze_features` so monomorphisation tables stay identical; only the Stage 0 budget overrides differ. - Tests: drop the two Stage 1.5 drop tests; replace with a load-bearing parity test that asserts bit-identical numerics vs `analyze_features` for every `FeatureSet::SUPPORTED` member at 256×256 (above the exhaustive-budget threshold), plus a grayscale test that asserts every requested chroma feature is *populated* (not dropped). - Update CHANGELOG + the public docstring to spell out the contract: for every requested feature, the dispatch plan returns the same `Some(_)` / `None` shape `analyze_features` would have returned. - Expose `analyze_specialized_raw` as `pub(crate)` so dispatch can reuse the canonical specialiser. Stages 2+ (extended-budget retry, selective Tier 3, derived likelihoods) remain deferred. None of them will skip caller-requested features either — the dispatch tree only ever adds compute, never drops it.
…tive seat target_zq and content_hash were speculative — neither stage 0 nor any shipping stage today consumes them. Under the project's no-0.2.x rule, shipping unused public fields commits forever to a shape based on guesses. Drop both fields. Keep DispatchHints as an empty #[non_exhaustive] struct — the seat is enough. Future stages add fields additively under 0.1.x without a public-signature change. Call sites that hand &DispatchHints today (constructed via empty()/default()) keep compiling unchanged when fields land.
Rebase-drift fix. Since this PR was authored, the canonical `analyze_features` laplacian SIMD-pass gate widened from `LaplacianVariance` alone to the full percentile set (LaplacianVariance + P50/P75/P90/P99/Peak, #42/#49). The dispatch plan's `tier1_wants_laplacian` helper still checked only `LaplacianVariance`, so a caller requesting just a percentile variant (e.g. LaplacianVarianceP90) via analyze_with_dispatch_plan would skip the histogram pass and get zeros where analyze_features populates the feature — violating the plan's own Some/None-parity contract. Helper now mirrors the canonical builder exactly.
3705429 to
ea70bf9
Compare
Rebased onto 0.2.0 (
|
| gate | result |
|---|---|
cargo build |
ok |
cargo test --lib |
122 passed, 0 failed |
cargo test --no-default-features --features experimental,hdr --lib |
179 passed, 0 failed, 2 ignored (pre-existing perf tests, not from this PR) |
cargo clippy --features experimental,hdr --all-targets -- -D warnings |
clean |
cargo fmt --check |
clean |
cargo build -p zenpicker |
ok |
Stage 2 still correctly deferred to #47
The flag_extended_pass (≥ 8 MP) bit is recorded but never acted on — stage 2 (extended-budget retry on budget-sensitive features) remains gated on the #47 corpus sweep, exactly as designed. The shipped surface is stage 0 only: dimension-driven sampling-budget adjustment that never narrows the caller's feature query.
CI now runs against the rebased branch.
|
Holding for completion rather than merging the stage-0 scaffold. A stage-0-only merge (skip-empty-query / exhaustive-on-tiny / flag-large) is a stub. The value is the content-aware gating: stage 1.5 (drop chroma features on grayscale, drop saturating features on flat content) and stage 2 (extended-budget retry on large images). That can't merge safely until the gating thresholds — and which features are droppable — are validated against a corpus, i.e. blocked on #47: dropping a feature the picker actually needed would silently degrade picks. Completion plan (tracked in #53):
This branch is rebased onto 0.2.0, green, and carries a real drift-fix (laplacian-percentile gate parity) @ |
…ded pass (#53) Layers Stage 1.5 + Stage 2 onto the Stage-0 dispatch tree. The dispatcher now inlines the same tier passes analyze_specialized_raw makes (against the random-access RowStream, so reordering tier1 / strict-grayscale relative to tier2/3 changes no value), then: - Stage 1.5: when the strict-grayscale classifier reports is_grayscale=true, subtract CHROMA_DROP_FEATURES (tier-1 chroma + all of tier-2 + chroma DCT compressibility + UV noise/quant percentiles + skin-tone). When uniformity > 0.95, subtract SATURATING_DROP_FEATURES (Laplacian percentiles + patch_fraction_fast + AqMap percentiles; AqMapMean/Std kept). Dropped features come back as None via into_results(narrowed, ...). - Stage 2: on >= LARGE_THRESHOLD (8 MP) images, re-run the tier-3 DCT pass at 2x hf_max_blocks and overwrite only EXTENDED_PASS_FEATURES (patch_fraction_fast, aq_map_p99, noise_floor_y_p90) with the finer-sampled values. Parity: every feature NOT dropped (1.5) and NOT one of the three Stage-2 targets on a >=8 MP image is bit-identical to analyze_features (same passes, same budgets, same gate booleans). The three Stage-2 features diverge by design — a strictly finer block sample is the point of the retry. Re-adds CHROMA_DROP_FEATURES / SATURATING_DROP_FEATURES to feature.rs (verbatim names re-verified against current feature IDs) and adds EXTENDED_PASS_FEATURES. tier1_wants_laplacian mirrors the current all-percentile-variant gate (matches the post-rebase drift-fix). Tests: grayscale drops chroma / colour keeps chroma (gate doesn't misfire) / uniform drops saturating-but-keeps-mean-std / >=8 MP Stage 2 populates the three targets while every other tier-3 feature stays bit-identical to the default-budget baseline. Existing parity test retained (sub-8 MP, non-gray, non-uniform synth ⇒ no gate fires).
… uniformity gate (#54) Adds examples/validate_dispatch_gates.rs — a picker-free gate-correctness harness that runs analyze_features (full) AND analyze_with_dispatch_plan (gated) on a stratified imazen-26 sample (50 screen + 100 photo/mixed, seed=1, 147 scored), and for each gate measures the worst-case full-analysis magnitude of every feature the gate WOULD drop on images where its condition fired. Cross-checks the grayscale classifier against a ground-truth per-pixel channel-spread measure (independent of the analyzer) to detect misfires. Findings (benchmarks/dispatch_gate_validation_2026-06-04.{tsv,meta}): - GRAYSCALE gate: VALIDATED-SAFE. Fired 8/147 (5.4%), 0 misfires (never on an image carrying real chroma). Every dropped chroma feature is bit-exactly 0.0 on strict R==G==B grayscale. Shipped ENABLED. - UNIFORMITY gate: UNSAFE on this corpus. uniformity>0.95 fires on text/line-art/document/diagram screen content that carries sparse-but-maximally-sharp edge signal — would drop laplacian_variance_peak=255, laplacian_variance_p99=38, patch_fraction_fast~0.99, aq_map_p99~5.9 (34 meaningful-drop events across 10 images). The issue-#53 saturation assumption holds for photographic flat content but not screen content — exactly the class the picker most needs to distinguish. Shipped DISABLED (ENABLE_UNIFORMITY_GATE=false); SATURATING_DROP_FEATURES retained for a content-aware-threshold follow-up. Per HONEST-STOP: disabling an unsafe gate with the data behind it is the intended outcome — the grayscale gate (the validated-safe win) still ships. Tests updated: the uniformity test now asserts the disabled gate yields pure parity with analyze_features. Cargo.toml: new validate_dispatch_gates example (experimental+hdr). CHANGELOG: stages 0+1.5+2 entry replaces the stage-0-only text.
Stages 1.5 + 2 landed; gate-validated against imazen-26Pushed What landed
Gate validation (imazen-26, picker-free gate-correctness)Harness:
Grayscale gate is safe: on strict R==G==B grayscale every dropped chroma feature is bit-exactly its default (chroma is definitionally zero), and the strict-equality classifier never fired on an image carrying real chroma. Uniformity gate is unsafe as specced and is shipped OFF ( Per HONEST-STOP, finding-and-disabling the unsafe gate is the intended outcome: the validated-safe grayscale win still ships. Green gate (all pass, captured locally)
Still deferredKept as a draft: picker-level pick-agreement no-regression is not included here (only zenwebp's picker is in production). This PR validates gate correctness (no meaningful feature dropped where a gate fires), not downstream picker argmin stability. That no-regression sweep remains a follow-up. |
… measure dispatch perf (#54) TASK 1 — content-aware uniformity gate calibration (issue #53 follow-up): Extended validate_dispatch_gates with tier1 edge_density/variance columns, a content-aware gate condition (uniformity>0.95 AND edge_density<τ), and a uniformity>0.95-subset class split (text/line-art vs flat-photo by a ground-truth fingerprint that is NOT a gate input). Result on imazen-26: NO safe tier1 threshold exists. - The uniformity>0.95 regime contains ZERO flat-photo images and 10/10 document/text/line-art images. There is no safe population to gate on. - edge_density does not separate the classes: a near-blank page (wikipedia__einstein, edge_density=0.0, variance=7e-4) still carries patch_fraction_fast=0.994 — the AUC-0.880 screen discriminator. Every τ in 1e-4…5e-3 was UNSAFE (drops the picker's strongest signal). - Fixed the safety classifier: patch_fraction* is [0,1]-valued, so a flat MEANINGFUL_MAGNITUDE=1.0 mis-passed a maximal 0.99 drop; meaningful_ threshold() now uses 0.27 (feature.rs screen-like operating threshold). Honest-stop: ENABLE_UNIFORMITY_GATE stays false (grayscale-gate-only); the dispatch.rs comment + validation .meta record the full calibration evidence. TASK 2 — dispatch perf measurement (issue #50): new examples/dispatch_perf.rs times analyze_features vs analyze_with_dispatch_plan (FeatureSet::SUPPORTED), interleaved A/B (alternating which side runs first per rep, median over reps), release / NO target-cpu=native. Finding: the gated path is net SLOWER — parity (-0.66%) on <8 MP images, -37.85% on ≥8 MP images. The regression is ENTIRELY Stage 2's extended Tier-3 DCT re-walk (deliberate accuracy tradeoff), NOT the gating: the <8 MP neither-fired control is -0.66% (free). The grayscale gate's win is not isolable here (all grayscale corpus images are ≥8 MP). Stable at reps=25. Artifacts: benchmarks/dispatch_perf_2026-06-04.{tsv,meta}. Green gate: cargo build / test --lib (123) / test --no-default-features --features experimental,hdr --lib (182) / clippy -D warnings / fmt --check / build -p zenpicker — all pass. No source behavior change (dispatch.rs is comment-only; the gate remains correctly disabled).
PR #54 — content-aware uniformity gate calibration + dispatch perf measurementPushed Task 1 — content-aware uniformity gate: NO safe tier1 threshold exists on imazen-26The gate fires post-tier1 / pre-tier3, so its condition may only use tier1 signals (always-computed: Refuted on two counts ( (1) The
(2) Threshold sweep (
Every tested τ is unsafe. (Also fixed the harness safety line: Verdict: keep Re-validation table (counterfactual safety, imazen-26, n=147)
Task 2 — dispatch perf measurement (issue #50)
Headline: the gated path is net SLOWER (negative = gated slower than full). 7950X, n=147, reps=9 (stable at reps=25: −28.85% vs −29.50%).
Per-gate-fired subset (a gate only saves on images it fires on):
Root cause: the Final per-gate verdict
Green gate (all pass)
Pushed + verified on |
) Stage 2 (the 2x Tier 3 re-walk on >=8 MP images to refine the 3 budget-sensitive features) was a ~-38% wall-time regression on large images, running unconditionally. Gate it behind a new DispatchHints::enable_extended_pass (default false) + a with_extended_pass() constructor. Default hints now skip Stage 2, so analyze_with_dispatch_plan reaches numeric parity with analyze_features on the default path (no large-image regression). Additive on the #[non_exhaustive] struct. Tests: default hints skip Stage 2 (parity); opt-in runs it and changes at least one EXTENDED_PASS_FEATURE. 123 default + 184 experimental,hdr lib tests pass; clippy + fmt clean. Recovered from an agent run that was interrupted by a transient server-side rate limit before commit.
Add examples/dispatch_perf_small.rs — downscales imazen-26 originals
(8 grayscale + 6 screen + 6 photo) to a log-spaced size set
{48..1024}px (Lanczos3, downscale-only), and times analyze_features vs
analyze_with_dispatch_plan(.., None) interleaved A/B (25 reps, median).
Measured (lilith, release, no target-cpu=native; benchmarks/
dispatch_perf_small_2026-06-05.{tsv,meta}):
- The grayscale gate's saving is PER-PIXEL, not fixed. On the grayscale
subset (80 cells) gated beta drops 9.51 -> 8.44 ms/MP (~11% of per-pixel
cost); fixed-overhead alpha delta ~0.006 ms (negligible). Per-size %
saving holds ~6-11% across all sizes (16% at 48px, settling to ~9-11%
by 768-1024px) and grows in absolute ms with size (0.006 -> 0.85 ms).
- On COLOR content (118 cells) the gate never fires: full==gated within
noise (delta-alpha ~ -0.003 ms, delta-beta ~ +0.013 ms/MP). Confirms
default-path parity / free dispatch on the dominant case.
- 123 lib tests pass incl. dispatch-plan parity; clippy/fmt clean.
Small-image dispatch-plan perf sweep — settling whether the gating is worth shippingFollowup to the large-corpus run (which showed the gating is free at <8 MP but couldn't measure its win — that corpus is large-image-heavy, median 9 MP, so the saving was buried under per-pixel cost). New harness Setup: Per-size mediansALL = grayscale + screen + photo mixed (n per size in parens). Grayscale-subset = the 8 grayscale images only (the gate fires on every one).
(The ALL Δ% hovers around 0 because grayscale is only 8 of ~20 sources at each size and the gate never fires on the color majority. The signal lives in the grayscale subset.) Linear fit
|
| group (n) | path | α (fixed ms) | β (ms/MP) |
|---|---|---|---|
| ALL (198) | full | +0.339 | 9.281 |
| ALL (198) | gated | +0.342 | 8.747 |
| GRAYSCALE (80) | full | +0.339 | 9.513 |
| GRAYSCALE (80) | gated | +0.332 | 8.441 |
| COLOR (118) | full | +0.341 | 9.058 |
| COLOR (118) | gated | +0.344 | 9.045 |
- Grayscale: Δα = +0.006 ms (negligible), Δβ = +1.07 ms/MP → the gate's saving is per-pixel, ~11% of per-pixel cost. It skips the chroma/Tier-2 passes, which scale with pixels.
- Color: Δα = −0.003 ms, Δβ = +0.013 ms/MP — both indistinguishable from zero. Default-path parity holds: gating is genuinely free on the dominant (color) case, confirming the large-corpus result and Task-1's numeric-parity claim.
Verdict — there IS a real win, but it's NOT a small-image-only "fixed-saving" story
The original hypothesis was that the grayscale gate's saving is roughly fixed, so it'd only be a meaningful % on small images. The data says otherwise: the saving is proportional to pixels (Δβ ≈ 1.07 ms/MP on grayscale, Δα ≈ 0). So there is no crossover size below which it suddenly becomes worth it — the gate delivers a steady ~6–11% wall-time saving on strict-grayscale content at every measured size (16% at 48px is partly small-image timing-resolution noise; it settles to ~9–11% by 768–1024px and the absolute ms saved grows monotonically, 0.006 → 0.85 ms). On color content the gating is free.
So: ship the grayscale gate. It's free on color (parity-verified) and a clean ~6–11% win on grayscale at all sizes — no downside. The "worth it" question is really how much grayscale traffic the workload carries, not image size: a document/scan-heavy or line-art workload gets a real proportional speedup; a photo-heavy workload sees ~0 either way (but loses nothing). Note Stage 2 is opt-in/off by default and the uniformity gate is permanently disabled, so this PR's default behavior is exactly: grayscale gate + numeric parity on everything else. The measured grayscale win justifies keeping the gate enabled by default.
|
Closing without merging — decision after the full validate-and-measure arc. Built + validated (all green at
Why close, not merge: the only validated win (grayscale gating) is real but narrow — it only materializes on grayscale traffic — and carries costs the cost/benefit doesn't justify for current workloads:
Branch |
|
Preserved as a DO-NOT-MERGE exploration draft: #80 on branch |
Summary
Adds
analyze_with_dispatch_plan(slice, query, hints)+DispatchHintsper the issue-#53 design. Layered on top ofanalyze_features— the existing entry is unchanged; codecs opt in by switching call sites.This PR ships stages 0 and 1.5. Stage 2 is deferred until #47's corpus sweep validates the threshold values.
What lands
query.features()→ return emptyAnalysisResultsimmediately, no scan.pixel_count ≤ 64 000→ bump the sampling budget to the pixel count (exhaustive).pixel_count ≥ 8 000 000→ record an internalflag_extended_passfor the future Stage 2 retry. Not acted on in this PR.is_grayscale = true→ subtractCHROMA_DROP_FEATURES(Tier 1 chroma + all of Tier 2 + chroma DCT compressibility + UV noise/quant percentiles + skin-tone) from the remaining-tier query.uniformity > 0.95→ subtractSATURATING_DROP_FEATURES(Laplacian percentiles +patch_fraction_fast+ AqMap percentiles).NonefromAnalysisResults::get— the layered defense inRawAnalysis::into_resultsguarantees no caller ever sees garbage.flag_extended_passfield is recorded but never read;DispatchHints.target_zq/content_hashare accepted but never consumed. Both are reserved for the Stage 2 + result-cache follow-ups and can land additively.Files
src/dispatch.rs(new):DispatchHints,DispatchPlan,dispatch::run.src/lib.rs: re-exportsDispatchHints, adds the documentedanalyze_with_dispatch_planpublic entry.src/feature.rs: adds two crate-internal constFeatureSets —CHROMA_DROP_FEATURESandSATURATING_DROP_FEATURES.src/tests.rs: 6 new tests undertests::dispatch_plan.CHANGELOG.md:[Unreleased]entry under "Added".Stage 1.5 drop sets (final)
CHROMA_DROP_FEATURES(gated byis_grayscale = true):ChromaComplexity,CbSharpness,CrSharpness,Colourfulness(experimental),SkinToneFraction(experimental).Cb{Horiz,Vert,Peak}Sharpness,Cr{Horiz,Vert,Peak}Sharpness.DctCompressibilityUV,NoiseFloorUV{,P25,P50,P75,P90},QuantSurvivalUv{,P10,P25,P50,P75}.SATURATING_DROP_FEATURES(gated byuniformity > 0.95, all experimental):LaplacianVariance{P50,P75,P90,P99,Peak}.PatchFractionFast.AqMap{P50,P75,P90,P95,P99}(mean / std kept — they're cheap byproducts of the same accumulator).Test plan
cargo test --lib --features experimental,composites— 169/169 passing (6 new intests::dispatch_plan).cargo test --lib— 125/125 passing.cargo clippy --lib --tests --features experimental,composites -- -D warnings— clean.cargo clippy --lib --tests -- -D warnings— clean.cargo fmt --check— clean.stage15_no_drops_on_typical_photo(bit-identical f32 values for every requested feature). On grayscale / flat inputs the dispatch-plan output legitimately differs fromanalyze_featuresbecause dropped features becomeNoneinstead of the zeroed-default values the existing entry would emit; the issue spec calls this out as the expected semantics.Cross-refs