| … | … | @@ -0,0 +1,98 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | """ | |
| 3 | HOW LONG DOES ONE TILE TAKE ON THE CPU? | |
| 4 | ||
| 5 | The number the whole background-generation question turns on, and nobody had | |
| 6 | taken 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). | |
| 8 | CPU is the only option that does not contend — IF it is fast enough. | |
| 9 | ||
| 10 | A TIMER WITH NO CORRECTNESS GATE MEASURES NOTHING. #50 section 2.1 is the | |
| 11 | session where a 14x speedup turned out to be returning 70% exact zeros. So each | |
| 12 | timed tile is compared against the SAME tile in the cache, which was generated | |
| 13 | by the CUDA service at seed 1234. That gate also answers a second question that | |
| 14 | matters more than the timing: | |
| 15 | ||
| 16 | DO CPU AND GPU AGREE, AND HOW CLOSELY? | |
| 17 | ||
| 18 | Peer generation is built on tiles being a pure function of (seed, tx, ty) with | |
| 19 | the content hash AS the verification. That was measured across machines, not | |
| 20 | across DEVICES. If CPU and CUDA differ at all, then a CPU-generated tile has a | |
| 21 | different hash from a GPU-generated one and the two cannot verify each other — | |
| 22 | which is a design constraint, not a rounding detail. | |
| 23 | ||
| 24 | python bench_cpu_tile.py [--reps 3] | |
| 25 | """ | |
| 26 | import argparse, os, re, sys, time | |
| 27 | import numpy as np | |
| 28 | from server import fetch_diffusion | |
| 29 | ||
| 30 | CACHE = os.path.expanduser("~/.cache/terrain-lookdev/tiles") | |
| 31 | TILE_PX = 256 | |
| 32 | ||
| 33 | ||
| 34 | def 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 | ||
| 39 | def 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 | ||
| 97 | if __name__ == "__main__": | |
| 98 | main() |
| … | … | @@ -0,0 +1,143 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | """ | |
| 3 | DO BIOMES BLEND, OR DO THEY NEED AN OVERMAP TO BLEND THEM? | |
| 4 | ||
| 5 | The question was whether terrain-diffusion needs a Voronoi overmap to lay out | |
| 6 | biomes and blend them. Reading synthetic_map.py says no, and says why: the | |
| 7 | model is CONDITIONED on a climate field, and for a synthetic world that field | |
| 8 | is fBm Perlin (FastNoiseLite, 2-4 octaves, lacunarity 2, gain 0.5, base | |
| 9 | frequency 0.05) histogram-matched onto real WorldClim distributions via | |
| 10 | transform_perlin. A continuous field has no cells, so there is nothing to blend. | |
| 11 | ||
| 12 | This measures it rather than trusting the reading. | |
| 13 | ||
| 14 | THE STATISTIC. For each of the four climate channels (bio_1 temp, bio_4 temp | |
| 15 | seasonality, 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 | |
| 17 | smooth field has a max step close to its median step. A CELL-PARTITIONED field | |
| 18 | is flat inside cells and jumps at the boundaries, so its max/median explodes. | |
| 19 | That ratio is the discriminator. | |
| 20 | ||
| 21 | THE CONTROL IS THE ALTERNATIVE BEING ASKED ABOUT. The same data is re-quantised | |
| 22 | onto Voronoi cells and pushed through the identical statistic. The probe must | |
| 23 | REJECT that. If it does not, the statistic cannot tell a blended field from a | |
| 24 | partitioned one and nothing it says about the real field means anything — | |
| 25 | an 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 | |
| 28 | free. The run reports each channel's spread and REFUSES to pass if the transect | |
| 29 | does not actually vary — a probe that only samples one biome has not tested | |
| 30 | blending, and says so instead of printing green. | |
| 31 | ||
| 32 | python probe_biome_continuity.py --url http://127.0.0.1:8000 | |
| 33 | """ | |
| 34 | import argparse, sys | |
| 35 | import numpy as np | |
| 36 | from server import fetch_diffusion | |
| 37 | ||
| 38 | CHANNELS = [ | |
| 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 | ||
| 46 | def 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 | ||
| 63 | def 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 | ||
| 72 | def 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 | ||
| 142 | if __name__ == "__main__": | |
| 143 | sys.exit(main()) |
| … | … | @@ -0,0 +1,115 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | """ | |
| 3 | HOW BIG DOES A TEST WORLD HAVE TO BE TO CONTAIN N BIOMES? | |
| 4 | ||
| 5 | A biome here is not a thing the model knows about. Every point carries four | |
| 6 | climate numbers (temp, temp seasonality, precip, precip variability) plus | |
| 7 | elevation, and a "biome" is a RULE over those. So the count is ours to choose; | |
| 8 | what is NOT ours to choose is how far you must travel before those numbers have | |
| 9 | visited enough of their range to satisfy the rules. | |
| 10 | ||
| 11 | That is what this measures, on the COARSE stage — 1 px = 7.68 km and ~700x | |
| 12 | cheaper per km2 than the detail stage, so a continent-sized survey is seconds. | |
| 13 | ||
| 14 | THE RULE SET IS DELIBERATELY PLAIN: a 3x3 Whittaker grid on temperature x | |
| 15 | precipitation, plus two elevation overrides (alpine, ocean). Eleven classes. | |
| 16 | Nothing here argues that these are the right biomes — they exist to make the | |
| 17 | question "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 | |
| 20 | present and useless to test with. The table gives the count at a >=1% threshold | |
| 21 | alongside the raw count, because "11 biomes" where nine are slivers is not a | |
| 22 | test world. | |
| 23 | ||
| 24 | python survey_biomes.py --url http://127.0.0.1:8830 --n 128 | |
| 25 | """ | |
| 26 | import argparse, urllib.request | |
| 27 | import numpy as np | |
| 28 | from server import coarse_to_metres | |
| 29 | ||
| 30 | COARSE_KM = 7.68 | |
| 31 | ||
| 32 | ||
| 33 | def 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 | ||
| 41 | def 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 | ||
| 56 | NAMES = {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 | ||
| 62 | def 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 | ||
| 114 | if __name__ == "__main__": | |
| 115 | main() |