Skip to main content

polydat_core/library/
diagnostic.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Diagnostic and debugging nodes.
5//!
6//! These are development aids, not hot-path nodes. They let users
7//! inspect types and values flowing through the DAG.
8//!
9//! `fft_analyze` keeps its cross-call eval state (buffer + lazy-open
10//! output file) in struct fields derived via `#[poly_const]` setup
11//! functions — one buffer setup keyed on `window_size`, one output
12//! setup keyed on `filename`. The file is opened lazily so
13//! describe/probe/dryrun paths that never feed samples leave
14//! nothing behind.
15
16use crate::ast::Value;
17
18/// Emit the type name of the input value as a string.
19///
20/// Signature: `(input: any) -> (String)`
21///
22/// Returns "u64", "f64", "bool", "String", or "bytes".
23#[crate::polydat_node(category = Diagnostic)]
24fn type_of(input: Value) -> String {
25    input.port_type().to_string()
26}
27
28/// Emit the Rust Debug representation of the input value.
29#[crate::polydat_node(category = Diagnostic)]
30fn debug_repr(input: Value) -> String {
31    format!("{input:?}")
32}
33
34/// Passthrough that prints the value (with a const label) to
35/// stderr. SameAsInput output — runtime port type preserved.
36#[crate::polydat_node(category = Diagnostic, purity = SideChannel(Stderr))]
37fn inspect(
38    input: Value,
39    #[poly_default("inspect")] label: crate::derive_support::Const<&str>,
40) -> Value {
41    eprintln!("[inspect:{}] {input:?}", label.0);
42    input
43}
44
45// ---------------------------------------------------------------------------
46// FFT / DFT analysis node
47// ---------------------------------------------------------------------------
48
49/// Wraps the lazily-opened output file plus the path it was
50/// configured with. Construction stores the path; the file is
51/// opened on the first window emit so probes / dryruns that
52/// never feed samples don't leave empty artifacts behind.
53struct FftOutput {
54    path: String,
55    writer: Option<std::io::BufWriter<std::fs::File>>,
56    open_attempted: bool,
57}
58
59impl crate::derive_support::PolydatSetup for std::sync::Mutex<Vec<f64>> {}
60impl crate::derive_support::PolydatSetup for std::sync::Mutex<FftOutput> {}
61
62/// Build the per-window signal buffer for the given window size.
63/// `window_size` is clamped to a minimum of 2 (DFT below that is
64/// degenerate). Returned as a `Mutex<Vec<f64>>` so the eval body
65/// can mutate across calls while remaining Send+Sync.
66fn fft_buffer(window_size: u64) -> std::sync::Mutex<Vec<f64>> {
67    let cap = window_size.max(2) as usize;
68    std::sync::Mutex::new(Vec::with_capacity(cap))
69}
70
71/// Stash the output path without opening the file. Lazy-open
72/// happens on the first window emit so describe/probe/dryrun
73/// paths that construct the node without ever feeding samples
74/// leave nothing behind.
75fn fft_output(filename: &str) -> std::sync::Mutex<FftOutput> {
76    std::sync::Mutex::new(FftOutput {
77        path: filename.to_string(),
78        writer: None,
79        open_attempted: false,
80    })
81}
82
83/// Collect values over N cycles and write DFT analysis to a JSONL file.
84///
85/// Signature: `fft_analyze(signal: f64, filename: str, window_size: u64) -> (u64)`
86///
87/// This is a diagnostic node with side effects (file I/O). It buffers
88/// N f64 signal values, computes a discrete Fourier transform when the
89/// buffer fills, writes one JSONL line with magnitudes, phases, DC
90/// component, and fundamental frequency, then clears the buffer.
91///
92/// The output is a passthrough of the current buffer length (how many
93/// samples have been collected in the current window).
94///
95/// Declared `Nondeterministic` — the per-cycle signal buffer
96/// accumulates across calls (return value depends on prior
97/// history), and every window emit writes a JSONL line to the
98/// configured file path. The eval-spanning state is the
99/// load-bearing aspect.
100#[crate::polydat_node(
101    category = Diagnostic,
102    purity = Nondeterministic("accumulates signal buffer across calls; writes JSONL on window emit"),
103)]
104fn fft_analyze(
105    signal: f64,
106    #[poly_default("fft.jsonl")] filename: crate::derive_support::Const<&str>,
107    #[poly_default(256u64)] window_size: crate::derive_support::Const<u64>,
108    #[poly_const(fft_buffer, from = window_size)] buffer: &std::sync::Mutex<Vec<f64>>,
109    #[poly_const(fft_output, from = filename)] output: &std::sync::Mutex<FftOutput>,
110) -> u64 {
111    let _ = filename; // const value stashed in `output` at setup; not used at eval
112    let window = (*window_size).max(2) as usize;
113
114    let mut buf = buffer.lock().unwrap();
115    let current_len = buf.len() as u64;
116
117    buf.push(signal);
118
119    if buf.len() >= window {
120        // Compute DFT
121        let n = buf.len();
122        let mut magnitudes = Vec::with_capacity(n / 2 + 1);
123        let mut phases = Vec::with_capacity(n / 2 + 1);
124
125        for k in 0..=(n / 2) {
126            let mut re = 0.0f64;
127            let mut im = 0.0f64;
128            for (i, &x) in buf.iter().enumerate() {
129                let angle = -2.0 * std::f64::consts::PI * (k as f64) * (i as f64) / (n as f64);
130                re += x * angle.cos();
131                im += x * angle.sin();
132            }
133            magnitudes.push((re * re + im * im).sqrt() / n as f64);
134            phases.push(im.atan2(re));
135        }
136
137        // Write JSONL line. The output file is lazy-opened
138        // here on first use; a previous open failure (bad
139        // path, permissions) is sticky for the lifetime of
140        // the node so we don't retry on every window.
141        if let Ok(mut out) = output.lock() {
142            if !out.open_attempted {
143                out.open_attempted = true;
144                out.writer = std::fs::File::create(&out.path)
145                    .ok()
146                    .map(std::io::BufWriter::new);
147            }
148            if let Some(ref mut writer) = out.writer {
149                use std::io::Write;
150                let json = serde_json::json!({
151                    "window_size": n,
152                    "magnitudes": magnitudes,
153                    "phases": phases,
154                    "dc": magnitudes.first().copied().unwrap_or(0.0),
155                    "fundamental": magnitudes.get(1).copied().unwrap_or(0.0),
156                });
157                let _ = writeln!(writer, "{}", json);
158                let _ = writer.flush();
159            }
160        }
161
162        buf.clear();
163    }
164
165    current_len
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    use crate::ast::{PolydatNode, PortType};
173
174    #[test]
175    fn type_of_u64() {
176        let node = TypeOf::new(PortType::U64);
177        let mut out = [Value::None];
178        node.eval(&[Value::U64(42)], &mut out);
179        assert_eq!(out[0].as_str(), "u64");
180    }
181
182    #[test]
183    fn type_of_f64() {
184        let node = TypeOf::new(PortType::F64);
185        let mut out = [Value::None];
186        node.eval(&[Value::F64(3.14)], &mut out);
187        assert_eq!(out[0].as_str(), "f64");
188    }
189
190    #[test]
191    fn type_of_str() {
192        let node = TypeOf::new(PortType::Str);
193        let mut out = [Value::None];
194        node.eval(&[Value::Str("hello".into())], &mut out);
195        assert_eq!(out[0].as_str(), "String");
196    }
197
198    #[test]
199    fn debug_repr_u64() {
200        let node = DebugRepr::new(PortType::U64);
201        let mut out = [Value::None];
202        node.eval(&[Value::U64(42)], &mut out);
203        assert_eq!(out[0].as_str(), "U64(42)");
204    }
205
206    #[test]
207    fn debug_repr_str() {
208        let node = DebugRepr::new(PortType::Str);
209        let mut out = [Value::None];
210        node.eval(&[Value::Str("hello".into())], &mut out);
211        assert!(out[0].as_str().contains("hello"));
212    }
213
214    #[test]
215    fn inspect_passthrough() {
216        let node = Inspect::new(PortType::U64, "test".to_string());
217        let mut out = [Value::None];
218        node.eval(&[Value::U64(42)], &mut out);
219        assert_eq!(out[0].as_u64(), 42);
220    }
221
222    #[test]
223    fn fft_analyzer_collects_and_writes() {
224        let tmp = std::env::temp_dir().join("test_fft_diag.jsonl");
225        let path = tmp.to_str().unwrap();
226        let node = FftAnalyze::new(path.to_string(), 4u64);
227        let mut out = [Value::None];
228
229        // Feed 4 samples: a simple DC signal of 1.0
230        for i in 0..4 {
231            node.eval(&[Value::F64(1.0)], &mut out);
232            // Output is the buffer length before this push
233            assert_eq!(out[0].as_u64(), i as u64);
234        }
235
236        // After 4 samples, buffer should have been flushed
237        // Next eval should show buffer len 0 again
238        node.eval(&[Value::F64(1.0)], &mut out);
239        assert_eq!(out[0].as_u64(), 0);
240
241        // Verify the JSONL file was written
242        let contents = std::fs::read_to_string(path).unwrap();
243        assert!(!contents.is_empty(), "JSONL file should not be empty");
244        let line: serde_json::Value =
245            serde_json::from_str(contents.lines().next().unwrap()).unwrap();
246        assert_eq!(line["window_size"], 4);
247        // DC component of constant 1.0 signal should be ~1.0/4 * 4 = 1.0
248        // Actually our normalization divides by n, so DC = sum/n = 1.0
249        let dc = line["dc"].as_f64().unwrap();
250        assert!(
251            (dc - 1.0).abs() < 0.001,
252            "DC component of constant signal should be ~1.0, got {dc}"
253        );
254
255        // Clean up
256        let _ = std::fs::remove_file(path);
257    }
258}