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