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}