nx_ferment_process.nx source
↩ module page · 191 lines · 7850 B
1// nx_ferment_process.nx -- R2 process rung: the ferment state machine.
2//
3// A fermentation recipe is a SEQUENCE OF PHASES, each with a target
4// temperature (for the R1 thermal controller) and an exit condition
5// (temperature reached, pH reached, or time elapsed). This is the
6// yogurt-maker / cheesemaker LOGIC: pasteurize -> cool -> inoculate ->
7// ferment-hold -> chill (yogurt); ripen -> rennet -> cut -> cook ->
8// drain -> press -> age (cheese). One generic executor, data-driven
9// per Rule 6/11 -- the phase table is data, not code.
10//
11// SAFETY COMPOSITION: every phase flagged is_ferment passes through the
12// R0 never-poison law (nx_ferment_validate) BEFORE its exit is checked.
13// Any refusal HALTS the whole process (status = HALTED_UNSAFE) and
14// records the verdict. The never-poison law thus propagates up the
15// ladder -- an unsafe batch can never run to "done".
16//
17// Non-ferment phases (e.g. the 82 C pasteurization, which is above
18// culture-kill) are NOT validated by the ferment law -- no culture is
19// present yet, so culture-kill does not apply. That separation is why
20// nx_ferment_validate documents itself as the ferment-HOLD predicate.
21//
22// genealogy_id: nishi_ferment_safety_envelope_2026 (via nx_ferment_safety)
23// + state_machine_recipe_executor_pattern
24
25import "nx_syscalls.nx"
26import "nx_ferment_safety.nx"
27const NX_MAGIC_82000: i64 = 82000
28const NX_MAGIC_43000: i64 = 43000
29const NX_MAGIC_4500: i64 = 4500
30const NX_MAGIC_5000: i64 = 5000
31
32// ===== Sealed enum: phase kind ===================================
33
34const NX_FP_PASTEURIZE: nx_int = 0
35const NX_FP_COOL: nx_int = 1
36const NX_FP_INOCULATE: nx_int = 2
37const NX_FP_FERMENT: nx_int = 3
38const NX_FP_CHILL: nx_int = 4
39const NX_FP_PRESS: nx_int = 5
40const NX_FP_AGE: nx_int = 6
41
42// ===== Sealed enum: exit-condition kind ==========================
43
44const NX_FX_TEMP_AT_OR_ABOVE: nx_int = 0 // temp >= threshold (heating done)
45const NX_FX_TEMP_AT_OR_BELOW: nx_int = 1 // temp <= threshold (cooling done)
46const NX_FX_PH_AT_OR_BELOW: nx_int = 2 // pH <= threshold (acidified)
47const NX_FX_TIME_ELAPSED: nx_int = 3 // elapsed >= threshold
48
49// ===== Sealed enum: process status ===============================
50
51const NX_PR_RUNNING: nx_int = 0
52const NX_PR_COMPLETE: nx_int = 1
53const NX_PR_HALTED_UNSAFE: nx_int = 2
54
55// ===== Structs ===================================================
56
57struct NxFermentPhase {
58 kind: nx_int,
59 target_milli_c: i64,
60 exit_kind: nx_int,
61 exit_threshold: i64,
62 is_ferment: nx_int, // 1 = run the R0 never-poison law each step
63 oxygen: nx_int, // NX_OX_* for the ferment law
64}
65
66const NX_FP_PHASE_BYTES: nx_size = 48
67
68struct NxFermentProcess {
69 phases: *NxFermentPhase,
70 n_phases: nx_int,
71 current: nx_int,
72 env: *NxFermentSafetyEnvelope,
73 status: nx_int,
74 last_verdict: nx_int,
75 phase_start_hours: i64,
76 ferment_kind: nx_int,
77}
78
79func nx_ferment_phases_new(n: nx_size) -> *NxFermentPhase {
80 let bytes: nx_size = n * NX_FP_PHASE_BYTES
81 return (sys_mmap(bytes)) as *NxFermentPhase
82}
83
84func _fp_phase_at(phases: *NxFermentPhase, idx: nx_size) -> *NxFermentPhase {
85 return (phases as i64 + (idx as i64) * NX_FP_PHASE_BYTES) as *NxFermentPhase
86}
87
88func nx_ferment_phase_set(phases: *NxFermentPhase, idx: nx_size,
89 kind: nx_int, target_milli_c: i64,
90 exit_kind: nx_int, exit_threshold: i64,
91 is_ferment: nx_int, oxygen: nx_int) -> i64 {
92 let p: *NxFermentPhase = _fp_phase_at(phases, idx)
93 p.kind = kind
94 p.target_milli_c = target_milli_c
95 p.exit_kind = exit_kind
96 p.exit_threshold = exit_threshold
97 p.is_ferment = is_ferment
98 p.oxygen = oxygen
99 return 0
100}
101
102func nx_ferment_process_new(env: *NxFermentSafetyEnvelope,
103 phases: *NxFermentPhase, n_phases: nx_int,
104 ferment_kind: nx_int) -> *NxFermentProcess {
105 let pr: *NxFermentProcess = (sys_mmap(64)) as *NxFermentProcess
106 pr.phases = phases
107 pr.n_phases = n_phases
108 pr.current = 0
109 pr.env = env
110 pr.status = NX_PR_RUNNING
111 pr.last_verdict = NX_FS_OK
112 pr.phase_start_hours = 0
113 pr.ferment_kind = ferment_kind
114 return pr
115}
116
117// The current phase's setpoint -- what the R1 thermal controller targets.
118func nx_ferment_process_target(pr: *NxFermentProcess) -> i64 {
119 if pr.current >= pr.n_phases { return 0 }
120 let ph: *NxFermentPhase = _fp_phase_at(pr.phases, pr.current as nx_size)
121 return ph.target_milli_c
122}
123
124// Is the current phase a safety-validated ferment hold?
125func nx_ferment_process_current_is_ferment(pr: *NxFermentProcess) -> nx_int {
126 if pr.current >= pr.n_phases { return 0 }
127 let ph: *NxFermentPhase = _fp_phase_at(pr.phases, pr.current as nx_size)
128 return ph.is_ferment
129}
130
131// One process step. On a ferment phase it runs the R0 never-poison law
132// and HALTS on any refusal; otherwise it checks the current phase's exit
133// condition and advances. Returns the process status.
134func nx_ferment_process_step(pr: *NxFermentProcess,
135 temp_milli_c: i64, ph_milli: i64,
136 salt_pct_milli: i64, age_days: i64,
137 now_hours: i64,
138 log: *NxFermentReadingLog) -> nx_int {
139 if pr.status != NX_PR_RUNNING { return pr.status }
140 if pr.current >= pr.n_phases { pr.status = NX_PR_COMPLETE; return pr.status }
141
142 let cur: *NxFermentPhase = _fp_phase_at(pr.phases, pr.current as nx_size)
143
144 // Never-poison gate on ferment-hold phases.
145 if cur.is_ferment == 1 {
146 let elapsed: i64 = now_hours - pr.phase_start_hours
147 let verdict: nx_int = nx_ferment_validate(pr.env, pr.ferment_kind,
148 cur.oxygen, temp_milli_c as nx_size, ph_milli as nx_size,
149 salt_pct_milli as nx_size, elapsed as nx_size,
150 age_days as nx_size, log, now_hours as nx_size)
151 pr.last_verdict = verdict
152 if verdict != NX_FS_OK {
153 pr.status = NX_PR_HALTED_UNSAFE
154 return pr.status
155 }
156 }
157
158 // Exit-condition check.
159 var advance: nx_int = 0
160 if cur.exit_kind == NX_FX_TEMP_AT_OR_ABOVE {
161 if temp_milli_c >= cur.exit_threshold { advance = 1 }
162 }
163 if cur.exit_kind == NX_FX_TEMP_AT_OR_BELOW {
164 if temp_milli_c <= cur.exit_threshold { advance = 1 }
165 }
166 if cur.exit_kind == NX_FX_PH_AT_OR_BELOW {
167 if ph_milli <= cur.exit_threshold { advance = 1 }
168 }
169 if cur.exit_kind == NX_FX_TIME_ELAPSED {
170 let el: i64 = now_hours - pr.phase_start_hours
171 if el >= cur.exit_threshold { advance = 1 }
172 }
173
174 if advance == 1 {
175 pr.current = pr.current + 1
176 pr.phase_start_hours = now_hours
177 if pr.current >= pr.n_phases { pr.status = NX_PR_COMPLETE }
178 }
179 return pr.status
180}
181
182// Build the canonical yogurt phase table into a 5-phase buffer.
183// 0 PASTEURIZE 82 C 1 COOL 43 C 2 INOCULATE 3 FERMENT->pH4.5 4 CHILL 5 C
184func nx_ferment_build_yogurt(phases: *NxFermentPhase) -> i64 {
185 nx_ferment_phase_set(phases, 0, NX_FP_PASTEURIZE, NX_MAGIC_82000, NX_FX_TEMP_AT_OR_ABOVE, NX_MAGIC_82000, 0, NX_OX_AEROBIC)
186 nx_ferment_phase_set(phases, 1, NX_FP_COOL, NX_MAGIC_43000, NX_FX_TEMP_AT_OR_BELOW, NX_MAGIC_43000, 0, NX_OX_AEROBIC)
187 nx_ferment_phase_set(phases, 2, NX_FP_INOCULATE, NX_MAGIC_43000, NX_FX_TIME_ELAPSED, 0, 0, NX_OX_AEROBIC)
188 nx_ferment_phase_set(phases, 3, NX_FP_FERMENT, NX_MAGIC_43000, NX_FX_PH_AT_OR_BELOW, NX_MAGIC_4500, 1, NX_OX_ANAEROBIC)
189 nx_ferment_phase_set(phases, 4, NX_FP_CHILL, NX_MAGIC_5000, NX_FX_TEMP_AT_OR_BELOW, NX_MAGIC_5000, 0, NX_OX_AEROBIC)
190 return 0
191}