OverviewFilesCommitsIssuesDocsReleasesPeers

terrain-lookdev commits / 12812f7

lookdev: CPU tile benchmark, biome survey, biome-continuity probe
bench_cpu_tile.py — ~50 s per 59 km2 tile on CPU (warm), ~40x slower than CUDA. Its correctness gate found the thing that mattered more than the timing: CPU and CUDA produce DIFFERENT BYTES for the same (seed,tx,ty), up to 5.00 m / rmse 1.0 m, so cross-device peers cannot verify each other by content hash. ⚠ The service CACHES — repeated fetches of one tile return in 0.01 s, so reps must use fresh tiles. My first median was contaminated by cache hits. survey_biomes.py — how much world per biome, on the coarse stage. 123 km gives 8 classes (7 at >=1%); it saturates at 11 by ~250 km. Also ranks candidate blocks. ⚠ Channel order is ch0 elevation, ch1 relief, ch2 temperature, ch3 temp std, ch4 precipitation, ch5 precip variability — NOT the five-entry maps list in synthetic_map.py, which is conditioning only. probe_biome_continuity.py — written, not yet run (needs a free service). Its control re-quantises the same field onto Voronoi cells and must be REJECTED before any result is reported.
12812f7915723bc1cf10f96d6b09c23e1635fdf6
by plankatron <fleeky@gmail.com> · 2026-09-02T15:31:44.000Z

Changed files (4) +356 -0

__pycache__/server.cpython-312.pyc +0 -0
(no content changes — binary or rename)
bench_cpu_tile.py +98 -0
……@@ -0,0 +1,98 @@
1#!/usr/bin/env python3
2"""
3HOW LONG DOES ONE TILE TAKE ON THE CPU?
4
5The number the whole background-generation question turns on, and nobody had
6taken it. The GPU is the renderer's; a generator that shares it costs frames
7(the model holds 976-3522 MiB resident and bursts 85-92% for seconds per tile).
8CPU is the only option that does not contend — IF it is fast enough.
9
10A TIMER WITH NO CORRECTNESS GATE MEASURES NOTHING. #50 section 2.1 is the
11session where a 14x speedup turned out to be returning 70% exact zeros. So each
12timed tile is compared against the SAME tile in the cache, which was generated
13by the CUDA service at seed 1234. That gate also answers a second question that
14matters more than the timing:
15
16 DO CPU AND GPU AGREE, AND HOW CLOSELY?
17
18Peer generation is built on tiles being a pure function of (seed, tx, ty) with
19the content hash AS the verification. That was measured across machines, not
20across DEVICES. If CPU and CUDA differ at all, then a CPU-generated tile has a
21different hash from a GPU-generated one and the two cannot verify each other —
22which is a design constraint, not a rounding detail.
23
24 python bench_cpu_tile.py [--reps 3]
25"""
26import argparse, os, re, sys, time
27import numpy as np
28from server import fetch_diffusion
29
30CACHE = os.path.expanduser("~/.cache/terrain-lookdev/tiles")
31TILE_PX = 256
32
33
34def cached(tx, ty):
35 p = os.path.join(CACHE, f"t2_256_{tx}_{ty}.npy")
36 return np.load(p).astype(np.float64) if os.path.exists(p) else None
37
38
39def main():
40 ap = argparse.ArgumentParser()
41 ap.add_argument("--cpu-url", default="http://127.0.0.1:8830")
42 ap.add_argument("--gpu-url", default="http://127.0.0.1:8000")
43 ap.add_argument("--reps", type=int, default=3)
44 ap.add_argument("--tiles", default="0,0 1,0 0,1")
45 a = ap.parse_args()
46
47 tiles = []
48 for t in a.tiles.split():
49 tx, ty = t.split(",")
50 tiles.append((int(tx), int(ty)))
51
52 area_km2 = (TILE_PX * 30.0 / 1000.0) ** 2
53 print(f"tile {TILE_PX}px @ 30 m = {TILE_PX*30/1000:.2f} km square "
54 f"= {area_km2:.1f} km2\n")
55
56 rows = []
57 for tx, ty in tiles:
58 ref = cached(tx, ty)
59 if ref is None:
60 print(f" ({tx},{ty}) not in cache — skipped (no oracle, no measurement)")
61 continue
62 for rep in range(a.reps):
63 t0 = time.perf_counter()
64 elev, clim = fetch_diffusion(a.cpu_url, tx * TILE_PX, ty * TILE_PX, TILE_PX)
65 dt = time.perf_counter() - t0
66 # The cache holds the tile WITHOUT its apron; compare the overlap.
67 h = min(elev.shape[0], ref.shape[0]); w = min(elev.shape[1], ref.shape[1])
68 d = elev[:h, :w] - ref[:h, :w]
69 rows.append((tx, ty, rep, dt, float(np.abs(d).max()),
70 float(np.sqrt((d ** 2).mean())),
71 int(np.count_nonzero(d)), d.size,
72 0 if clim is None else clim.shape[2]))
73 print(f" ({tx:>2},{ty:>2}) rep {rep}: {dt:7.2f} s "
74 f"max|d| {np.abs(d).max():8.2f} m rmse {np.sqrt((d**2).mean()):7.3f} m "
75 f"{'IDENTICAL' if not np.any(d) else 'differs'}"
76 f" climate ch {0 if clim is None else clim.shape[2]}")
77
78 if not rows:
79 raise SystemExit("no tiles measured")
80
81 ts = np.array([r[3] for r in rows])
82 print(f"\n CPU seconds per tile: median {np.median(ts):.2f} "
83 f"min {ts.min():.2f} max {ts.max():.2f} (n={len(ts)})")
84 print(f" throughput: {area_km2/np.median(ts):.2f} km2/s "
85 f"(CUDA measured ~50 km2/s in #50)")
86 worst = max(r[4] for r in rows)
87 ident = all(r[6] == 0 for r in rows)
88 print(f"\n CPU vs CUDA: worst |diff| {worst:.2f} m, "
89 f"{'BIT-IDENTICAL' if ident else 'DIFFERENT BYTES'}")
90 if not ident:
91 print(" => a CPU tile and a GPU tile of the same (seed,tx,ty) do NOT share\n"
92 " a content hash. Cross-device peers cannot verify each other by\n"
93 " hash; the tile key must name the device/pipeline, or one device\n"
94 " must be canonical for a given world.")
95
96
97if __name__ == "__main__":
98 main()
probe_biome_continuity.py +143 -0
……@@ -0,0 +1,143 @@
1#!/usr/bin/env python3
2"""
3DO BIOMES BLEND, OR DO THEY NEED AN OVERMAP TO BLEND THEM?
4
5The question was whether terrain-diffusion needs a Voronoi overmap to lay out
6biomes and blend them. Reading synthetic_map.py says no, and says why: the
7model is CONDITIONED on a climate field, and for a synthetic world that field
8is fBm Perlin (FastNoiseLite, 2-4 octaves, lacunarity 2, gain 0.5, base
9frequency 0.05) histogram-matched onto real WorldClim distributions via
10transform_perlin. A continuous field has no cells, so there is nothing to blend.
11
12This measures it rather than trusting the reading.
13
14THE STATISTIC. For each of the four climate channels (bio_1 temp, bio_4 temp
15seasonality, bio_12 precip, bio_15 precip CV), walk a long transect and take
16|d/dx| per step, normalised by the channel's own spread over the transect. A
17smooth field has a max step close to its median step. A CELL-PARTITIONED field
18is flat inside cells and jumps at the boundaries, so its max/median explodes.
19That ratio is the discriminator.
20
21THE CONTROL IS THE ALTERNATIVE BEING ASKED ABOUT. The same data is re-quantised
22onto Voronoi cells and pushed through the identical statistic. The probe must
23REJECT that. If it does not, the statistic cannot tell a blended field from a
24partitioned one and nothing it says about the real field means anything —
25an instrument that cannot fail is not evidence (#50 section 6).
26
27⚠ VACUOUS PASS GUARD. A transect that never leaves one biome is continuous for
28free. The run reports each channel's spread and REFUSES to pass if the transect
29does not actually vary — a probe that only samples one biome has not tested
30blending, and says so instead of printing green.
31
32 python probe_biome_continuity.py --url http://127.0.0.1:8000
33"""
34import argparse, sys
35import numpy as np
36from server import fetch_diffusion
37
38CHANNELS = [
39 ("bio_1 mean temp", "temperature"),
40 ("bio_4 temp seasonality", "seasonality"),
41 ("bio_12 annual precip", "precipitation"),
42 ("bio_15 precip CV", "precip variability"),
43]
44
45
46def step_ratio(sig):
47 """max step / median step, both normalised by the signal's own spread.
48
49 Scale-free on purpose: the four channels are in different units and a
50 threshold in degrees would not transfer to millimetres.
51 """
52 spread = float(np.nanmax(sig) - np.nanmin(sig))
53 if spread <= 0:
54 return float("nan"), 0.0, 0.0, spread
55 d = np.abs(np.diff(sig)) / spread
56 med = float(np.median(d))
57 mx = float(np.max(d))
58 if med <= 0:
59 return float("inf"), mx, med, spread
60 return mx / med, mx, med, spread
61
62
63def voronoi_quantise(sig, n_cells, rng):
64 """The CONTROL: the same signal, forced into cells with hard edges."""
65 edges = np.sort(rng.choice(np.arange(1, len(sig)), size=n_cells - 1, replace=False))
66 out = np.empty_like(sig)
67 for a, b in zip(np.r_[0, edges], np.r_[edges, len(sig)]):
68 out[a:b] = np.nanmean(sig[a:b])
69 return out
70
71
72def main():
73 ap = argparse.ArgumentParser()
74 ap.add_argument("--url", default="http://127.0.0.1:8000")
75 ap.add_argument("--i1", type=int, default=0, help="transect start, px")
76 ap.add_argument("--j1", type=int, default=0)
77 ap.add_argument("--n", type=int, default=1024,
78 help="transect length in px (30 m each)")
79 ap.add_argument("--band", type=int, default=8, help="rows averaged, to damp pixel noise")
80 ap.add_argument("--ratio-max", type=float, default=12.0,
81 help="max acceptable max/median step ratio")
82 a = ap.parse_args()
83
84 print(f"transect {a.n} px = {a.n * 30 / 1000:.2f} km at 30 m/px, "
85 f"from i1={a.i1} j1={a.j1}\n")
86
87 elev, clim = fetch_diffusion(a.url, a.i1, a.j1, a.n)
88 if clim is None:
89 raise SystemExit("service returned no climate channels — nothing to measure")
90 if clim.shape[2] != 4:
91 raise SystemExit(f"expected 4 climate channels, got {clim.shape[2]}")
92
93 rng = np.random.default_rng(0)
94 band = min(a.band, clim.shape[0])
95 print(f"{'channel':>26} {'spread':>12} {'max step':>10} {'med step':>10} "
96 f"{'ratio':>8} {'control':>8}")
97 fails, vacuous = [], []
98 for c, (name, _) in enumerate(CHANNELS):
99 sig = np.nanmean(clim[:band, :, c].astype(np.float64), axis=0)
100 ratio, mx, med, spread = step_ratio(sig)
101 ctl_ratio, _, _, _ = step_ratio(voronoi_quantise(sig, 8, rng))
102 flag = ""
103 if not np.isfinite(ratio) or ratio > a.ratio_max:
104 fails.append(name); flag = " <-- STEPPED"
105 # A channel that barely moves has not tested anything.
106 if spread <= 1e-6 or med <= 0:
107 vacuous.append(name); flag = " <-- FLAT, tests nothing"
108 print(f"{name:>26} {spread:>12.4g} {mx:>10.4f} {med:>10.4f} "
109 f"{ratio:>8.2f} {ctl_ratio:>8.2f}{flag}")
110
111 # The control must be rejected by the same threshold, or the test is blind.
112 ctl_ratios = []
113 for c in range(4):
114 sig = np.nanmean(clim[:band, :, c].astype(np.float64), axis=0)
115 r, _, _, s = step_ratio(voronoi_quantise(sig, 8, rng))
116 if s > 0:
117 ctl_ratios.append(r)
118 print()
119 if not ctl_ratios or min(ctl_ratios) <= a.ratio_max:
120 print(f"CONTROL NOT REJECTED (min {min(ctl_ratios) if ctl_ratios else float('nan'):.2f}"
121 f" <= {a.ratio_max}). The statistic cannot tell a blended field from")
122 print("a cell-partitioned one, so nothing above is evidence. Fix the probe.")
123 return 2
124 print(f"control (same data forced into 8 Voronoi cells) rejected at "
125 f"ratio {min(ctl_ratios):.1f}-{max(ctl_ratios):.1f} — the probe discriminates")
126
127 if vacuous:
128 print(f"\nINCONCLUSIVE: {', '.join(vacuous)} did not vary over this transect.")
129 print("A transect inside one biome is continuous for free. Move it or lengthen it.")
130 return 3
131 if fails:
132 print(f"\nVERDICT: {', '.join(fails)} STEPS. The climate field is not continuous")
133 print(" here, and a blending layer would be needed after all.")
134 return 1
135 print("\nVERDICT: all four channels vary smoothly across the transect. The")
136 print(" conditioning field is continuous by construction — biomes blend")
137 print(" without an overmap, and adding Voronoi cells could only")
138 print(" introduce the boundaries this does not have.")
139 return 0
140
141
142if __name__ == "__main__":
143 sys.exit(main())
survey_biomes.py +115 -0
……@@ -0,0 +1,115 @@
1#!/usr/bin/env python3
2"""
3HOW BIG DOES A TEST WORLD HAVE TO BE TO CONTAIN N BIOMES?
4
5A biome here is not a thing the model knows about. Every point carries four
6climate numbers (temp, temp seasonality, precip, precip variability) plus
7elevation, and a "biome" is a RULE over those. So the count is ours to choose;
8what is NOT ours to choose is how far you must travel before those numbers have
9visited enough of their range to satisfy the rules.
10
11That is what this measures, on the COARSE stage — 1 px = 7.68 km and ~700x
12cheaper per km2 than the detail stage, so a continent-sized survey is seconds.
13
14THE RULE SET IS DELIBERATELY PLAIN: a 3x3 Whittaker grid on temperature x
15precipitation, plus two elevation overrides (alpine, ocean). Eleven classes.
16Nothing here argues that these are the right biomes — they exist to make the
17question "how much world per biome" answerable with a number.
18
19⚠ IT REPORTS COVERAGE, NOT JUST COUNT. A biome occupying 0.1% of the map is
20present and useless to test with. The table gives the count at a >=1% threshold
21alongside the raw count, because "11 biomes" where nine are slivers is not a
22test world.
23
24 python survey_biomes.py --url http://127.0.0.1:8830 --n 128
25"""
26import argparse, urllib.request
27import numpy as np
28from server import coarse_to_metres
29
30COARSE_KM = 7.68
31
32
33def fetch_coarse(url, i1, j1, n):
34 q = f"{url}/coarse?i1={int(i1)}&j1={int(j1)}&i2={int(i1)+int(n)}&j2={int(j1)+int(n)}"
35 with urllib.request.urlopen(q, timeout=1800) as r:
36 h = int(r.headers["X-Height"]); w = int(r.headers["X-Width"])
37 c = int(r.headers["X-Channels"]); b = r.read()
38 return np.frombuffer(b, dtype="<f4").reshape(h, w, c).astype(np.float64)
39
40
41def classify(elev_m, temp, precip):
42 """3x3 Whittaker grid + alpine + ocean. Thresholds are QUANTILES of this
43 survey, so the classes exist by construction and the question stays 'how
44 much world', not 'did I guess the thresholds'."""
45 t1, t2 = np.percentile(temp, [33, 67])
46 p1, p2 = np.percentile(precip, [33, 67])
47 out = np.zeros(elev_m.shape, dtype=np.int32)
48 ti = np.digitize(temp, [t1, t2])
49 pi = np.digitize(precip, [p1, p2])
50 out = 1 + ti * 3 + pi # 1..9
51 out[elev_m > 2200] = 10 # alpine, above the trees
52 out[elev_m <= 0] = 0 # ocean
53 return out
54
55
56NAMES = {0: "ocean", 10: "alpine",
57 1: "cold-dry", 2: "cold-mid", 3: "cold-wet",
58 4: "temp-dry", 5: "temp-mid", 6: "temp-wet",
59 7: "warm-dry", 8: "warm-mid", 9: "warm-wet"}
60
61
62def main():
63 ap = argparse.ArgumentParser()
64 ap.add_argument("--url", default="http://127.0.0.1:8830")
65 ap.add_argument("--n", type=int, default=128, help="coarse px per side")
66 ap.add_argument("--i1", type=int, default=-64)
67 ap.add_argument("--j1", type=int, default=-64)
68 a = ap.parse_args()
69
70 arr = fetch_coarse(a.url, a.i1, a.j1, a.n)
71 side_km = a.n * COARSE_KM
72 print(f"survey {a.n}x{a.n} coarse px = {side_km:.0f} x {side_km:.0f} km "
73 f"= {side_km*side_km/1e6:.2f} M km2, {arr.shape[2]} channels\n")
74
75 # channel 0 is signed-square elevation (#50: R2 0.9952 vs 0.9126 linear)
76 # server.coarse_to_metres, NOT a reinvention of it. My first pass guessed
77 # sign(e)*e^2*1000 and produced elevations of +/-6 million metres, which
78 # then classified 38.5% of the world as alpine. The decoder already existed
79 # eight lines from the fetch helper this file imports.
80 elev_m = coarse_to_metres(arr[..., 0])
81 # CHANNELS, from world_pipeline.py:1064 — the synthetic map's five
82 # conditioning channels are normalised with MODEL_MEANS[[0,2,3,4,5]],
83 # SKIPPING index 1. So the six coarse channels are
84 # 0 elevation 1 ch0-raw = local relief 2 temperature
85 # 3 temp std 4 precipitation 5 precip variability
86 # My first pass took ch1 as temperature and ch3 as precipitation, which is
87 # relief and temperature-seasonality — both wrong, and ch1 correlates 0.98
88 # with elevation, which is what made biomes look altitude-driven.
89 temp, precip = arr[..., 2], arr[..., 4]
90 for i, nm in enumerate(["elev", "relief", "temp", "temp_std", "precip", "precip_var"][:arr.shape[2]]):
91 c = arr[..., i]
92 print(f" ch{i} {nm:<10} {c.min():10.3f} .. {c.max():10.3f}")
93
94 cls = classify(elev_m, temp, precip)
95 print(f"\n elevation {elev_m.min():.0f} .. {elev_m.max():.0f} m")
96
97 print(f"\n{'window':>10} {'area':>12} {'biomes':>8} {'>=1% of area':>14}")
98 for win_px in [2, 4, 8, 16, 32, 64, a.n]:
99 if win_px > a.n: continue
100 # take the CENTRE window, the honest sample for "a world this size"
101 c0 = (a.n - win_px) // 2
102 sub = cls[c0:c0+win_px, c0:c0+win_px]
103 vals, counts = np.unique(sub, return_counts=True)
104 frac = counts / sub.size
105 km = win_px * COARSE_KM
106 print(f"{win_px:>6} px {km:>6.0f} km sq {len(vals):>8} {int((frac >= 0.01).sum()):>14}")
107
108 vals, counts = np.unique(cls, return_counts=True)
109 print(f"\n full survey composition:")
110 for v, c in sorted(zip(vals, counts), key=lambda x: -x[1]):
111 print(f" {NAMES.get(int(v), v):<10} {100.0*c/cls.size:5.1f} %")
112
113
114if __name__ == "__main__":
115 main()