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 -> Sign -> Syntax</text>
450<text x="488" y="594" font-family="sans-serif" font-size="18">Grammar -> Semantics -> 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}