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