| … | … | @@ -0,0 +1,210 @@ |
| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | CROSSFADE TWO WORLDS — in the CONDITIONING, which is the only place it works. |
| 4 | |
| 5 | "Can we blend seeds at large regions?" splits in two, because seed does two |
| 6 | jobs: |
| 7 | |
| 8 | LATENT seed — cannot be blended. Noise is drawn position-addressed as |
| 9 | standard_normal(seed, i*TILE_STRIDE, j*TILE_STRIDE) |
| 10 | (world_pipeline.py:1230). One global field is exactly what |
| 11 | buys seam-freedom (0.00 m over 108 adjacent tile pairs). Two |
| 12 | regions on different seeds draw uncorrelated noise and the |
| 13 | 16 px overlap window averages them into mush. |
| 14 | |
| 15 | CONDITIONING — blendable, continuous, and the half that carries regional |
| 16 | character: elevation prior, temperature, seasonality, |
| 17 | precipitation, variability. The latent seed only decides WHICH |
| 18 | ridge goes where; conditioning decides what KIND of place it is. |
| 19 | |
| 20 | So this blends two synthetic conditioning maps with different frequency_mult |
| 21 | (biome scale) and different seeds (layout), across a smooth band, and asks |
| 22 | whether the result is continuous. |
| 23 | |
| 24 | THE CONTROL IS A HARD SWITCH at the same place. The statistic must REJECT it. |
| 25 | A continuity test that cannot see an abrupt cut is measuring nothing — the |
| 26 | whole question is whether blending beats switching (#50 section 6). |
| 27 | |
| 28 | ⚠ BLENDS THE FINALIZED FIELDS, not the raw Perlin. finalize_synthetic_map |
| 29 | applies the lapse coupling (temp += lapse*max(0,elev)) inside each factory, so |
| 30 | a convex blend of two finalized maps only approximately preserves that |
| 31 | relationship. The run measures corr(temp, elev) in A, in B and in the blend, so |
| 32 | the size of that approximation is reported rather than assumed. |
| 33 | |
| 34 | ⚠ UPSTREAM SHARP EDGE: _load_stats_cache() takes no arguments, so the quantile |
| 35 | stats cached on disk are reused for ANY frequency_mult even though |
| 36 | _compute_map_stats(frequency_mult, ...) depends on it. Two factories with |
| 37 | different frequency_mult therefore share stats. Fine for a shape prototype; |
| 38 | not fine if you tune frequency_mult and expect the distribution to follow. |
| 39 | |
| 40 | python crossfade_seeds.py |
| 41 | """ |
| 42 | import argparse, os, sys |
| 43 | import numpy as np |
| 44 | |
| 45 | # ⚠ synthetic_map.py uses RELATIVE paths ("data/global/...") for the WorldClim |
| 46 | # and ETOPO rasters AND for the stats cache. Imported from elsewhere it silently |
| 47 | # misses the cache and recomputes quantiles over the full Earth rasters — which |
| 48 | # does not finish in ten minutes. chdir first. |
| 49 | TD = "/storage/projects/code/godot/terrain-diffusion" |
| 50 | sys.path.insert(0, TD) |
| 51 | os.chdir(TD) |
| 52 | from terrain_diffusion.inference.synthetic_map import make_synthetic_map_factory |
| 53 | |
| 54 | NAMES = ["elevation", "temperature", "temp std", "precipitation", "precip var"] |
| 55 | |
| 56 | |
| 57 | def seam_z(sig, cut, band): |
| 58 | """How exceptional is the step AT the seam, against steps elsewhere? |
| 59 | |
| 60 | ⚠ THE FIRST VERSION OF THIS USED max/median OVER THE WHOLE TRANSECT and |
| 61 | could not reject a hard cut: fBm conditioning is intrinsically spiky (one |
| 62 | world alone scored 9.4-18.8), so a single extra step does not stand out |
| 63 | against that baseline. The probe said so and refused to report — the |
| 64 | statistic was the bug, not the blend. |
| 65 | |
| 66 | Localising fixes it, and mirrors gate_heightmap's seam test: take the step |
| 67 | at the join in units of the spread of steps AWAY from the join. A hard cut |
| 68 | is a large outlier there by construction; a smooth blend is not. |
| 69 | """ |
| 70 | d = np.abs(np.diff(sig)) |
| 71 | lo, hi = cut - band, cut + band |
| 72 | outside = np.r_[d[:max(lo, 1)], d[min(hi, len(d) - 1):]] |
| 73 | if outside.size < 8: |
| 74 | return float("nan") |
| 75 | med = float(np.median(outside)) |
| 76 | sd = float(np.std(outside)) |
| 77 | if sd <= 0: |
| 78 | return float("inf") |
| 79 | # THE STEP EXACTLY AT THE JOIN, not the largest step near it. Taking the |
| 80 | # max over the band found a natural fBm spike instead of the cut and |
| 81 | # returned the SAME value for the blend and the hard cut (7.57 vs 7.57) — |
| 82 | # the second statistic the control rejected before it could mislead. |
| 83 | # ⚠ OFF BY ONE, and it hid the cut completely. d[i] = |sig[i+1] - sig[i]|, |
| 84 | # and with world A below `cut` and world B at and above it, the jump is |
| 85 | # d[cut-1]. Reading d[cut] samples a step wholly inside world B, which is |
| 86 | # why a hard cut scored z = -0.54 and looked seamless. |
| 87 | at_cut = float(d[min(max(cut - 1, 0), len(d) - 1)]) |
| 88 | return (at_cut - med) / sd |
| 89 | |
| 90 | |
| 91 | def main(): |
| 92 | ap = argparse.ArgumentParser() |
| 93 | ap.add_argument("--n", type=int, default=768, help="transect length, conditioning cells") |
| 94 | ap.add_argument("--rows", type=int, default=8) |
| 95 | ap.add_argument("--band", type=int, default=192, help="crossfade width, cells") |
| 96 | ap.add_argument("--cuts", type=int, default=41, |
| 97 | help="how many cut positions to sweep") |
| 98 | ap.add_argument("--z-max", type=float, default=6.0, |
| 99 | help="per-channel flag threshold (advisory)") |
| 100 | ap.add_argument("--min-control-z", type=float, default=1.0, |
| 101 | help="a hard cut must score at least this to be a control") |
| 102 | a = ap.parse_args() |
| 103 | |
| 104 | print("building two conditioning worlds") |
| 105 | fa = make_synthetic_map_factory(frequency_mult=[1.0]*5, seed=1234) |
| 106 | fb = make_synthetic_map_factory(frequency_mult=[2.5]*5, seed=777) |
| 107 | |
| 108 | # RAW, then finalize ONCE on the blend. |
| 109 | # |
| 110 | # sample_full_synthetic_map finalizes each world separately, and finalize |
| 111 | # applies the lapse coupling temp += lapse(precip)*max(0,elev) against THAT |
| 112 | # world's own elevation. A convex blend of two finalized maps therefore |
| 113 | # carries two inconsistent corrections: measured, corr(temp, elev) fell to |
| 114 | # -0.098 from -0.431 (A) and -0.149 (B) — mountains in the transition band |
| 115 | # stopped being cold. |
| 116 | # |
| 117 | # sample_raw and finalize are already exposed as attributes on the returned |
| 118 | # callable (synthetic_map.py:269-270). I wrote a patch to add them before |
| 119 | # checking, and it was a duplicate of code already in HEAD. |
| 120 | A = np.asarray(fa.sample_raw(0, 0, a.rows, a.n), dtype=np.float64) |
| 121 | B = np.asarray(fb.sample_raw(0, 0, a.rows, a.n), dtype=np.float64) |
| 122 | FINALIZE = fa.finalize |
| 123 | print(f" A frequency_mult 1.0 seed 1234 B frequency_mult 2.5 seed 777") |
| 124 | print(f" sampled {A.shape} conditioning cells\n") |
| 125 | |
| 126 | # smooth weight: 0 on the left third, 1 on the right, smoothstep across band |
| 127 | x = np.arange(a.n, dtype=np.float64) |
| 128 | c = a.n * 0.5 |
| 129 | t = np.clip((x - (c - a.band / 2)) / a.band, 0.0, 1.0) |
| 130 | w_soft = t * t * (3 - 2 * t) |
| 131 | w_hard = (x >= c).astype(np.float64) # the CONTROL |
| 132 | |
| 133 | # MANY CUT POSITIONS, not one. A single cut is a sample of one: where the |
| 134 | # two worlds happen to agree, a hard cut leaves no step and the control |
| 135 | # looks seamless (precipitation scored z 2.46 that way). Sweeping the cut |
| 136 | # across the transect and reporting the MEDIAN is what makes the comparison |
| 137 | # about blending rather than about one lucky x. |
| 138 | cuts = np.linspace(a.band, a.n - a.band, a.cuts).astype(int) |
| 139 | print(f"{'channel':>15} {'blend z':>18} {'hard-cut z':>18} (median over " |
| 140 | f"{len(cuts)} cut positions)") |
| 141 | fails, soft_med, hard_med = [], [], [] |
| 142 | for ch in range(5): |
| 143 | sa = A[ch].mean(axis=0); sb = B[ch].mean(axis=0) |
| 144 | zs_all, zh_all = [], [] |
| 145 | for cut in cuts: |
| 146 | t = np.clip((x - (cut - a.band / 2)) / a.band, 0.0, 1.0) |
| 147 | ws = t * t * (3 - 2 * t) |
| 148 | wh = (x >= cut).astype(np.float64) |
| 149 | zs_all.append(seam_z((1 - ws) * sa + ws * sb, int(cut), a.band)) |
| 150 | zh_all.append(seam_z((1 - wh) * sa + wh * sb, int(cut), a.band)) |
| 151 | zs = float(np.nanmedian(zs_all)); zh = float(np.nanmedian(zh_all)) |
| 152 | soft_med.append(zs); hard_med.append(zh) |
| 153 | flag = "" |
| 154 | if not np.isfinite(zs) or zs > a.z_max: |
| 155 | fails.append(NAMES[ch]); flag = " <-- STEPS" |
| 156 | print(f"{NAMES[ch]:>15} {zs:>18.2f} {zh:>18.2f}{flag}") |
| 157 | hard_z, soft_z = hard_med, soft_med |
| 158 | |
| 159 | # THE CRITERION IS RELATIVE, and it took four wrong statistics to see why. |
| 160 | # |
| 161 | # An absolute z threshold does not work here: two worlds drawn from the SAME |
| 162 | # quantile distribution are often already close at any given x, so a hard cut |
| 163 | # between them costs only ~2 sigma. Setting z_max = 6 meant the control could |
| 164 | # never trip, and the probe correctly refused to report rather than printing |
| 165 | # a green line it had not earned. |
| 166 | # |
| 167 | # The question was never "is the blend smooth in absolute terms" — it is |
| 168 | # "does blending remove the step that switching creates". So: the control |
| 169 | # must be DETECTABLE, and the blend must be well below it. |
| 170 | print() |
| 171 | hard_worst = float(np.median(hard_z)) |
| 172 | blend_worst = float(np.max(np.abs(soft_z))) |
| 173 | if hard_worst < a.min_control_z: |
| 174 | print(f"CONTROL NOT DETECTABLE (median hard-cut z {hard_worst:.2f} < " |
| 175 | f"{a.min_control_z}).") |
| 176 | print("A hard cut leaves no measurable step, so there is nothing for the") |
| 177 | print("blend to improve on and this run proves nothing.") |
| 178 | return 2 |
| 179 | print(f"control (hard cut, same positions) is detectable at median z " |
| 180 | f"{hard_worst:.2f} — the test discriminates") |
| 181 | if blend_worst > 0.5 * hard_worst: |
| 182 | print(f"\nVERDICT: the blend ({blend_worst:.2f}) is not clearly better than\n" |
| 183 | f" switching ({hard_worst:.2f}). Widen --band.") |
| 184 | return 1 |
| 185 | |
| 186 | # Does the lapse coupling survive the blend? |
| 187 | # Blend RAW, then finalize once — so the lapse coupling is applied to the |
| 188 | # blended elevation and precipitation rather than inherited from two. |
| 189 | raw_blend = np.stack([(1 - w_soft) * A[c].mean(0) + w_soft * B[c].mean(0) |
| 190 | for c in range(5)]) |
| 191 | blend = np.asarray(FINALIZE(raw_blend[:, None, :]), dtype=np.float64)[:, 0, :] |
| 192 | fA = np.asarray(FINALIZE(A.mean(axis=1)[:, None, :]), dtype=np.float64)[:, 0, :] |
| 193 | fB = np.asarray(FINALIZE(B.mean(axis=1)[:, None, :]), dtype=np.float64)[:, 0, :] |
| 194 | print(f"\ncorr(temperature, elevation) — negative means the lapse rate holds") |
| 195 | print(f" world A {np.corrcoef(fA[1], fA[0])[0,1]:+.3f}" |
| 196 | f" world B {np.corrcoef(fB[1], fB[0])[0,1]:+.3f}" |
| 197 | f" blend {np.corrcoef(blend[1], blend[0])[0,1]:+.3f}" |
| 198 | f" (all finalized the same way)") |
| 199 | |
| 200 | print(f"\nVERDICT: crossfading two conditioning worlds over {a.band} cells " |
| 201 | f"({a.band * 7.68:.0f} km)\n" |
| 202 | f" leaves a seam of z {blend_worst:.2f}, against {hard_worst:.2f} " |
| 203 | f"for switching between\n" |
| 204 | f" them at the same places. Two worlds CAN be blended, in the\n" |
| 205 | f" conditioning, under ONE latent seed.") |
| 206 | return 0 |
| 207 | |
| 208 | |
| 209 | if __name__ == "__main__": |
| 210 | sys.exit(main()) |