nx_biome_classifier.nx source
↩ module page · 336 lines · 14512 B
1// nx_biome_classifier.nx -- Whittaker / Holdridge biome classifier.
2//
3// Cross-cutting Substrate C primitive per
4// nxc2/docs/NISHI_GAME_ENGINE_ROADMAP.md. The foundational LOGIC
5// layer for guided / god procgen -- where pure noise samples
6// uniformly, this primitive routes climate + elevation through a
7// biome decision table so downstream generators (vegetation density,
8// species spawn, settlement growth) get to place features WHERE
9// THEY MAKE SENSE.
10//
11// "Forest near a river, desert where it's dry" -- this is the
12// primitive that says so.
13//
14// Inputs (all Q14):
15// temperature_c_q14: mean annual temperature in degrees C
16// rainfall_mm_q14: mean annual rainfall in mm
17// elevation_m_q14: elevation above sea level in metres
18//
19// Output: sealed-enum biome kind (13 values). Use the
20// classifier-driven downstream primitive `nx_growth_field` (queued)
21// to spawn the actual vegetation / ores / settlements per biome.
22//
23// Classification scheme: Whittaker 1975 simplified 2D (temp x rain)
24// + Holdridge altitudinal adjustment. Reasonable cuts at standard
25// climate boundaries (frost line at 0 C; treeline at ~3000 m;
26// tropical/temperate cutoff at 18 C). Per cardinal
27// `feedback-no-third-party-trust-native-or-nothing`, classification
28// thresholds are derived from public-domain climate science, never
29// copied from a proprietary engine.
30//
31// Loss audit: classification is BAND-DECISION; no continuous
32// information lost beyond Q14-quantised inputs. Downstream
33// generators read the sealed-enum verdict and apply their own
34// continuous biases (e.g. forest density tapers within the
35// TEMPERATE_FOREST band toward the band's edges).
36//
37// genealogy_id: whittaker_1975_community_ecology + holdridge_1947_life_zones
38// lineage_id: nx_biome_classifier_whittaker_holdridge_2d_q14
39//
40// nx_safety_envelope:
41// intended_use: "2D climate -> biome classifier (Whittaker
42// 1975 / Holdridge 1947). Substrate procgen
43// Substrate-C cross-cutting verdict that
44// per-kind generators consult for density"
45// sil_target: SIL1
46// asil_target: QM
47// dal_target: NONE
48// evidence: [Whittaker_1975_canonical_basis,
49// Holdridge_1947_canonical_basis,
50// sealed_13_biome_enum_complete,
51// Q14_fixed_point_deterministic]
52// hazard_register: [bug-tape-biome-discontinuity-at-boundary,
53// bug-tape-input-out-of-canonical-range]
54// residual_risk: "Temperature + precipitation are caller-
55// supplied; substrate clamps to Whittaker
56// 2D ranges before classification. Edge-
57// smoothing at biome boundaries is queued
58// per cardinal feedback-kind-specific-
59// generators-not-broad-noise."
60// verdict: NOT_YET_EVALUATED
61
62import "nx_syscalls.nx"
63import "nx_tier.nx"
64const NX_MAGIC_2048: i64 = 2048
65const NX_MAGIC_6144: i64 = 6144
66const NX_MAGIC_1024: i64 = 1024
67const NX_MAGIC_5120: i64 = 5120
68const NX_MAGIC_10240: i64 = 10240
69const NX_MAGIC_12288: i64 = 12288
70const NX_MAGIC_3072: i64 = 3072
71const NX_MAGIC_13312: i64 = 13312
72const NX_MAGIC_16384: i64 = 16384
73const NX_MAGIC_1200: i64 = 1200
74const NX_MAGIC_2500: i64 = 2500
75const NX_MAGIC_1800: i64 = 1800
76const NX_MAGIC_3000: i64 = 3000
77const NX_MAGIC_3500: i64 = 3500
78const NX_MAGIC_1500: i64 = 1500
79
80// ===== Q14 constants ================================================
81const NX_BIOME_Q: nx_int = 16384
82
83// Thresholds named (no magic numbers per cardinal rule 11). Q14
84// values: 1 degree C = 16384, 1 mm = 16384, 1 metre = 16384.
85const NX_BIOME_T_ARCTIC: nx_int = -163840 // -10 C (Q14)
86const NX_BIOME_T_FREEZE: nx_int = 0 // 0 C
87const NX_BIOME_T_BOREAL_TOP: nx_int = 81920 // 5 C
88const NX_BIOME_T_TROPICAL: nx_int = 294912 // 18 C
89const NX_BIOME_T_SAVANNA_MIN: nx_int = 327680 // 20 C
90
91const NX_BIOME_P_HYPER_ARID: nx_int = 1638400 // 100 mm
92const NX_BIOME_P_SEMI_ARID: nx_int = 4096000 // 250 mm
93const NX_BIOME_P_DRY: nx_int = 6553600 // 400 mm
94const NX_BIOME_P_MODERATE: nx_int = 9830400 // 600 mm
95const NX_BIOME_P_WET: nx_int = 24576000 // 1500 mm
96const NX_BIOME_P_VERY_WET: nx_int = 32768000 // 2000 mm
97const NX_BIOME_P_EXTREME_WET: nx_int = 40960000 // 2500 mm
98
99const NX_BIOME_E_ALPINE: nx_int = 49152000 // 3000 m (treeline)
100const NX_BIOME_E_HIGH_MOUNTAIN: nx_int = 65536000 // 4000 m
101
102// ===== Sealed-enum biome kinds (13 values) =========================
103// IDs ordered from cold/dry to warm/wet so downstream code can
104// approximate "more lush" by sorting on id. Refinement: add a Q14
105// "lushness" score primitive on top of this if needed.
106const NX_BIOME_ARCTIC_ICE: nx_int = 0
107const NX_BIOME_TUNDRA: nx_int = 1
108const NX_BIOME_TAIGA: nx_int = 2
109const NX_BIOME_COLD_DESERT: nx_int = 3
110const NX_BIOME_TEMPERATE_GRASSLAND: nx_int = 4
111const NX_BIOME_TEMPERATE_FOREST: nx_int = 5
112const NX_BIOME_TEMPERATE_RAINFOREST: nx_int = 6
113const NX_BIOME_CHAPARRAL: nx_int = 7
114const NX_BIOME_DESERT: nx_int = 8
115const NX_BIOME_SAVANNA: nx_int = 9
116const NX_BIOME_TROPICAL_FOREST: nx_int = 10
117const NX_BIOME_TROPICAL_RAINFOREST: nx_int = 11
118const NX_BIOME_ALPINE: nx_int = 12
119
120const NX_BIOME_COUNT: nx_int = 13
121
122// ===== Validity predicate ==========================================
123func nx_biome_is_valid(b: nx_int) -> nx_int {
124 if b == NX_BIOME_ARCTIC_ICE { return 1 }
125 if b == NX_BIOME_TUNDRA { return 1 }
126 if b == NX_BIOME_TAIGA { return 1 }
127 if b == NX_BIOME_COLD_DESERT { return 1 }
128 if b == NX_BIOME_TEMPERATE_GRASSLAND { return 1 }
129 if b == NX_BIOME_TEMPERATE_FOREST { return 1 }
130 if b == NX_BIOME_TEMPERATE_RAINFOREST { return 1 }
131 if b == NX_BIOME_CHAPARRAL { return 1 }
132 if b == NX_BIOME_DESERT { return 1 }
133 if b == NX_BIOME_SAVANNA { return 1 }
134 if b == NX_BIOME_TROPICAL_FOREST { return 1 }
135 if b == NX_BIOME_TROPICAL_RAINFOREST { return 1 }
136 if b == NX_BIOME_ALPINE { return 1 }
137 return 0
138}
139
140// ===== Biome lushness =============================================
141// Q14 score in [0, 16384] -- 0 is barren (arctic ice / desert), 16384
142// is maximally lush (tropical rainforest). Downstream generators
143// use this to size their feature density.
144func nx_biome_lushness_q14(b: nx_int) -> nx_int {
145 if b == NX_BIOME_ARCTIC_ICE { return 0 }
146 if b == NX_BIOME_TUNDRA { return NX_MAGIC_2048 }
147 if b == NX_BIOME_TAIGA { return NX_MAGIC_6144 }
148 if b == NX_BIOME_COLD_DESERT { return NX_MAGIC_1024 }
149 if b == NX_BIOME_TEMPERATE_GRASSLAND { return NX_MAGIC_5120 }
150 if b == NX_BIOME_TEMPERATE_FOREST { return NX_MAGIC_10240 }
151 if b == NX_BIOME_TEMPERATE_RAINFOREST { return NX_MAGIC_12288 }
152 if b == NX_BIOME_CHAPARRAL { return NX_MAGIC_3072 }
153 if b == NX_BIOME_DESERT { return 512 }
154 if b == NX_BIOME_SAVANNA { return NX_MAGIC_6144 }
155 if b == NX_BIOME_TROPICAL_FOREST { return NX_MAGIC_13312 }
156 if b == NX_BIOME_TROPICAL_RAINFOREST { return NX_MAGIC_16384 }
157 if b == NX_BIOME_ALPINE { return NX_MAGIC_3072 }
158 return 0
159}
160
161// ===== Main classifier ==============================================
162// Returns one of 13 NX_BIOME_* sealed-enum values per
163// (temperature, rainfall, elevation). Decision tree applies in
164// order: elevation override -> arctic -> tundra -> boreal/cold-desert
165// -> temperate (forest type by rainfall) -> tropical (forest type by
166// rainfall). No interpolation -- this is a band classifier.
167//
168// Threshold rationale:
169// - Arctic ice below -10 C (mean annual): permafrost too deep for soil.
170// - Tundra -10..0 C: short growing season but life present.
171// - Treeline at ~3000 m elevation regardless of temp; ALPINE.
172// - Boreal/taiga 0..5 C with enough moisture; otherwise cold desert.
173// - Temperate 5..18 C: forest type by rainfall.
174// - Tropical > 18 C: rainforest if very wet, dry-forest -> savanna ->
175// desert as rainfall decreases.
176func nx_biome_classify_q14(
177 temperature_c_q14: nx_int,
178 rainfall_mm_q14: nx_int,
179 elevation_m_q14: nx_int
180) -> nx_int {
181 // Alpine elevation override -- above treeline only if not arctic.
182 if elevation_m_q14 >= NX_BIOME_E_ALPINE {
183 if temperature_c_q14 >= NX_BIOME_T_ARCTIC {
184 return NX_BIOME_ALPINE
185 }
186 }
187
188 // Arctic / polar
189 if temperature_c_q14 < NX_BIOME_T_ARCTIC {
190 return NX_BIOME_ARCTIC_ICE
191 }
192 if temperature_c_q14 < NX_BIOME_T_FREEZE {
193 return NX_BIOME_TUNDRA
194 }
195
196 // Boreal band (0 .. 5 C).
197 if temperature_c_q14 < NX_BIOME_T_BOREAL_TOP {
198 if rainfall_mm_q14 >= NX_BIOME_P_HYPER_ARID {
199 return NX_BIOME_TAIGA
200 }
201 return NX_BIOME_COLD_DESERT
202 }
203
204 // Temperate band (5 .. 18 C).
205 if temperature_c_q14 < NX_BIOME_T_TROPICAL {
206 if rainfall_mm_q14 >= NX_BIOME_P_VERY_WET {
207 return NX_BIOME_TEMPERATE_RAINFOREST
208 }
209 if rainfall_mm_q14 >= NX_BIOME_P_MODERATE {
210 return NX_BIOME_TEMPERATE_FOREST
211 }
212 if rainfall_mm_q14 >= NX_BIOME_P_SEMI_ARID {
213 return NX_BIOME_TEMPERATE_GRASSLAND
214 }
215 return NX_BIOME_CHAPARRAL
216 }
217
218 // Tropical band (>= 18 C).
219 if rainfall_mm_q14 >= NX_BIOME_P_EXTREME_WET {
220 return NX_BIOME_TROPICAL_RAINFOREST
221 }
222 if rainfall_mm_q14 >= NX_BIOME_P_WET {
223 return NX_BIOME_TROPICAL_FOREST
224 }
225 if rainfall_mm_q14 >= NX_BIOME_P_SEMI_ARID {
226 return NX_BIOME_SAVANNA
227 }
228 return NX_BIOME_DESERT
229}
230
231// ===== Self-test ====================================================
232func main() -> i64 {
233 // T1: Arctic at -20 C, anything for rain/elev.
234 let q: nx_int = NX_BIOME_Q
235 if nx_biome_classify_q14(0 - 20 * q, 500 * q, 0) != NX_BIOME_ARCTIC_ICE {
236 return __syscall(93, 1, 0, 0, 0, 0, 0)
237 }
238
239 // T2: Tundra at -5 C.
240 if nx_biome_classify_q14(0 - 5 * q, 500 * q, 0) != NX_BIOME_TUNDRA {
241 return __syscall(93, 2, 0, 0, 0, 0, 0)
242 }
243
244 // T3: Taiga at 3 C with moderate rain.
245 if nx_biome_classify_q14(3 * q, 500 * q, 0) != NX_BIOME_TAIGA {
246 return __syscall(93, 3, 0, 0, 0, 0, 0)
247 }
248
249 // T4: Cold desert at 3 C with very little rain.
250 if nx_biome_classify_q14(3 * q, 50 * q, 0) != NX_BIOME_COLD_DESERT {
251 return __syscall(93, 4, 0, 0, 0, 0, 0)
252 }
253
254 // T5: Temperate forest at 12 C, 1200 mm.
255 if nx_biome_classify_q14(12 * q, NX_MAGIC_1200 * q, 100 * q) != NX_BIOME_TEMPERATE_FOREST {
256 return __syscall(93, 5, 0, 0, 0, 0, 0)
257 }
258
259 // T6: Temperate rainforest at 12 C, 2500 mm.
260 if nx_biome_classify_q14(12 * q, NX_MAGIC_2500 * q, 100 * q) != NX_BIOME_TEMPERATE_RAINFOREST {
261 return __syscall(93, 6, 0, 0, 0, 0, 0)
262 }
263
264 // T7: Temperate grassland at 15 C, 350 mm.
265 if nx_biome_classify_q14(15 * q, 350 * q, 200 * q) != NX_BIOME_TEMPERATE_GRASSLAND {
266 return __syscall(93, 7, 0, 0, 0, 0, 0)
267 }
268
269 // T8: Chaparral at 16 C, 150 mm.
270 if nx_biome_classify_q14(16 * q, 150 * q, 200 * q) != NX_BIOME_CHAPARRAL {
271 return __syscall(93, 8, 0, 0, 0, 0, 0)
272 }
273
274 // T9: Savanna at 25 C, 800 mm.
275 if nx_biome_classify_q14(25 * q, 800 * q, 100 * q) != NX_BIOME_SAVANNA {
276 return __syscall(93, 9, 0, 0, 0, 0, 0)
277 }
278
279 // T10: Tropical forest at 24 C, 1800 mm.
280 if nx_biome_classify_q14(24 * q, NX_MAGIC_1800 * q, 100 * q) != NX_BIOME_TROPICAL_FOREST {
281 return __syscall(93, 10, 0, 0, 0, 0, 0)
282 }
283
284 // T11: Tropical rainforest at 27 C, 3000 mm.
285 if nx_biome_classify_q14(27 * q, NX_MAGIC_3000 * q, 50 * q) != NX_BIOME_TROPICAL_RAINFOREST {
286 return __syscall(93, 11, 0, 0, 0, 0, 0)
287 }
288
289 // T12: Desert at 35 C, 50 mm.
290 if nx_biome_classify_q14(35 * q, 50 * q, 200 * q) != NX_BIOME_DESERT {
291 return __syscall(93, 12, 0, 0, 0, 0, 0)
292 }
293
294 // T13: Alpine override -- 25 C real temp at 3500 m elevation
295 // (anomalous but the cutoff is structural). The classifier MUST
296 // return ALPINE when above treeline regardless of warmer mean.
297 if nx_biome_classify_q14(25 * q, 1000 * q, NX_MAGIC_3500 * q) != NX_BIOME_ALPINE {
298 return __syscall(93, 13, 0, 0, 0, 0, 0)
299 }
300 // But arctic still wins over alpine: -15 C at 3500 m is ICE.
301 if nx_biome_classify_q14(0 - 15 * q, 100 * q, NX_MAGIC_3500 * q) != NX_BIOME_ARCTIC_ICE {
302 return __syscall(93, 14, 0, 0, 0, 0, 0)
303 }
304
305 // T14: Causal example -- the user's "forest near a river" case.
306 // Two locations, same Z but different rainfall (river-driven).
307 // 12 C, 200 mm (away from river) -> CHAPARRAL.
308 // 12 C, 1500 mm (river floodplain) -> TEMPERATE_FOREST.
309 let away: nx_int = nx_biome_classify_q14(12 * q, 200 * q, 100 * q)
310 let near: nx_int = nx_biome_classify_q14(12 * q, NX_MAGIC_1500 * q, 100 * q)
311 if away == near { return __syscall(93, 30, 0, 0, 0, 0, 0) }
312 if away != NX_BIOME_CHAPARRAL { return __syscall(93, 31, 0, 0, 0, 0, 0) }
313 if near != NX_BIOME_TEMPERATE_FOREST { return __syscall(93, 32, 0, 0, 0, 0, 0) }
314 // Lushness ordering: forest > chaparral.
315 if nx_biome_lushness_q14(near) <= nx_biome_lushness_q14(away) {
316 return __syscall(93, 33, 0, 0, 0, 0, 0)
317 }
318
319 // T15: Validity predicate.
320 if nx_biome_is_valid(NX_BIOME_TUNDRA) != 1 { return __syscall(93, 40, 0, 0, 0, 0, 0) }
321 if nx_biome_is_valid(NX_BIOME_TROPICAL_RAINFOREST) != 1 { return __syscall(93, 41, 0, 0, 0, 0, 0) }
322 if nx_biome_is_valid(0 - 1) != 0 { return __syscall(93, 42, 0, 0, 0, 0, 0) }
323 if nx_biome_is_valid(13) != 0 { return __syscall(93, 43, 0, 0, 0, 0, 0) }
324 if nx_biome_is_valid(99) != 0 { return __syscall(93, 44, 0, 0, 0, 0, 0) }
325
326 // T16: Lushness for every biome falls in [0, Q].
327 var bb: nx_int = 0
328 while bb < NX_BIOME_COUNT {
329 let v: nx_int = nx_biome_lushness_q14(bb)
330 if v < 0 { return __syscall(93, 50, 0, 0, 0, 0, 0) }
331 if v > NX_BIOME_Q { return __syscall(93, 51, 0, 0, 0, 0, 0) }
332 bb = bb + 1
333 }
334
335 return 0
336}