nx_thermal_process.nx source
↩ module page · 261 lines · 11782 B
1// nx_thermal_process.nx -- FOOD-SCIENCE SUITE / THERMAL PROCESS rung. The
2// heat side of food safety: how much lethality a time-temperature hold
3// actually delivers, whether a proposed process meets the legal
4// pasteurization schedule, and -- the decision that governs everything --
5// whether a food needs a botulinum cook at all.
6//
7// This completes a triangle the ecosystem already had two corners of.
8// nx_ferment_safety refuses unsafe FERMENTS on the pH 4.6 botulinum line.
9// nx_chem_solution owns that line as chemistry (CS_HIGH_ACID_PH_MILLI).
10// Neither could say anything about HEAT, so a mix that is pasteurised
11// rather than acidified had no safety path -- and ice cream mix is exactly
12// that case, since 21 CFR 135.110 requires the mix to be pasteurised.
13//
14// ACID AND HEAT ARE ALTERNATIVE BARRIERS, NOT A CHECKLIST. That is the
15// whole logic of tp_barrier_required, and it is why this imports
16// nx_chem_solution instead of restating 4.6:
17// pH < 4.6 -> the ACID barrier holds; C. botulinum
18// cannot grow; pasteurisation suffices.
19// pH >= 4.6, shelf-stable -> nothing but heat stands between the
20// product and botulism -> full 12D cook.
21// pH >= 4.6, kept cold -> pasteurise AND hold the cold chain;
22// refrigeration is the second barrier.
23// Ice cream mix is the third case. Treating it as the second would be a
24// pointless retort; treating it as the first would be dangerous.
25//
26// WHAT IS ASSERTED VS WHAT IS DERIVED -- read this before trusting a number.
27// The only organism constant here is C. botulinum proteolytic spores,
28// D(121.1 C) = 0.21 min, z = 10 C. That pair is canonical, universally
29// published, and is the entire basis of the 12D cook and of F0. I did NOT
30// bake in D/z values for the pasteurisation organisms, because I could not
31// verify them to the precision a safety calculation deserves.
32//
33// Instead the z-value for pasteurisation is DERIVED FROM THE LAW ITSELF.
34// The PMO gives two schedules it considers equivalent -- 145 F/30 min and
35// 161 F/15 s -- and two equivalent schedules uniquely determine a z:
36// z = (T2 - T1) / log10(t1/t2)
37// tp_implied_z_milli computes it and gets 4.279 C (exact arithmetic on the
38// pair gives 4.275, so the integer path is good to 0.1%). That sits just
39// BELOW the commonly quoted 4.3-5.0 C band for the milk-pasteurisation design
40// organism -- close agreement, but stated precisely rather than rounded up
41// into the band. The number this file uses was recovered, not guessed.
42//
43// Units: temperature in milli-Celsius, time in MILLISECONDS (a 30 min vat
44// hold and a 0.01 s flash schedule must share one unit), D-values in ms.
45//
46// Grounding (cited; researcher-groundable):
47// stumbo_thermobacteriology_d_z_f0_model
48// c_botulinum_proteolytic_d121_0_21min_z10 (the 12D basis)
49// grade_a_pmo_pasteurization_time_temperature_table
50// pmo_five_degree_increase_fat_10pct_or_sweetened (ice cream mix)
51// fda_21cfr113_low_acid_canned_food_12d
52//
53// genealogy_id: thermal_process_authority + nishi_food_science_suite
54
55import "nx_syscalls.nx"
56import "nx_pow10.nx"
57import "nx_chem_solution.nx"
58
59// ===== C. botulinum reference kinetics (the one asserted organism) =====
60
61const TP_REF_STERIL_MILLI_C: i64 = 121100 // 121.1 C = 250 F
62const TP_BOT_D_REF_MS: i64 = 12600 // D(121.1) = 0.21 min
63const TP_BOT_Z_MILLI: i64 = 10000 // z = 10 C
64const TP_BOT_TARGET_LOGS: i64 = 12 // the 12D cook
65const TP_F0_STANDARD_MS: i64 = 180000 // 3.0 min, the industry target
66
67// ===== Grade A PMO pasteurization schedules ===========================
68//
69// Base (milk) legs, and the PMO's flat five-degree-Fahrenheit increase for
70// any product at or above 10% fat OR carrying added sweetener -- which is
71// ice cream mix on both counts. The bumped temperatures are DERIVED from
72// the base plus the bump, never restated, so the two can never drift.
73
74const TP_MILK_VAT_TEMP_MILLI: i64 = 62778 // 145 F
75const TP_MILK_VAT_TIME_MS: i64 = 1800000 // 30 min
76const TP_MILK_HTST_TEMP_MILLI: i64 = 71667 // 161 F
77const TP_MILK_HTST_TIME_MS: i64 = 15000 // 15 s
78const TP_HIGHFAT_BUMP_MILLI: i64 = 2778 // +5 F
79
80const TP_SCHED_MILK_VAT: i64 = 0
81const TP_SCHED_MILK_HTST: i64 = 1
82const TP_SCHED_MIX_VAT: i64 = 2
83const TP_SCHED_MIX_HTST: i64 = 3
84
85// ===== Barrier outcomes (sealed) ======================================
86
87const TP_BARRIER_NONE: i64 = 0
88const TP_BARRIER_ACID: i64 = 1
89const TP_BARRIER_BOT_COOK: i64 = 2
90const TP_BARRIER_REFRIGERATION: i64 = 3
91
92// ===== Schedule accessors =============================================
93
94func tp_sched_temp_milli(id: i64) -> i64 {
95 if id == TP_SCHED_MILK_VAT { return TP_MILK_VAT_TEMP_MILLI }
96 if id == TP_SCHED_MILK_HTST { return TP_MILK_HTST_TEMP_MILLI }
97 if id == TP_SCHED_MIX_VAT { return TP_MILK_VAT_TEMP_MILLI + TP_HIGHFAT_BUMP_MILLI }
98 if id == TP_SCHED_MIX_HTST { return TP_MILK_HTST_TEMP_MILLI + TP_HIGHFAT_BUMP_MILLI }
99 return 0
100}
101
102func tp_sched_time_ms(id: i64) -> i64 {
103 if id == TP_SCHED_MILK_VAT { return TP_MILK_VAT_TIME_MS }
104 if id == TP_SCHED_MILK_HTST { return TP_MILK_HTST_TIME_MS }
105 if id == TP_SCHED_MIX_VAT { return TP_MILK_VAT_TIME_MS }
106 if id == TP_SCHED_MIX_HTST { return TP_MILK_HTST_TIME_MS }
107 return 0
108}
109
110// ===== Core kinetics ==================================================
111//
112// PRECISION NOTE, and it matters for safety: a negative exponent is applied
113// by DIVIDING by the positive power, never by multiplying by a Q3
114// reciprocal. 10^-2.077 in Q3 is the integer 8 -- one significant figure,
115// a 4% error that made a legally-equivalent vat schedule read as
116// non-compliant on the first attempt here. Dividing keeps full precision.
117
118// D-value at temperature T: D_T = D_ref * 10^((T_ref - T)/z).
119func tp_d_at_temp_ms(d_ref_ms: i64, t_ref_milli: i64, z_milli: i64, t_milli: i64) -> i64 {
120 if z_milli <= 0 { return 0 }
121 let delta: i64 = t_ref_milli - t_milli
122 let exp_milli: i64 = delta * 1000 / z_milli
123 if exp_milli >= 0 {
124 let f: i64 = ipow10_q3(exp_milli)
125 return d_ref_ms * f / 1000
126 }
127 let g: i64 = ipow10_q3(0 - exp_milli)
128 if g <= 0 { return 0 }
129 return d_ref_ms * 1000 / g
130}
131
132// Log reductions delivered by a hold, x1000.
133func tp_log_reductions_q3(hold_ms: i64, d_ms: i64) -> i64 {
134 if d_ms <= 0 { return 0 }
135 return hold_ms * 1000 / d_ms
136}
137
138// Equivalent time at a reference temperature: F = t * 10^((T - T_ref)/z).
139func tp_equiv_time_at_ref_ms(hold_ms: i64, temp_milli: i64, t_ref_milli: i64, z_milli: i64) -> i64 {
140 if z_milli <= 0 { return 0 }
141 let delta: i64 = temp_milli - t_ref_milli
142 let exp_milli: i64 = delta * 1000 / z_milli
143 if exp_milli >= 0 {
144 let f: i64 = ipow10_q3(exp_milli)
145 return hold_ms * f / 1000
146 }
147 let g: i64 = ipow10_q3(0 - exp_milli)
148 if g <= 0 { return 0 }
149 return hold_ms * 1000 / g
150}
151
152// F0: equivalent minutes at 121.1 C with z = 10 C. The standard sterilisation
153// yardstick, in ms.
154func tp_f0_ms(hold_ms: i64, temp_milli: i64) -> i64 {
155 return tp_equiv_time_at_ref_ms(hold_ms, temp_milli, TP_REF_STERIL_MILLI_C, TP_BOT_Z_MILLI)
156}
157
158// ===== The botulinum cook =============================================
159
160func tp_bot_12d_ms() -> i64 {
161 return TP_BOT_TARGET_LOGS * TP_BOT_D_REF_MS
162}
163
164// Adequate iff the delivered F0 meets the 12D minimum. The nx_pow10 chord
165// can over-state a POSITIVE exponent by up to 0.7%; the industry F0 target
166// of 3.0 min carries ~19% margin over the 2.52 min 12D floor, so that
167// numerical bias cannot reach the verdict. Stated so it can be checked.
168//
169// ⚠This margin note read 0.3% until 2026-07-25, quoting nx_pow10's own
170// header, which had never been measured because that file had no gate. The
171// measured figure is 0.664% (nx_pow10_test T5). Re-checked at the corrected
172// value: 0.7% against ~19% margin, so the adequacy verdict is unchanged.
173// The lesson is not the number -- it is that a bound inherited from an
174// ungated dependency had been carried into a food-safety conclusion.
175func tp_bot_cook_adequate(f0_ms: i64) -> i64 {
176 let need: i64 = tp_bot_12d_ms()
177 if f0_ms >= need { return 1 }
178 return 0
179}
180
181// ===== z recovered from the law =======================================
182//
183// Two schedules the regulator declares equivalent determine z uniquely:
184// t1 * 10^((T1-Tr)/z) = t2 * 10^((T2-Tr)/z) => z = (T2-T1)/log10(t1/t2)
185// REFUSES (0) unless schedule 1 is the longer, cooler leg -- otherwise the
186// logarithm is zero or negative and the result would be nonsense.
187func tp_implied_z_milli(t1_ms: i64, temp1_milli: i64, t2_ms: i64, temp2_milli: i64) -> i64 {
188 if t2_ms <= 0 { return 0 }
189 if t1_ms <= t2_ms { return 0 }
190 if temp2_milli <= temp1_milli { return 0 }
191 let ratio_q3: i64 = t1_ms * 1000 / t2_ms
192 let log_ratio: i64 = ilog10_milli(ratio_q3)
193 if log_ratio <= 0 { return 0 }
194 let dt: i64 = temp2_milli - temp1_milli
195 return dt * 1000 / log_ratio
196}
197
198// The pasteurisation z-value this file uses, recovered from the PMO pair.
199func tp_pasteurization_z_milli() -> i64 {
200 return tp_implied_z_milli(TP_MILK_VAT_TIME_MS, TP_MILK_VAT_TEMP_MILLI, TP_MILK_HTST_TIME_MS, TP_MILK_HTST_TEMP_MILLI)
201}
202
203// ===== Pasteurization compliance ======================================
204//
205// Kinetic, not a lookup: any time-temperature pair is legal if it delivers
206// at least the lethality of the applicable HTST leg. That admits the whole
207// continuum of valid processes (including the flash schedules) instead of
208// only the four tabulated points.
209
210func tp_htst_sched_for(highfat_or_sweetened: i64) -> i64 {
211 if highfat_or_sweetened == 1 { return TP_SCHED_MIX_HTST }
212 return TP_SCHED_MILK_HTST
213}
214
215func tp_pasteurization_equiv_ms(temp_milli: i64, hold_ms: i64, highfat_or_sweetened: i64) -> i64 {
216 let sched: i64 = tp_htst_sched_for(highfat_or_sweetened)
217 let ref_t: i64 = tp_sched_temp_milli(sched)
218 let z: i64 = tp_pasteurization_z_milli()
219 if z <= 0 { return 0 }
220 return tp_equiv_time_at_ref_ms(hold_ms, temp_milli, ref_t, z)
221}
222
223func tp_pasteurization_compliant(temp_milli: i64, hold_ms: i64, highfat_or_sweetened: i64) -> i64 {
224 if hold_ms <= 0 { return 0 }
225 let sched: i64 = tp_htst_sched_for(highfat_or_sweetened)
226 let need: i64 = tp_sched_time_ms(sched)
227 let got: i64 = tp_pasteurization_equiv_ms(temp_milli, hold_ms, highfat_or_sweetened)
228 if got >= need { return 1 }
229 return 0
230}
231
232// Ice cream mix is high-fat AND sweetened, so it always takes the bumped
233// schedule. Named so a caller cannot forget which flag applies.
234func tp_ice_cream_mix_compliant(temp_milli: i64, hold_ms: i64) -> i64 {
235 return tp_pasteurization_compliant(temp_milli, hold_ms, 1)
236}
237
238// ===== The barrier decision ===========================================
239
240func tp_barrier_required(ph_milli: i64, ambient_stable: i64) -> i64 {
241 let acid: i64 = cs_is_high_acid(ph_milli)
242 if acid == 1 { return TP_BARRIER_ACID }
243 if ambient_stable == 1 { return TP_BARRIER_BOT_COOK }
244 return TP_BARRIER_REFRIGERATION
245}
246
247// Does this process actually satisfy the barrier the food requires?
248// Fail-closed: an unrecognised barrier returns 0.
249func tp_process_adequate(ph_milli: i64, ambient_stable: i64, f0_ms: i64, pasteurized: i64) -> i64 {
250 let barrier: i64 = tp_barrier_required(ph_milli, ambient_stable)
251 if barrier == TP_BARRIER_BOT_COOK { return tp_bot_cook_adequate(f0_ms) }
252 if barrier == TP_BARRIER_ACID {
253 if pasteurized == 1 { return 1 }
254 return 0
255 }
256 if barrier == TP_BARRIER_REFRIGERATION {
257 if pasteurized == 1 { return 1 }
258 return 0
259 }
260 return 0
261}