dsfb_semiconductor/process_context.rs
1//! Recipe-step and tool-state context for admissibility gating.
2//!
3//! # Step-Indexed Admissibility
4//! The DSFB admissibility envelope radius ρ is not a global constant.
5//! Industry practice demands that tolerance bands vary with the process
6//! recipe step: a ±5 % gas-flow deviation is expected during a
7//! "Gas Stabilise" ramp, but the same deviation during "Main Etch"
8//! constitutes an out-of-control condition.
9//!
10//! This module encodes that domain knowledge as a look-up table (LUT)
11//! keyed on [`RecipeStep`]. The LUT multiplier is applied to the
12//! feature-level ρ values before the grammar layer evaluates admissibility.
13//!
14//! # Maintenance Hysteresis
15//! Upon receipt of a [`ToolState::ChamberClean`] signal the engine
16//! executes a "Warm Reset": accumulated grammar state is cleared and
17//! a configurable guard window suppresses new alarms for the first
18//! `post_clean_guard_runs` runs after clean completion. This prevents
19//! false escalations during the seasoning period that follows every
20//! chamber clean.
21//!
22//! # No-std Compatibility
23//! This module is `no_std`-compatible with `alloc`.
24
25use serde::{Deserialize, Serialize};
26#[cfg(not(feature = "std"))]
27use alloc::{format, string::{String, ToString}};
28
29// ─── Recipe Step ──────────────────────────────────────────────────────────────
30
31/// Canonical set of recipe steps recognised by the DSFB engine.
32///
33/// Fabs that use non-standard step names should map their internal identifiers
34/// to the closest canonical variant via [`RecipeStep::from_str`].
35#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
36#[non_exhaustive]
37pub enum RecipeStep {
38 /// Pre-etch gas stabilisation — MFC setpoints ramp; relaxed tolerances
39 /// apply because transient overshoots are physically expected.
40 GasStabilize,
41 /// Main active etch — tight tolerances; this is the yield-critical window.
42 MainEtch,
43 /// Deposition step — moderate tolerances.
44 Deposition,
45 /// Post-etch over-etch — slightly relaxed tolerances.
46 OverEtch,
47 /// Chamber conditioning after maintenance — widest tolerances.
48 Seasoning,
49 /// Any step not explicitly classified; baseline tolerances apply.
50 Other(String),
51}
52
53impl Default for RecipeStep {
54 fn default() -> Self {
55 Self::Other("unknown".into())
56 }
57}
58
59impl RecipeStep {
60 /// Parse a step name string into the canonical variant. Matching is
61 /// case-insensitive. Unknown names produce [`RecipeStep::Other`].
62 #[allow(clippy::should_implement_trait)]
63 pub fn from_str(s: &str) -> Self {
64 match s.to_ascii_lowercase().as_str() {
65 "gas_stabilize" | "gas stabilize" | "gasstabilize" | "stabilize" => {
66 Self::GasStabilize
67 }
68 "main_etch" | "main etch" | "mainetch" | "etch" => Self::MainEtch,
69 "deposition" | "dep" | "cvd" | "pvd" | "ald" => Self::Deposition,
70 "over_etch" | "over etch" | "overetch" => Self::OverEtch,
71 "seasoning" | "season" | "conditioning" => Self::Seasoning,
72 other => Self::Other(other.to_string()),
73 }
74 }
75
76 /// Short display name for reporting / traceability manifests.
77 pub fn display_name(&self) -> &str {
78 match self {
79 Self::GasStabilize => "GasStabilize",
80 Self::MainEtch => "MainEtch",
81 Self::Deposition => "Deposition",
82 Self::OverEtch => "OverEtch",
83 Self::Seasoning => "Seasoning",
84 Self::Other(s) => s.as_str(),
85 }
86 }
87}
88
89// ─── Tool State ───────────────────────────────────────────────────────────────
90
91/// Observable tool-level state that may suppress or modulate the grammar engine.
92///
93/// The tool-state is independent of the recipe step: a chamber clean can be
94/// initiated between any two wafer runs.
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
96#[non_exhaustive]
97pub enum ToolState {
98 /// Normal production run — grammar operates at full sensitivity.
99 #[default]
100 Production,
101 /// Chamber clean cycle in progress.
102 ///
103 /// **Effect:** the grammar engine issues a Warm Reset; all deviations
104 /// during this state are suppressed because the process is intentionally
105 /// out-of-spec.
106 ChamberClean,
107 /// Post-clean seasoning cycle — grammar operates at reduced sensitivity
108 /// because thin-film conditioning transients are expected.
109 Seasoning,
110 /// Tool is idle or in a maintenance hold — monitoring is paused.
111 Maintenance,
112}
113
114// ─── Process Context ──────────────────────────────────────────────────────────
115
116/// Full operational context for a single process run, passed to the DSFB
117/// engine so that admissibility gating is recipe-step-aware.
118///
119/// # Example
120/// ```
121/// use dsfb_semiconductor::process_context::{ProcessContext, RecipeStep, ToolState};
122///
123/// let ctx = ProcessContext {
124/// recipe_step_id: "STEP_002_MAIN_ETCH".into(),
125/// recipe_step: RecipeStep::MainEtch,
126/// tool_state: ToolState::Production,
127/// lot_id: Some("LOT-2026-0401".into()),
128/// chamber_id: Some("CH-A".into()),
129/// };
130///
131/// // Main-etch tightens the envelope by 20 %.
132/// assert!((ctx.admissibility_multiplier() - 0.80).abs() < 1e-9);
133/// ```
134#[derive(Debug, Clone, Serialize, Deserialize, Default)]
135pub struct ProcessContext {
136 /// Fab-internal identifier string for the active recipe step.
137 pub recipe_step_id: String,
138 /// Canonical DSFB classification of the recipe step.
139 pub recipe_step: RecipeStep,
140 /// Observable tool-level state.
141 pub tool_state: ToolState,
142 /// Wafer lot / batch identifier (informational; included in traceability).
143 pub lot_id: Option<String>,
144 /// Chamber identifier (informational; included in traceability).
145 pub chamber_id: Option<String>,
146}
147
148impl ProcessContext {
149 /// Admissibility envelope multiplier for this context.
150 ///
151 /// The LUT below encodes industry-standard domain knowledge:
152 ///
153 /// | Recipe step | Multiplier | Rationale |
154 /// |----------------|-----------|---------------------------------------- |
155 /// | `GasStabilize` | 1.50 × | Ramp transients are physically expected |
156 /// | `MainEtch` | 0.80 × | Yield-critical; tightest control window |
157 /// | `Deposition` | 1.10 × | Moderate tolerance |
158 /// | `OverEtch` | 1.20 × | Controlled endpoint; slightly relaxed |
159 /// | `Seasoning` | 2.00 × | Post-clean transients expected |
160 /// | `Other` | 1.00 × | Baseline |
161 ///
162 /// During a `ChamberClean` tool state, deviations are fully suppressed
163 /// (`f64::INFINITY` — no finite residual can exceed an infinite envelope).
164 #[must_use]
165 pub fn admissibility_multiplier(&self) -> f64 {
166 if self.tool_state == ToolState::ChamberClean {
167 return f64::INFINITY; // suppress all deviations; chamber is intentionally dirty
168 }
169 match &self.recipe_step {
170 RecipeStep::GasStabilize => 1.50,
171 RecipeStep::MainEtch => 0.80,
172 RecipeStep::Deposition => 1.10,
173 RecipeStep::OverEtch => 1.20,
174 RecipeStep::Seasoning => 2.00,
175 RecipeStep::Other(_) => 1.00,
176 }
177 }
178
179 /// Returns `true` when the grammar engine must issue a Warm Reset.
180 ///
181 /// A Warm Reset clears accumulated grammar state to prevent false alarms
182 /// after a chamber clean or during a seasoning cycle.
183 #[must_use]
184 pub fn requires_warm_reset(&self) -> bool {
185 matches!(
186 self.tool_state,
187 ToolState::ChamberClean | ToolState::Seasoning | ToolState::Maintenance
188 )
189 }
190
191 /// A terse string representation for traceability manifests.
192 pub fn traceability_tag(&self) -> String {
193 format!(
194 "step={} tool_state={:?} lot={} chamber={}",
195 self.recipe_step.display_name(),
196 self.tool_state,
197 self.lot_id.as_deref().unwrap_or("none"),
198 self.chamber_id.as_deref().unwrap_or("none"),
199 )
200 }
201}
202
203// ─── Maintenance Hysteresis ───────────────────────────────────────────────────
204
205/// Tracks the hysteresis boundary around chamber-clean and seasoning events.
206///
207/// When a [`ToolState::ChamberClean`] signal is received, the engine
208/// performs a "Warm Reset": the accumulated grammar state is cleared so that
209/// post-clean transients during seasoning do not trigger false alarms.
210/// A configurable guard window (`post_clean_guard_runs`) suppresses new alarms
211/// for the first N wafer runs after clean completion.
212///
213/// # Integration Pattern
214/// Call [`MaintenanceHysteresis::update`] at the start of every run with the
215/// current [`ProcessContext`]. If it returns `true`, flush the grammar
216/// accumulator for all features before processing the current run.
217///
218/// # Example
219/// ```
220/// use dsfb_semiconductor::process_context::{
221/// MaintenanceHysteresis, ProcessContext, ToolState,
222/// };
223///
224/// let mut hyst = MaintenanceHysteresis::new(10);
225/// let mut ctx = ProcessContext::default();
226///
227/// ctx.tool_state = ToolState::ChamberClean;
228/// assert!(hyst.update(&ctx), "clean signal must trigger warm reset");
229/// assert!(hyst.is_suppressed(), "guard window should be active");
230///
231/// ctx.tool_state = ToolState::Production;
232/// for _ in 0..10 {
233/// hyst.update(&ctx);
234/// }
235/// assert!(!hyst.is_suppressed(), "guard window should have elapsed");
236/// ```
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct MaintenanceHysteresis {
239 /// Number of production runs after a clean event during which alarms are
240 /// suppressed to allow the chamber seasoning transient to decay.
241 pub post_clean_guard_runs: usize,
242
243 reset_pending: bool,
244 runs_since_reset: usize,
245}
246
247impl Default for MaintenanceHysteresis {
248 fn default() -> Self {
249 Self::new(10)
250 }
251}
252
253impl MaintenanceHysteresis {
254 /// Create a new tracker with the specified guard window.
255 ///
256 /// Setting `post_clean_guard_runs = 0` disables the guard window.
257 pub fn new(post_clean_guard_runs: usize) -> Self {
258 Self {
259 post_clean_guard_runs,
260 reset_pending: false,
261 runs_since_reset: usize::MAX,
262 }
263 }
264
265 /// Update the tracker with the current process context.
266 ///
267 /// Returns `true` if the grammar engine must perform a Warm Reset
268 /// (i.e., the current context indicates a clean/maintenance event).
269 pub fn update(&mut self, ctx: &ProcessContext) -> bool {
270 if ctx.requires_warm_reset() {
271 self.reset_pending = true;
272 self.runs_since_reset = 0;
273 return true;
274 }
275
276 if self.reset_pending {
277 self.runs_since_reset = self.runs_since_reset.saturating_add(1);
278 if self.runs_since_reset >= self.post_clean_guard_runs {
279 self.reset_pending = false;
280 self.runs_since_reset = usize::MAX;
281 }
282 }
283
284 false
285 }
286
287 /// Returns `true` while the engine is inside the post-clean suppression
288 /// window (i.e., alarms should be downgraded to `Watch` or suppressed).
289 #[must_use]
290 pub fn is_suppressed(&self) -> bool {
291 self.reset_pending
292 }
293
294 /// Number of production runs elapsed since the last Warm Reset.
295 /// Returns `usize::MAX` when no reset has been triggered.
296 #[must_use]
297 pub fn runs_since_last_reset(&self) -> usize {
298 self.runs_since_reset
299 }
300}
301
302// ─── Unit tests ───────────────────────────────────────────────────────────────
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 #[test]
309 fn main_etch_tightens_envelope() {
310 let ctx = ProcessContext {
311 recipe_step: RecipeStep::MainEtch,
312 tool_state: ToolState::Production,
313 ..Default::default()
314 };
315 assert!(
316 (ctx.admissibility_multiplier() - 0.80).abs() < 1e-9,
317 "MainEtch should yield 0.80× multiplier"
318 );
319 }
320
321 #[test]
322 fn gas_stabilize_relaxes_envelope() {
323 let ctx = ProcessContext {
324 recipe_step: RecipeStep::GasStabilize,
325 tool_state: ToolState::Production,
326 ..Default::default()
327 };
328 assert!(
329 (ctx.admissibility_multiplier() - 1.50).abs() < 1e-9,
330 "GasStabilize should yield 1.50× multiplier"
331 );
332 }
333
334 #[test]
335 fn chamber_clean_suppresses_everything() {
336 let ctx = ProcessContext {
337 recipe_step: RecipeStep::MainEtch,
338 tool_state: ToolState::ChamberClean,
339 ..Default::default()
340 };
341 assert!(
342 ctx.admissibility_multiplier().is_infinite(),
343 "ChamberClean should yield MAX multiplier (full suppression)"
344 );
345 }
346
347 #[test]
348 fn chamber_clean_requires_warm_reset() {
349 let ctx = ProcessContext {
350 tool_state: ToolState::ChamberClean,
351 ..Default::default()
352 };
353 assert!(ctx.requires_warm_reset());
354 }
355
356 #[test]
357 fn production_does_not_require_warm_reset() {
358 let ctx = ProcessContext {
359 tool_state: ToolState::Production,
360 ..Default::default()
361 };
362 assert!(!ctx.requires_warm_reset());
363 }
364
365 #[test]
366 fn hysteresis_guard_window_elapses() {
367 let mut hyst = MaintenanceHysteresis::new(3);
368 let mut clean = ProcessContext::default();
369 clean.tool_state = ToolState::ChamberClean;
370
371 assert!(hyst.update(&clean));
372 assert!(hyst.is_suppressed());
373
374 let mut prod = ProcessContext::default();
375 prod.tool_state = ToolState::Production;
376
377 hyst.update(&prod);
378 assert!(hyst.is_suppressed());
379 hyst.update(&prod);
380 assert!(hyst.is_suppressed());
381 hyst.update(&prod);
382 assert!(!hyst.is_suppressed(), "guard window should have expired after 3 runs");
383 }
384
385 #[test]
386 fn recipe_step_from_str_round_trips() {
387 assert_eq!(RecipeStep::from_str("main_etch"), RecipeStep::MainEtch);
388 assert_eq!(RecipeStep::from_str("GAS STABILIZE"), RecipeStep::GasStabilize);
389 assert_eq!(RecipeStep::from_str("seasoning"), RecipeStep::Seasoning);
390 assert!(matches!(
391 RecipeStep::from_str("plasma_clean"),
392 RecipeStep::Other(_)
393 ));
394 }
395}