Skip to main content

dsfb_semiconductor/
non_intrusive.rs

1use crate::error::{DsfbSemiconductorError, Result};
2use plotters::prelude::*;
3use serde::Serialize;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7const ARCHITECTURE_WIDTH: u32 = 1600;
8const ARCHITECTURE_HEIGHT: u32 = 900;
9
10pub const DSFB_NON_INTRUSIVE_ARCHITECTURE_PNG: &str = "dsfb_non_intrusive_architecture.png";
11pub const DSFB_NON_INTRUSIVE_ARCHITECTURE_SVG: &str = "dsfb_non_intrusive_architecture.svg";
12pub const NON_INTRUSIVE_INTERFACE_SPEC: &str = "non_intrusive_interface_spec.md";
13pub const DSFB_LAYER_ORDER: [&str; 6] = [
14    "Residual",
15    "Sign",
16    "Syntax",
17    "Grammar",
18    "Semantics",
19    "Policy",
20];
21
22#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
23pub enum DsfbAdvisoryState {
24    Silent,
25    Watch,
26    Review,
27    Escalate,
28}
29
30impl DsfbAdvisoryState {
31    pub fn as_lowercase(self) -> &'static str {
32        match self {
33            Self::Silent => "silent",
34            Self::Watch => "watch",
35            Self::Review => "review",
36            Self::Escalate => "escalate",
37        }
38    }
39}
40
41#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
42pub struct UpstreamAlarmSnapshot {
43    pub ewma_alarm: bool,
44    pub spc_alarm: bool,
45    pub threshold_alarm: bool,
46}
47
48#[derive(Debug, Clone, Serialize, PartialEq)]
49pub struct DsfbObserverInput {
50    pub run_index: usize,
51    pub timestamp: String,
52    pub residuals: Vec<f64>,
53    pub upstream_alarms: UpstreamAlarmSnapshot,
54    pub metadata_pairs: Vec<(String, String)>,
55}
56
57#[derive(Debug, Clone, Serialize, PartialEq)]
58pub struct DsfbAdvisoryOutput {
59    pub run_index: usize,
60    pub timestamp: String,
61    pub advisory_state: DsfbAdvisoryState,
62    pub layer_order: Vec<String>,
63    pub advisory_labels: Vec<String>,
64    pub advisory_note: String,
65    pub fail_safe_isolation_note: String,
66}
67
68pub trait NonIntrusiveDsfbObserver {
69    fn observe(&self, observations: &[DsfbObserverInput]) -> Vec<DsfbAdvisoryOutput>;
70
71    fn integration_mode(&self) -> &'static str {
72        "read_only_side_channel"
73    }
74}
75
76#[derive(Debug, Default, Clone, Copy)]
77pub struct DeterministicReplayObserver;
78
79impl NonIntrusiveDsfbObserver for DeterministicReplayObserver {
80    fn observe(&self, observations: &[DsfbObserverInput]) -> Vec<DsfbAdvisoryOutput> {
81        observations
82            .iter()
83            .map(|observation| {
84                let residual_energy = observation
85                    .residuals
86                    .iter()
87                    .map(|value| value * value)
88                    .sum::<f64>();
89                let alarm_count = observation.upstream_alarms.ewma_alarm as usize
90                    + observation.upstream_alarms.spc_alarm as usize
91                    + observation.upstream_alarms.threshold_alarm as usize;
92                let advisory_state = if alarm_count >= 2 || residual_energy >= 9.0 {
93                    DsfbAdvisoryState::Escalate
94                } else if alarm_count == 1 || residual_energy >= 4.0 {
95                    DsfbAdvisoryState::Review
96                } else if residual_energy > 0.0 {
97                    DsfbAdvisoryState::Watch
98                } else {
99                    DsfbAdvisoryState::Silent
100                };
101                let advisory_labels = if advisory_state == DsfbAdvisoryState::Silent {
102                    vec!["admissible_residual_context".into()]
103                } else {
104                    vec![
105                        format!("upstream_alarm_count={alarm_count}"),
106                        format!("residual_energy={residual_energy:.3}"),
107                    ]
108                };
109                DsfbAdvisoryOutput {
110                    run_index: observation.run_index,
111                    timestamp: observation.timestamp.clone(),
112                    advisory_state,
113                    layer_order: DSFB_LAYER_ORDER
114                        .iter()
115                        .map(|layer| (*layer).to_string())
116                        .collect(),
117                    advisory_labels,
118                    advisory_note:
119                        "advisory-only interpretation derived from immutable residual observations"
120                            .into(),
121                    fail_safe_isolation_note:
122                        "observer failure cannot alter upstream monitoring behavior".into(),
123                }
124            })
125            .collect()
126    }
127}
128
129#[derive(Debug, Clone, Serialize)]
130pub struct NonIntrusiveArtifacts {
131    pub architecture_png_path: PathBuf,
132    pub architecture_svg_path: PathBuf,
133    pub interface_spec_path: PathBuf,
134    pub layer_order: Vec<String>,
135    pub integration_mode: String,
136}
137
138pub fn materialize_non_intrusive_artifacts(run_dir: &Path) -> Result<NonIntrusiveArtifacts> {
139    let figure_dir = run_dir.join("figures");
140    fs::create_dir_all(&figure_dir)?;
141    let architecture_png_path = figure_dir.join(DSFB_NON_INTRUSIVE_ARCHITECTURE_PNG);
142    let architecture_svg_path = figure_dir.join(DSFB_NON_INTRUSIVE_ARCHITECTURE_SVG);
143    let interface_spec_path = run_dir.join(NON_INTRUSIVE_INTERFACE_SPEC);
144
145    draw_non_intrusive_architecture_png(&architecture_png_path)?;
146    write_non_intrusive_architecture_svg(&architecture_svg_path)?;
147    fs::write(
148        &interface_spec_path,
149        non_intrusive_interface_spec_markdown(),
150    )?;
151
152    Ok(NonIntrusiveArtifacts {
153        architecture_png_path,
154        architecture_svg_path,
155        interface_spec_path,
156        layer_order: DSFB_LAYER_ORDER
157            .iter()
158            .map(|layer| (*layer).to_string())
159            .collect(),
160        integration_mode: "read_only_side_channel".into(),
161    })
162}
163
164pub fn non_intrusive_interface_spec_markdown() -> String {
165    let layer_order = DSFB_LAYER_ORDER.join(" -> ");
166    format!(
167        "# Non-Intrusive DSFB Interface Specification\n\n\
168DSFB is a deterministic, non-intrusive, read-only interpretation layer. It does not replace SPC, EWMA, threshold logic, APC, or controller actuation. Its role is to read upstream residuals and alarms, transform them through a fixed structural stack, and emit advisory interpretations only.\n\n\
169## Contract\n\n\
170- Integration mode: `read_only_side_channel`\n\
171- Fixed layer order: `{layer_order}`\n\
172- Inputs are immutable residual observations, upstream alarm snapshots, and optional metadata.\n\
173- Outputs are advisory interpretations only: `Silent`, `Watch`, `Review`, or `Escalate`.\n\
174- No DSFB API writes back into thresholds, controller gains, recipe parameters, or actuation paths.\n\
175- Primary control timing is unchanged because DSFB consumes a side tap of residual/alarm streams.\n\
176- Replay is deterministic: identical ordered inputs must yield identical outputs.\n\
177- Failure is isolated: if DSFB crashes or is disabled, upstream plant behavior is unchanged.\n\n\
178## Input Surface\n\n\
179`DsfbObserverInput` contains:\n\
180- `run_index`\n\
181- `timestamp`\n\
182- `residuals`\n\
183- `upstream_alarms`\n\
184- `metadata_pairs`\n\n\
185## Output Surface\n\n\
186`DsfbAdvisoryOutput` contains:\n\
187- `run_index`\n\
188- `timestamp`\n\
189- `advisory_state`\n\
190- `layer_order`\n\
191- `advisory_labels`\n\
192- `advisory_note`\n\
193- `fail_safe_isolation_note`\n\n\
194## Explicit Non-Claims\n\n\
195- No control command output exists.\n\
196- No threshold-tuning API exists.\n\
197- No recipe-write API exists.\n\
198- No claim of controller replacement is made.\n\
199- No claim of latency benefit is made; the contract is only that DSFB must not add latency to the upstream control loop.\n"
200    )
201}
202
203fn draw_non_intrusive_architecture_png(output_path: &Path) -> Result<()> {
204    let root = BitMapBackend::new(output_path, (ARCHITECTURE_WIDTH, ARCHITECTURE_HEIGHT))
205        .into_drawing_area();
206    root.fill(&WHITE).map_err(plot_error)?;
207
208    let title_font = ("sans-serif", 34).into_font().style(FontStyle::Bold);
209    let body_font = ("sans-serif", 20).into_font();
210    let note_font = ("sans-serif", 18).into_font();
211
212    root.draw(&Text::new(
213        "DSFB Non-Intrusive Side-Channel Architecture",
214        (60, 52),
215        title_font,
216    ))
217    .map_err(plot_error)?;
218    root.draw(&Text::new(
219        "Primary SPC/EWMA/controller path remains authoritative; DSFB observes residuals and alarms only.",
220        (60, 92),
221        body_font.clone(),
222    ))
223    .map_err(plot_error)?;
224
225    draw_box(
226        &root,
227        (90, 210),
228        (350, 330),
229        "Process / Tool",
230        &[
231            "physical process",
232            "sensor stream x(k)",
233            "no DSFB dependency",
234        ],
235        RGBColor(235, 235, 235),
236        BLACK,
237    )?;
238    draw_box(
239        &root,
240        (470, 210),
241        (820, 330),
242        "SPC / EWMA / Controller",
243        &[
244            "primary monitoring",
245            "thresholds, charts, APC",
246            "certified timing unchanged",
247        ],
248        RGBColor(205, 205, 205),
249        BLACK,
250    )?;
251    draw_box(
252        &root,
253        (940, 210),
254        (1260, 330),
255        "Alarm / Actuation Path",
256        &[
257            "alarms and controller output",
258            "upstream authority retained",
259            "no DSFB write-back",
260        ],
261        RGBColor(160, 160, 160),
262        WHITE,
263    )?;
264    draw_box(
265        &root,
266        (470, 500),
267        (870, 700),
268        "DSFB Observer Layer",
269        &[
270            "Residual -> Sign -> Syntax",
271            "Grammar -> Semantics -> Policy",
272            "advisory interpretation only",
273        ],
274        RGBColor(245, 245, 245),
275        BLACK,
276    )?;
277    draw_box(
278        &root,
279        (1020, 520),
280        (1450, 680),
281        "Operator-Facing Advisory Output",
282        &[
283            "Silent / Watch / Review / Escalate",
284            "typed residual interpretation",
285            "fail-safe isolated",
286        ],
287        RGBColor(215, 215, 215),
288        BLACK,
289    )?;
290
291    draw_arrow(&root, (350, 270), (470, 270), false, "x(k)")?;
292    draw_arrow(&root, (820, 270), (940, 270), false, "alarms / actuation")?;
293    draw_arrow(
294        &root,
295        (650, 330),
296        (650, 500),
297        true,
298        "read-only residual tap",
299    )?;
300    draw_arrow(&root, (980, 330), (920, 560), true, "read-only alarm tap")?;
301    draw_arrow(&root, (870, 600), (1020, 600), false, "advisory only")?;
302
303    root.draw(&Text::new(
304        "No arrow returns from DSFB to control. No threshold, recipe, or actuation API exists.",
305        (60, 790),
306        note_font.clone(),
307    ))
308    .map_err(plot_error)?;
309    root.draw(&Text::new(
310        "Deterministic replay: identical ordered inputs yield identical DSFB outputs.",
311        (60, 825),
312        note_font,
313    ))
314    .map_err(plot_error)?;
315
316    root.present().map_err(plot_error)?;
317    Ok(())
318}
319
320fn draw_box(
321    root: &DrawingArea<BitMapBackend<'_>, plotters::coord::Shift>,
322    top_left: (i32, i32),
323    bottom_right: (i32, i32),
324    title: &str,
325    body: &[&str],
326    fill: RGBColor,
327    text: RGBColor,
328) -> Result<()> {
329    root.draw(&Rectangle::new(
330        [top_left, bottom_right],
331        ShapeStyle::from(&fill).filled(),
332    ))
333    .map_err(plot_error)?;
334    root.draw(&Rectangle::new(
335        [top_left, bottom_right],
336        ShapeStyle::from(&BLACK).stroke_width(2),
337    ))
338    .map_err(plot_error)?;
339    root.draw(&Text::new(
340        title.to_string(),
341        (top_left.0 + 18, top_left.1 + 28),
342        ("sans-serif", 24)
343            .into_font()
344            .style(FontStyle::Bold)
345            .color(&text),
346    ))
347    .map_err(plot_error)?;
348    for (index, line) in body.iter().enumerate() {
349        root.draw(&Text::new(
350            (*line).to_string(),
351            (top_left.0 + 18, top_left.1 + 62 + (index as i32 * 28)),
352            ("sans-serif", 18).into_font().color(&text),
353        ))
354        .map_err(plot_error)?;
355    }
356    Ok(())
357}
358
359fn draw_arrow(
360    root: &DrawingArea<BitMapBackend<'_>, plotters::coord::Shift>,
361    start: (i32, i32),
362    end: (i32, i32),
363    dashed: bool,
364    label: &str,
365) -> Result<()> {
366    if dashed {
367        draw_dashed_line(root, start, end, 10)?;
368    } else {
369        root.draw(&PathElement::new(
370            vec![start, end],
371            ShapeStyle::from(&BLACK).stroke_width(3),
372        ))
373        .map_err(plot_error)?;
374    }
375    let arrow_tip = if end.0 >= start.0 {
376        vec![(end.0 - 14, end.1 - 8), end, (end.0 - 14, end.1 + 8)]
377    } else if end.1 >= start.1 {
378        vec![(end.0 - 8, end.1 - 14), end, (end.0 + 8, end.1 - 14)]
379    } else {
380        vec![(end.0 - 8, end.1 + 14), end, (end.0 + 8, end.1 + 14)]
381    };
382    root.draw(&PathElement::new(
383        arrow_tip,
384        ShapeStyle::from(&BLACK).stroke_width(3),
385    ))
386    .map_err(plot_error)?;
387
388    let label_x = (start.0 + end.0) / 2;
389    let label_y = (start.1 + end.1) / 2 - 14;
390    root.draw(&Text::new(
391        label.to_string(),
392        (label_x, label_y),
393        ("sans-serif", 18).into_font(),
394    ))
395    .map_err(plot_error)?;
396    Ok(())
397}
398
399fn draw_dashed_line(
400    root: &DrawingArea<BitMapBackend<'_>, plotters::coord::Shift>,
401    start: (i32, i32),
402    end: (i32, i32),
403    segments: i32,
404) -> Result<()> {
405    for segment in 0..segments {
406        if segment % 2 == 0 {
407            let segment_start = interpolate_point(start, end, segment as f64 / segments as f64);
408            let segment_end = interpolate_point(start, end, (segment + 1) as f64 / segments as f64);
409            root.draw(&PathElement::new(
410                vec![segment_start, segment_end],
411                ShapeStyle::from(&BLACK).stroke_width(3),
412            ))
413            .map_err(plot_error)?;
414        }
415    }
416    Ok(())
417}
418
419fn interpolate_point(start: (i32, i32), end: (i32, i32), t: f64) -> (i32, i32) {
420    (
421        (start.0 as f64 + (end.0 - start.0) as f64 * t).round() as i32,
422        (start.1 as f64 + (end.1 - start.1) as f64 * t).round() as i32,
423    )
424}
425
426fn write_non_intrusive_architecture_svg(output_path: &Path) -> Result<()> {
427    let svg = format!(
428        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">
429<rect width="100%" height="100%" fill="#ffffff"/>
430<text x="60" y="52" font-family="sans-serif" font-size="34" font-weight="700" fill="#000000">DSFB Non-Intrusive Side-Channel Architecture</text>
431<text x="60" y="92" font-family="sans-serif" font-size="20" fill="#000000">Primary SPC/EWMA/controller path remains authoritative; DSFB observes residuals and alarms only.</text>
432<rect x="90" y="210" width="260" height="120" fill="#ebebeb" stroke="#000000" stroke-width="2"/>
433<text x="108" y="238" font-family="sans-serif" font-size="24" font-weight="700">Process / Tool</text>
434<text x="108" y="272" font-family="sans-serif" font-size="18">physical process</text>
435<text x="108" y="300" font-family="sans-serif" font-size="18">sensor stream x(k)</text>
436<text x="108" y="328" font-family="sans-serif" font-size="18">no DSFB dependency</text>
437<rect x="470" y="210" width="350" height="120" fill="#cdcdcd" stroke="#000000" stroke-width="2"/>
438<text x="488" y="238" font-family="sans-serif" font-size="24" font-weight="700">SPC / EWMA / Controller</text>
439<text x="488" y="272" font-family="sans-serif" font-size="18">primary monitoring</text>
440<text x="488" y="300" font-family="sans-serif" font-size="18">thresholds, charts, APC</text>
441<text x="488" y="328" font-family="sans-serif" font-size="18">certified timing unchanged</text>
442<rect x="940" y="210" width="320" height="120" fill="#a0a0a0" stroke="#000000" stroke-width="2"/>
443<text x="958" y="238" font-family="sans-serif" font-size="24" font-weight="700" fill="#ffffff">Alarm / Actuation Path</text>
444<text x="958" y="272" font-family="sans-serif" font-size="18" fill="#ffffff">alarms and controller output</text>
445<text x="958" y="300" font-family="sans-serif" font-size="18" fill="#ffffff">upstream authority retained</text>
446<text x="958" y="328" font-family="sans-serif" font-size="18" fill="#ffffff">no DSFB write-back</text>
447<rect x="470" y="500" width="400" height="200" fill="#f5f5f5" stroke="#000000" stroke-width="2"/>
448<text x="488" y="530" font-family="sans-serif" font-size="24" font-weight="700">DSFB Observer Layer</text>
449<text x="488" y="566" font-family="sans-serif" font-size="18">Residual -&gt; Sign -&gt; Syntax</text>
450<text x="488" y="594" font-family="sans-serif" font-size="18">Grammar -&gt; Semantics -&gt; Policy</text>
451<text x="488" y="622" font-family="sans-serif" font-size="18">advisory interpretation only</text>
452<rect x="1020" y="520" width="430" height="160" fill="#d7d7d7" stroke="#000000" stroke-width="2"/>
453<text x="1038" y="550" font-family="sans-serif" font-size="24" font-weight="700">Operator-Facing Advisory Output</text>
454<text x="1038" y="586" font-family="sans-serif" font-size="18">Silent / Watch / Review / Escalate</text>
455<text x="1038" y="614" font-family="sans-serif" font-size="18">typed residual interpretation</text>
456<text x="1038" y="642" font-family="sans-serif" font-size="18">fail-safe isolated</text>
457<line x1="350" y1="270" x2="470" y2="270" stroke="#000000" stroke-width="3"/>
458<polyline points="456,262 470,270 456,278" fill="none" stroke="#000000" stroke-width="3"/>
459<text x="392" y="248" font-family="sans-serif" font-size="18">x(k)</text>
460<line x1="820" y1="270" x2="940" y2="270" stroke="#000000" stroke-width="3"/>
461<polyline points="926,262 940,270 926,278" fill="none" stroke="#000000" stroke-width="3"/>
462<text x="836" y="248" font-family="sans-serif" font-size="18">alarms / actuation</text>
463<line x1="650" y1="330" x2="650" y2="500" stroke="#000000" stroke-width="3" stroke-dasharray="12,8"/>
464<polyline points="642,486 650,500 658,486" fill="none" stroke="#000000" stroke-width="3"/>
465<text x="674" y="420" font-family="sans-serif" font-size="18">read-only residual tap</text>
466<line x1="980" y1="330" x2="920" y2="560" stroke="#000000" stroke-width="3" stroke-dasharray="12,8"/>
467<polyline points="912,546 920,560 928,546" fill="none" stroke="#000000" stroke-width="3"/>
468<text x="932" y="446" font-family="sans-serif" font-size="18">read-only alarm tap</text>
469<line x1="870" y1="600" x2="1020" y2="600" stroke="#000000" stroke-width="3"/>
470<polyline points="1006,592 1020,600 1006,608" fill="none" stroke="#000000" stroke-width="3"/>
471<text x="912" y="578" font-family="sans-serif" font-size="18">advisory only</text>
472<text x="60" y="790" font-family="sans-serif" font-size="18">No arrow returns from DSFB to control. No threshold, recipe, or actuation API exists.</text>
473<text x="60" y="825" font-family="sans-serif" font-size="18">Deterministic replay: identical ordered inputs yield identical DSFB outputs.</text>
474</svg>
475"##,
476        width = ARCHITECTURE_WIDTH,
477        height = ARCHITECTURE_HEIGHT,
478    );
479    fs::write(output_path, svg)?;
480    Ok(())
481}
482
483fn plot_error<E: std::fmt::Display>(err: E) -> DsfbSemiconductorError {
484    DsfbSemiconductorError::ExternalCommand(err.to_string())
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    #[test]
492    fn deterministic_replay_observer_is_stable() {
493        let observer = DeterministicReplayObserver;
494        let input = vec![
495            DsfbObserverInput {
496                run_index: 7,
497                timestamp: "2008-07-19 21:57:00".into(),
498                residuals: vec![0.0, 1.0, 2.0],
499                upstream_alarms: UpstreamAlarmSnapshot {
500                    ewma_alarm: true,
501                    spc_alarm: false,
502                    threshold_alarm: false,
503                },
504                metadata_pairs: vec![("tool".into(), "etch".into())],
505            },
506            DsfbObserverInput {
507                run_index: 8,
508                timestamp: "2008-07-19 22:02:00".into(),
509                residuals: vec![0.0, 0.0, 0.0],
510                upstream_alarms: UpstreamAlarmSnapshot {
511                    ewma_alarm: false,
512                    spc_alarm: false,
513                    threshold_alarm: false,
514                },
515                metadata_pairs: vec![("tool".into(), "etch".into())],
516            },
517        ];
518        let first = observer.observe(&input);
519        let second = observer.observe(&input);
520        assert_eq!(first, second);
521        assert_eq!(
522            first[0].layer_order.join(" -> "),
523            DSFB_LAYER_ORDER.join(" -> ")
524        );
525    }
526
527    #[test]
528    fn advisory_output_surface_contains_no_feedback_keys() {
529        let observer = DeterministicReplayObserver;
530        let output = observer.observe(&[DsfbObserverInput {
531            run_index: 1,
532            timestamp: "2008-01-01 00:00:00".into(),
533            residuals: vec![3.0],
534            upstream_alarms: UpstreamAlarmSnapshot {
535                ewma_alarm: true,
536                spc_alarm: true,
537                threshold_alarm: false,
538            },
539            metadata_pairs: vec![],
540        }]);
541        let serialized = serde_json::to_string(&output[0]).unwrap();
542        for forbidden in ["controller", "actuation", "recipe", "threshold_write"] {
543            assert!(
544                !serialized.contains(forbidden),
545                "unexpected feedback surface key {forbidden}"
546            );
547        }
548    }
549
550    #[test]
551    fn architecture_artifacts_are_deterministic() {
552        let temp = tempfile::tempdir().unwrap();
553        let first_dir = temp.path().join("first");
554        let second_dir = temp.path().join("second");
555        fs::create_dir_all(&first_dir).unwrap();
556        fs::create_dir_all(&second_dir).unwrap();
557
558        let first = materialize_non_intrusive_artifacts(&first_dir).unwrap();
559        let second = materialize_non_intrusive_artifacts(&second_dir).unwrap();
560
561        assert_eq!(
562            fs::read(first.architecture_png_path).unwrap(),
563            fs::read(second.architecture_png_path).unwrap()
564        );
565        assert_eq!(
566            fs::read_to_string(first.architecture_svg_path).unwrap(),
567            fs::read_to_string(second.architecture_svg_path).unwrap()
568        );
569        assert!(first.interface_spec_path.exists());
570    }
571}