code wiki / (root) / nx_ice_recrystal.nx

nx_ice_recrystal.nx source

↩ module page · 305 lines · 12893 B

1// nx_ice_recrystal.nx -- HOW FROZEN DESSERT ACTUALLY DIES. 2// 3// ===== WHY THIS EXISTS ============================================ 4// 5// The stability stack in this lane models first-order CHEMICAL degradation: 6// a molecule decays, potency falls, an expiry date follows. For most 7// products that is the failure mode. For ice cream it is not. 8// 9// Ice cream almost never fails chemically. It fails because its ice 10// crystals GROW -- small crystals melt and re-deposit onto large ones until 11// the texture turns coarse and sandy. That process is not first order, it 12// has no activation energy in the Arrhenius sense, and it is driven mainly 13// by temperature FLUCTUATION rather than by mean temperature. So the lane 14// had a complete frozen-dessert stack and a complete stability stack, and 15// nothing that could say when an ice cream goes bad. 16// 17// ===== THE MECHANISM ============================================== 18// 19// Ostwald ripening. A small crystal has high surface curvature, which by 20// the Gibbs-Thomson effect depresses its local melting point -- so on any 21// warming it is the small crystals that melt first. On the following 22// cooling that water refreezes, but onto the crystals that survived, which 23// are the large ones. Every cycle therefore removes small crystals and 24// feeds large ones. Nothing is lost or gained in mass; the DISTRIBUTION 25// coarsens, and coarseness is what the tongue detects. 26// 27// Isothermal growth follows the cube law of Lifshitz-Slyozov-Wagner: 28// 29// D(t)^3 = D(0)^3 + k*t 30// 31// The cube is the signature of diffusion-limited ripening and is why 32// recrystallisation cannot be written as a rate constant on concentration. 33// 34// ===== WHAT IS COMPUTED VS ASSERTED =============================== 35// 36// ★THE CYCLING TERM IS DERIVED, NOT ASSERTED, AND THAT IS THE POINT. The 37// mass of ice that melts and refreezes on a warm excursion is exactly the 38// change in frozen fraction between the two temperatures -- and this lane 39// already computes frozen fraction from colligative physics 40// (ic_frozen_water_permil). So cycling damage falls straight out of the 41// freezing curve that was validated against published data in wave 1. No 42// new physical constant is introduced to describe it. 43// 44// ★k IS RECOVERED FROM OBSERVATION, NOT TYPED. Two measured crystal sizes 45// at two times determine k exactly (ir_k_from_observation), the same 46// discipline that recovered the pasteurisation z-value from two legal 47// schedules and the ICH activation energy from two storage conditions. A 48// typed default exists but is FLAGGED, and every caller can see which it 49// got via ir_k_is_recovered. 50// 51// ⚠THE GEOMETRIC COUPLING IS GENUINELY ASSERTED. Converting "this mass 52// fraction of ice re-deposited" into "the mean diameter grew by this much" 53// needs an assumption about the crystal size distribution. IR_COUPLING is 54// that assumption, it is not derived from anything here, and 55// ir_coupling_is_asserted() returns 1 so no consumer can mistake it for 56// physics. It is the honest weak point of this organ. 57// 58// grounded: lifshitz_slyozov_wagner_ripening + gibbs_thomson_curvature 59// + donhowe_hartel_ice_cream_recrystallisation 60// genealogy_id: nishi_food_chem_lane_recrystal_2026_07 61// license_tier: ORIGINAL 62 63import "nx_syscalls.nx" 64import "nx_icecream.nx" 65const IR_MAGIC_3000000: i64 = 3000000 66const IR_MAGIC_1000000000000: i64 = 1000000000000 67const IR_MAGIC_100000: i64 = 100000 68 69const IR_INVALID: i64 = 0 - 1 70 71// Diameters are carried in TENTHS OF A MICROMETRE. Finer than any practical 72// measurement, and it keeps D^3 far away from the i64 ceiling: 50 um is 500 73// here and 500^3 is 1.25e8, so even heavily coarsened states stay small. 74const IR_UM: i64 = 10 75 76// Published sensory landmarks for frozen dessert (Donhowe & Hartel). These 77// are perception thresholds, not physics -- cited, never derived. 78const IR_FRESH_D: i64 = 250 // 25 um, typical well-made fresh ice cream 79const IR_DETECT_D: i64 = 400 // 40 um, coarseness becomes perceptible 80const IR_COARSE_D: i64 = 500 // 50 um, unambiguously icy/sandy 81 82// Fallback isothermal rate in (0.1 um)^3 per day, near -18 C. ASSERTED. 83const IR_K_DEFAULT: i64 = 1200 84 85// Geometric coupling: how much mean VOLUME grows per unit of cycled ice mass 86// fraction. Asserted; see the header. Expressed per mil so it composes in 87// integer arithmetic. 88const IR_COUPLING: i64 = 1000 89 90// === Integer cube root ============================================ 91 92// Largest r with r^3 <= v, by binary search. Exact, terminating, and no 93// float anywhere: the whole point of the cube law is lost if the cube root 94// that inverts it is approximate in an unstated way. 95func ir_icbrt(v: i64) -> i64 { 96 if v < 0 { return IR_INVALID } 97 if v == 0 { return 0 } 98 var lo: i64 = 0 99 var hi: i64 = 1 100 while hi * hi * hi <= v { 101 lo = hi 102 hi = hi * 2 103 if hi > IR_MAGIC_3000000 { return IR_INVALID } 104 } 105 while lo + 1 < hi { 106 let mid: i64 = (lo + hi) / 2 107 if mid * mid * mid <= v { lo = mid } else { hi = mid } 108 } 109 return lo 110} 111 112func ir_cube(d: i64) -> i64 { 113 if d < 0 { return IR_INVALID } 114 if d > IR_MAGIC_3000000 { return IR_INVALID } 115 return d * d * d 116} 117 118// === k, recovered from measurement ================================ 119 120// The rate constant implied by observing a crystal population grow from d0 121// to d1 over `days`. REFUSES a shrinking or static observation: crystals do 122// not un-ripen, so that input is a measurement error and returning a 123// negative or zero k would propagate it as a shelf life. 124func ir_k_from_observation(d0: i64, d1: i64, days: i64) -> i64 { 125 if days <= 0 { return IR_INVALID } 126 if d0 <= 0 { return IR_INVALID } 127 if d1 <= d0 { return IR_INVALID } 128 let c0: i64 = ir_cube(d0) 129 let c1: i64 = ir_cube(d1) 130 if c0 == IR_INVALID { return IR_INVALID } 131 if c1 == IR_INVALID { return IR_INVALID } 132 return (c1 - c0) / days 133} 134 135// Did this k come from data or from the typed fallback? A consumer that 136// cannot tell the difference will report a measured shelf life it does not 137// have. 138func ir_k_is_recovered(k: i64) -> i64 { 139 if k == IR_K_DEFAULT { return 0 } 140 return 1 141} 142 143func ir_coupling_is_asserted() -> i64 { 144 return 1 145} 146 147// === Isothermal growth ============================================ 148 149// Mean crystal diameter after `days` of steady storage. 150func ir_size_isothermal(d0: i64, k: i64, days: i64) -> i64 { 151 if d0 <= 0 { return IR_INVALID } 152 if k < 0 { return IR_INVALID } 153 if days < 0 { return IR_INVALID } 154 let c: i64 = ir_cube(d0) 155 if c == IR_INVALID { return IR_INVALID } 156 return ir_icbrt(c + k * days) 157} 158 159// === Cycling: the part that is DERIVED from the freezing curve ===== 160 161// Fraction (permil) of the ice that melts and refreezes when a mix is warmed 162// from t_cold to t_warm and returned. 163// 164// ★This is the whole composition: the frozen fraction at each temperature is 165// already computed by nx_icecream from colligative physics, so the cycled 166// mass is a DIFFERENCE OF TWO NUMBERS THIS LANE ALREADY TRUSTS rather than a 167// new empirical parameter. 168// 169// Returns 0 for a non-excursion (warm end not warmer), which is correct 170// rather than an error: a cycle that does not warm melts nothing. 171func ir_cycled_fraction_permil(m: *NxIceMix, t_cold_milli_c: i64, t_warm_milli_c: i64) -> i64 { 172 if t_warm_milli_c <= t_cold_milli_c { return 0 } 173 let f_cold: i64 = ic_frozen_water_permil(m, t_cold_milli_c) 174 let f_warm: i64 = ic_frozen_water_permil(m, t_warm_milli_c) 175 if f_cold <= 0 { return IR_INVALID } 176 if f_warm < 0 { return IR_INVALID } 177 if f_warm >= f_cold { return 0 } 178 return (f_cold - f_warm) * 1000 / f_cold 179} 180 181// Mean diameter after `cycles` excursions, starting from d0. 182// 183// Volume compounds: each cycle re-deposits the cycled mass onto the 184// surviving crystals, so the growth MULTIPLIES rather than adds. That is 185// why many small excursions are so much worse than one long warm hold -- 186// the damage is geometric in the number of cycles, not linear in the time 187// spent warm. 188func ir_size_after_cycles(d0: i64, cycled_permil: i64, cycles: i64) -> i64 { 189 if d0 <= 0 { return IR_INVALID } 190 if cycled_permil < 0 { return IR_INVALID } 191 if cycles < 0 { return IR_INVALID } 192 var v: i64 = ir_cube(d0) 193 if v == IR_INVALID { return IR_INVALID } 194 let step: i64 = cycled_permil * IR_COUPLING / 1000 195 var i: i64 = 0 196 while i < cycles { 197 let grow: i64 = v * step / 1000 198 if grow < 0 { return IR_INVALID } 199 v = v + grow 200 if v > IR_MAGIC_1000000000000 { return IR_INVALID } 201 i = i + 1 202 } 203 return ir_icbrt(v) 204} 205 206// Same compounding, but taking the per-cycle VOLUME GROWTH directly instead 207// of deriving it from the asserted coupling. 208// 209// ★THIS IS THE PATH THAT SHOULD BE USED. nx_ice_distribution computes that 210// growth exactly from a crystal size distribution -- melt the smallest, count 211// what was lost, mass over count gives the new mean -- so a caller with a 212// distribution needs no coupling constant at all. ir_size_after_cycles 213// remains for callers who have no distribution, and it is OPTIMISTIC: the 214// asserted coupling of 1.0 is the claim that the number fraction of crystals 215// lost equals the mass fraction melted, and measurement puts the real ratio 216// near 4x for a realistic profile. 217func ir_size_after_cycles_growth(d0: i64, growth_permil: i64, cycles: i64) -> i64 { 218 if d0 <= 0 { return IR_INVALID } 219 if growth_permil < 0 { return IR_INVALID } 220 if cycles < 0 { return IR_INVALID } 221 var v: i64 = ir_cube(d0) 222 if v == IR_INVALID { return IR_INVALID } 223 var i: i64 = 0 224 while i < cycles { 225 let grow: i64 = v / 1000 * growth_permil + (v % 1000) * growth_permil / 1000 226 if grow < 0 { return IR_INVALID } 227 v = v + grow 228 if v > IR_MAGIC_1000000000000 { return IR_INVALID } 229 i = i + 1 230 } 231 return ir_icbrt(v) 232} 233 234func ir_size_combined_growth(d0: i64, k: i64, days: i64, growth_permil: i64, cycles: i64) -> i64 { 235 let iso: i64 = ir_size_isothermal(d0, k, days) 236 if iso == IR_INVALID { return IR_INVALID } 237 return ir_size_after_cycles_growth(iso, growth_permil, cycles) 238} 239 240// Both mechanisms together over a real distribution history. 241func ir_size_combined(d0: i64, k: i64, days: i64, cycled_permil: i64, cycles: i64) -> i64 { 242 let iso: i64 = ir_size_isothermal(d0, k, days) 243 if iso == IR_INVALID { return IR_INVALID } 244 return ir_size_after_cycles(iso, cycled_permil, cycles) 245} 246 247// === Verdict ====================================================== 248 249// 0 smooth, 1 perceptible, 2 coarse. Fail-closed: an unusable diameter 250// reads as COARSE, because reporting "smooth" for a state we cannot evaluate 251// is the one answer that would ship a defective product. 252func ir_texture_grade(d: i64) -> i64 { 253 if d == IR_INVALID { return 2 } 254 if d <= 0 { return 2 } 255 if d >= IR_COARSE_D { return 2 } 256 if d >= IR_DETECT_D { return 1 } 257 return 0 258} 259 260// Days of steady storage until coarseness becomes perceptible. 261func ir_days_to_detectable(d0: i64, k: i64) -> i64 { 262 if k <= 0 { return IR_INVALID } 263 if d0 <= 0 { return IR_INVALID } 264 if d0 >= IR_DETECT_D { return 0 } 265 let c0: i64 = ir_cube(d0) 266 let ct: i64 = ir_cube(IR_DETECT_D) 267 return (ct - c0) / k 268} 269 270// How many excursions of this size the product survives before coarsening. 271// Bounded, and the bound is REPORTED as the sentinel rather than silently 272// becoming "survives forever". 273func ir_cycles_to_detectable(d0: i64, cycled_permil: i64) -> i64 { 274 if d0 <= 0 { return IR_INVALID } 275 if cycled_permil <= 0 { return IR_INVALID } 276 if d0 >= IR_DETECT_D { return 0 } 277 var n: i64 = 0 278 var d: i64 = d0 279 while n < IR_MAGIC_100000 { 280 if d >= IR_DETECT_D { return n } 281 n = n + 1 282 d = ir_size_after_cycles(d0, cycled_permil, n) 283 if d == IR_INVALID { return IR_INVALID } 284 } 285 return IR_INVALID 286} 287 288// ★★WHY A SINGLE EFFECTIVE TEMPERATURE CANNOT DESCRIBE THIS. 289// 290// Mean kinetic temperature answers "what steady temperature produces the 291// chemical degradation this varying history produced". It is the right tool 292// for a first-order reaction and this lane uses it for exactly that. 293// 294// It cannot work here, and not because it is imprecise -- because the driver 295// is different in kind. Recrystallisation is fed by the OSCILLATION: the 296// ice that melts on each warming is what refreezes onto large crystals on 297// each cooling. Collapsing a history to any single temperature discards the 298// oscillation and therefore discards the entire cycling mechanism, leaving 299// only isothermal ripening. 300// 301// Returns 0, as a function rather than a comment, so no planner can feed an 302// MKT into a recrystallisation model and believe the answer. 303func ir_mkt_describes_recrystallisation() -> i64 { 304 return 0 305}