#!/usr/bin/env python3
"""
CROSSFADE TWO WORLDS — in the CONDITIONING, which is the only place it works.
"Can we blend seeds at large regions?" splits in two, because seed does two
jobs:
LATENT seed — cannot be blended. Noise is drawn position-addressed as
standard_normal(seed, i*TILE_STRIDE, j*TILE_STRIDE)
(world_pipeline.py:1230). One global field is exactly what
buys seam-freedom (0.00 m over 108 adjacent tile pairs). Two
regions on different seeds draw uncorrelated noise and the
16 px overlap window averages them into mush.
CONDITIONING — blendable, continuous, and the half that carries regional
character: elevation prior, temperature, seasonality,
precipitation, variability. The latent seed only decides WHICH
ridge goes where; conditioning decides what KIND of place it is.
So this blends two synthetic conditioning maps with different frequency_mult
(biome scale) and different seeds (layout), across a smooth band, and asks
whether the result is continuous.
THE CONTROL IS A HARD SWITCH at the same place. The statistic must REJECT it.
A continuity test that cannot see an abrupt cut is measuring nothing — the
whole question is whether blending beats switching (#50 section 6).
⚠ BLENDS THE FINALIZED FIELDS, not the raw Perlin. finalize_synthetic_map
applies the lapse coupling (temp += lapse*max(0,elev)) inside each factory, so
a convex blend of two finalized maps only approximately preserves that
relationship. The run measures corr(temp, elev) in A, in B and in the blend, so
the size of that approximation is reported rather than assumed.
⚠ UPSTREAM SHARP EDGE: _load_stats_cache() takes no arguments, so the quantile
stats cached on disk are reused for ANY frequency_mult even though
_compute_map_stats(frequency_mult, ...) depends on it. Two factories with
different frequency_mult therefore share stats. Fine for a shape prototype;
not fine if you tune frequency_mult and expect the distribution to follow.
python crossfade_seeds.py
"""
import argparse, os, sys
import numpy as np
# ⚠ synthetic_map.py uses RELATIVE paths ("data/global/...") for the WorldClim
# and ETOPO rasters AND for the stats cache. Imported from elsewhere it silently
# misses the cache and recomputes quantiles over the full Earth rasters — which
# does not finish in ten minutes. chdir first.
TD = "/storage/projects/code/godot/terrain-diffusion"
sys.path.insert(0, TD)
os.chdir(TD)
from terrain_diffusion.inference.synthetic_map import make_synthetic_map_factory
NAMES = ["elevation", "temperature", "temp std", "precipitation", "precip var"]
def seam_z(sig, cut, band):
"""How exceptional is the step AT the seam, against steps elsewhere?
⚠ THE FIRST VERSION OF THIS USED max/median OVER THE WHOLE TRANSECT and
could not reject a hard cut: fBm conditioning is intrinsically spiky (one
world alone scored 9.4-18.8), so a single extra step does not stand out
against that baseline. The probe said so and refused to report — the
statistic was the bug, not the blend.
Localising fixes it, and mirrors gate_heightmap's seam test: take the step
at the join in units of the spread of steps AWAY from the join. A hard cut
is a large outlier there by construction; a smooth blend is not.
"""
d = np.abs(np.diff(sig))
lo, hi = cut - band, cut + band
outside = np.r_[d[:max(lo, 1)], d[min(hi, len(d) - 1):]]
if outside.size < 8:
return float("nan")
med = float(np.median(outside))
sd = float(np.std(outside))
if sd <= 0:
return float("inf")
# THE STEP EXACTLY AT THE JOIN, not the largest step near it. Taking the
# max over the band found a natural fBm spike instead of the cut and
# returned the SAME value for the blend and the hard cut (7.57 vs 7.57) —
# the second statistic the control rejected before it could mislead.
# ⚠ OFF BY ONE, and it hid the cut completely. d[i] = |sig[i+1] - sig[i]|,
# and with world A below `cut` and world B at and above it, the jump is
# d[cut-1]. Reading d[cut] samples a step wholly inside world B, which is
# why a hard cut scored z = -0.54 and looked seamless.
at_cut = float(d[min(max(cut - 1, 0), len(d) - 1)])
return (at_cut - med) / sd
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--n", type=int, default=768, help="transect length, conditioning cells")
ap.add_argument("--rows", type=int, default=8)
ap.add_argument("--band", type=int, default=192, help="crossfade width, cells")
ap.add_argument("--cuts", type=int, default=41,
help="how many cut positions to sweep")
ap.add_argument("--z-max", type=float, default=6.0,
help="per-channel flag threshold (advisory)")
ap.add_argument("--min-control-z", type=float, default=1.0,
help="a hard cut must score at least this to be a control")
a = ap.parse_args()
print("building two conditioning worlds")
fa = make_synthetic_map_factory(frequency_mult=[1.0]*5, seed=1234)
fb = make_synthetic_map_factory(frequency_mult=[2.5]*5, seed=777)
# RAW, then finalize ONCE on the blend.
#
# sample_full_synthetic_map finalizes each world separately, and finalize
# applies the lapse coupling temp += lapse(precip)*max(0,elev) against THAT
# world's own elevation. A convex blend of two finalized maps therefore
# carries two inconsistent corrections: measured, corr(temp, elev) fell to
# -0.098 from -0.431 (A) and -0.149 (B) — mountains in the transition band
# stopped being cold.
#
# sample_raw and finalize are already exposed as attributes on the returned
# callable (synthetic_map.py:269-270). I wrote a patch to add them before
# checking, and it was a duplicate of code already in HEAD.
A = np.asarray(fa.sample_raw(0, 0, a.rows, a.n), dtype=np.float64)
B = np.asarray(fb.sample_raw(0, 0, a.rows, a.n), dtype=np.float64)
FINALIZE = fa.finalize
print(f" A frequency_mult 1.0 seed 1234 B frequency_mult 2.5 seed 777")
print(f" sampled {A.shape} conditioning cells\n")
# smooth weight: 0 on the left third, 1 on the right, smoothstep across band
x = np.arange(a.n, dtype=np.float64)
c = a.n * 0.5
t = np.clip((x - (c - a.band / 2)) / a.band, 0.0, 1.0)
w_soft = t * t * (3 - 2 * t)
w_hard = (x >= c).astype(np.float64) # the CONTROL
# MANY CUT POSITIONS, not one. A single cut is a sample of one: where the
# two worlds happen to agree, a hard cut leaves no step and the control
# looks seamless (precipitation scored z 2.46 that way). Sweeping the cut
# across the transect and reporting the MEDIAN is what makes the comparison
# about blending rather than about one lucky x.
cuts = np.linspace(a.band, a.n - a.band, a.cuts).astype(int)
print(f"{'channel':>15} {'blend z':>18} {'hard-cut z':>18} (median over "
f"{len(cuts)} cut positions)")
fails, soft_med, hard_med = [], [], []
for ch in range(5):
sa = A[ch].mean(axis=0); sb = B[ch].mean(axis=0)
zs_all, zh_all = [], []
for cut in cuts:
t = np.clip((x - (cut - a.band / 2)) / a.band, 0.0, 1.0)
ws = t * t * (3 - 2 * t)
wh = (x >= cut).astype(np.float64)
zs_all.append(seam_z((1 - ws) * sa + ws * sb, int(cut), a.band))
zh_all.append(seam_z((1 - wh) * sa + wh * sb, int(cut), a.band))
zs = float(np.nanmedian(zs_all)); zh = float(np.nanmedian(zh_all))
soft_med.append(zs); hard_med.append(zh)
flag = ""
if not np.isfinite(zs) or zs > a.z_max:
fails.append(NAMES[ch]); flag = " <-- STEPS"
print(f"{NAMES[ch]:>15} {zs:>18.2f} {zh:>18.2f}{flag}")
hard_z, soft_z = hard_med, soft_med
# THE CRITERION IS RELATIVE, and it took four wrong statistics to see why.
#
# An absolute z threshold does not work here: two worlds drawn from the SAME
# quantile distribution are often already close at any given x, so a hard cut
# between them costs only ~2 sigma. Setting z_max = 6 meant the control could
# never trip, and the probe correctly refused to report rather than printing
# a green line it had not earned.
#
# The question was never "is the blend smooth in absolute terms" — it is
# "does blending remove the step that switching creates". So: the control
# must be DETECTABLE, and the blend must be well below it.
print()
hard_worst = float(np.median(hard_z))
blend_worst = float(np.max(np.abs(soft_z)))
if hard_worst < a.min_control_z:
print(f"CONTROL NOT DETECTABLE (median hard-cut z {hard_worst:.2f} < "
f"{a.min_control_z}).")
print("A hard cut leaves no measurable step, so there is nothing for the")
print("blend to improve on and this run proves nothing.")
return 2
print(f"control (hard cut, same positions) is detectable at median z "
f"{hard_worst:.2f} — the test discriminates")
if blend_worst > 0.5 * hard_worst:
print(f"\nVERDICT: the blend ({blend_worst:.2f}) is not clearly better than\n"
f" switching ({hard_worst:.2f}). Widen --band.")
return 1
# Does the lapse coupling survive the blend?
# Blend RAW, then finalize once — so the lapse coupling is applied to the
# blended elevation and precipitation rather than inherited from two.
raw_blend = np.stack([(1 - w_soft) * A[c].mean(0) + w_soft * B[c].mean(0)
for c in range(5)])
blend = np.asarray(FINALIZE(raw_blend[:, None, :]), dtype=np.float64)[:, 0, :]
fA = np.asarray(FINALIZE(A.mean(axis=1)[:, None, :]), dtype=np.float64)[:, 0, :]
fB = np.asarray(FINALIZE(B.mean(axis=1)[:, None, :]), dtype=np.float64)[:, 0, :]
print(f"\ncorr(temperature, elevation) — negative means the lapse rate holds")
print(f" world A {np.corrcoef(fA[1], fA[0])[0,1]:+.3f}"
f" world B {np.corrcoef(fB[1], fB[0])[0,1]:+.3f}"
f" blend {np.corrcoef(blend[1], blend[0])[0,1]:+.3f}"
f" (all finalized the same way)")
print(f"\nVERDICT: crossfading two conditioning worlds over {a.band} cells "
f"({a.band * 7.68:.0f} km)\n"
f" leaves a seam of z {blend_worst:.2f}, against {hard_worst:.2f} "
f"for switching between\n"
f" them at the same places. Two worlds CAN be blended, in the\n"
f" conditioning, under ONE latent seed.")
return 0
if __name__ == "__main__":
sys.exit(main())