Skip to main content

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}