Skip to main content

polydat_core/library/
context.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Context state nodes: non-deterministic, session-scoped values.
5//!
6//! These nodes produce values from the execution environment rather
7//! than the coordinate space. They break the deterministic model
8//! and should be used deliberately.
9//!
10//! SRD-80b Phase E migration. All authoring goes through
11//! `#[polydat_node]`. Three shapes appear here:
12//!
13//! * Pure clock / OS reads (`current_epoch_millis`, `thread_id`) —
14//!   plain body, marked `Nondeterministic`.
15//! * Construction-frozen captures (`session_start_millis`,
16//!   `elapsed_millis`, `tmp_dir`, `env_or`) — use
17//!   `#[poly_const(setup_fn, from = ())]` (or `from = <const_arg>`
18//!   when the capture depends on a const) to compute the cached
19//!   value once at construction. The body just reads the cache.
20//! * Fallible construction (`env`) — body returns
21//!   `Result<String, String>`. The macro emits `try_new` and
22//!   propagates `Err` as a workload-compile error via the build
23//!   closure.
24
25use std::sync::atomic::{AtomicU64, Ordering};
26use std::time::{SystemTime, UNIX_EPOCH};
27
28use crate::ast::{NodeMeta, PolydatNode, Port, Slot, SlotType, Value};
29
30/// Current wall-clock time in epoch milliseconds.
31///
32/// Signature: `() -> (u64)`. Non-deterministic — clock read per eval.
33#[crate::polydat_node(
34    category = Context,
35    purity = Nondeterministic("reads system clock"),
36)]
37fn current_epoch_millis() -> u64 {
38    SystemTime::now()
39        .duration_since(UNIX_EPOCH)
40        .unwrap()
41        .as_millis() as u64
42}
43
44/// Helper for the time-capture setup fns: read epoch millis now.
45/// Plain function pointer compatible with `#[poly_const(fn, from = ())]`.
46fn capture_epoch_millis() -> u64 {
47    SystemTime::now()
48        .duration_since(UNIX_EPOCH)
49        .unwrap()
50        .as_millis() as u64
51}
52
53fn session_start_millis_jit_constants(node: &SessionStartMillis) -> Vec<u64> {
54    vec![node.start]
55}
56
57/// Session start time in epoch milliseconds, frozen at construction.
58///
59/// Signature: `() -> (u64)`. Deterministic within a session.
60///
61/// Captured-at-construction values are marked Nondeterministic so
62/// they are excluded from const-fold identity (workload hash stays
63/// stable across runs even though the captured value differs).
64#[crate::polydat_node(
65    category = Context,
66    purity = Nondeterministic("session start time captured from system clock"),
67    jit_constants = session_start_millis_jit_constants,
68)]
69fn session_start_millis(#[poly_const(capture_epoch_millis, from = ())] start: &u64) -> u64 {
70    *start
71}
72
73fn elapsed_millis_jit_constants(node: &ElapsedMillis) -> Vec<u64> {
74    vec![node.start]
75}
76
77/// Elapsed milliseconds since session start.
78///
79/// Signature: `() -> (u64)`. Non-deterministic, grows monotonically.
80#[crate::polydat_node(
81    category = Context,
82    purity = Nondeterministic("monotonic elapsed time from system clock"),
83    jit_constants = elapsed_millis_jit_constants,
84)]
85fn elapsed_millis(#[poly_const(capture_epoch_millis, from = ())] start: &u64) -> u64 {
86    let now = SystemTime::now()
87        .duration_since(UNIX_EPOCH)
88        .unwrap()
89        .as_millis() as u64;
90    now.saturating_sub(*start)
91}
92
93/// Current OS thread numeric identifier.
94///
95/// Signature: `() -> (u64)`. Non-deterministic — value depends on
96/// the scheduling thread.
97#[crate::polydat_node(
98    category = Context,
99    purity = Nondeterministic("OS thread identity varies across fibers"),
100)]
101fn thread_id() -> u64 {
102    thread_local! {
103        // `ThreadId` is opaque; the numeric id is extracted once per
104        // thread via the Debug formatter (`ThreadId(N)`).
105        static THREAD_ID: u64 = {
106            let id = std::thread::current().id();
107            let id_str = format!("{id:?}");
108            let num = id_str.trim_start_matches("ThreadId(").trim_end_matches(')');
109            num.parse().unwrap_or(0)
110        };
111    }
112    THREAD_ID.with(|id| *id)
113}
114
115/// Environment variable read, frozen at construction.
116///
117/// Signature: `env(name: const str) -> str`. Reads the named env
118/// var once at workload-compile time; the captured value is
119/// returned on every eval. Errors at construction when the
120/// variable is unset — use `env_or` for a defaulted form.
121///
122/// SRD-80b Phase E: fallible construction. The body returns
123/// `Result<String, String>`; the macro runs it once inside
124/// `try_new`, caches the Ok value, and propagates Err as a
125/// build-time error.
126#[crate::polydat_node(category = Context)]
127fn env(name: Const<&str>) -> Result<String, String> {
128    let var = name.0;
129    std::env::var(var).map_err(|_| {
130        format!(
131            "env('{var}'): environment variable not set; \
132         use env_or('{var}', '<default>') if a fallback is acceptable",
133        )
134    })
135}
136
137/// Environment variable read with default, frozen at construction.
138///
139/// Signature: `env_or(name: const str, default: const str) -> str`.
140/// Reads the named env var at construction; falls back to the
141/// literal `default` when the variable is unset. The captured
142/// value is constant for the session.
143#[crate::polydat_node(category = Context)]
144fn env_or(
145    name: Const<&str>,
146    default: Const<&str>,
147    #[poly_const(capture_env_opt, from = name)] captured: &Option<String>,
148) -> String {
149    match captured {
150        Some(v) => v.clone(),
151        None => default.0.to_string(),
152    }
153}
154
155/// Setup helper for `env_or`: read the env var into `Option<String>`.
156/// `None` indicates the var is unset; the body picks the default.
157fn capture_env_opt(name: &str) -> Option<String> {
158    std::env::var(name).ok()
159}
160
161/// System temp directory, frozen at construction.
162///
163/// Signature: `tmp_dir() -> str`.
164#[crate::polydat_node(category = Context)]
165fn tmp_dir(#[poly_const(capture_tmp_dir, from = ())] path: &String) -> String {
166    path.clone()
167}
168
169/// Setup helper for `tmp_dir`: capture `std::env::temp_dir()` as
170/// a UTF-8 string. Falls back to `/tmp` on non-UTF-8 paths
171/// (extremely rare on modern systems).
172fn capture_tmp_dir() -> String {
173    std::env::temp_dir()
174        .to_str()
175        .map(String::from)
176        .unwrap_or_else(|| "/tmp".to_string())
177}
178
179/// Monotonic counter (non-deterministic). SRD-80 PR B.11 migration.
180///
181/// Returns 0, 1, 2, ... across all calls. Thread-safe via AtomicU64.
182#[crate::polydat_node(
183    category = Context,
184    purity = Nondeterministic("monotonic counter incremented per call"),
185)]
186fn counter(
187    #[poly_default(0u64)] start: Const<u64>,
188    #[poly_const(AtomicU64::new, from = start)] count: &AtomicU64,
189) -> u64 {
190    count.fetch_add(1, Ordering::Relaxed)
191}
192
193// ---------------------------------------------------------------------------
194// Cursor limit — not a #[polydat_node]: it's a passthrough whose
195// `max_items` value is read via the const-slot meta by the cursor
196// machinery, and is constructed directly by the cursor compiler
197// (`polydat::dsl::compile`) rather than via the DSL function
198// registry. Keeping the hand-written shape preserves the explicit
199// constructor used at that one call site.
200// ---------------------------------------------------------------------------
201
202/// Cursor limit node: passes through the input value unchanged.
203///
204/// Inserted by the compiler when the `limit` activity parameter is present.
205/// The node is a visible, documented passthrough in the Polydat graph that
206/// clamps the cursor's extent. The `max_items` value is used by the
207/// `Cursors` system to determine when to stop advancing.
208///
209/// Signature: `limit(input: u64, max_items: u64) -> u64`
210pub struct CursorLimit {
211    meta: NodeMeta,
212    /// Maximum number of items the cursor should yield.
213    pub max_items: u64,
214}
215
216impl CursorLimit {
217    /// A limit node yielding at most `max_items`.
218    pub fn new(max_items: u64) -> Self {
219        Self {
220            meta: NodeMeta {
221                name: "limit".into(),
222                outs: vec![Port::u64("output")],
223                ins: vec![Slot::Wire(Port::u64("input"))],
224            },
225            max_items,
226        }
227    }
228}
229
230impl PolydatNode for CursorLimit {
231    fn meta(&self) -> &NodeMeta {
232        &self.meta
233    }
234    fn eval(&self, inputs: &[Value], outputs: &mut [Value]) {
235        // Pure passthrough — the limit is enforced by the cursor system,
236        // not by the node evaluation. The node exists to be visible in
237        // the graph and to carry the max_items metadata.
238        outputs[0] = inputs[0].clone();
239    }
240    /// The same passthrough on the closure tier: the limit is the
241    /// cursor system's, so the compiled step copies its slot.
242    fn compiled_u64(&self) -> Option<crate::ast::CompiledU64Op> {
243        Some(Box::new(|inputs: &[u64], outputs: &mut [u64]| {
244            outputs[0] = inputs[0];
245        }))
246    }
247}
248
249// ---------------------------------------------------------------------------
250// Signature declarations for the cursor-limit node only. Every
251// other context node registers itself via the `#[polydat_node]`
252// macro's inventory submission.
253// ---------------------------------------------------------------------------
254
255use crate::dsl::registry::{Arity, FuncCategory, FuncSig, ParamSpec};
256
257/// Signature for the cursor-limit passthrough.
258pub fn signatures() -> &'static [FuncSig] {
259    use FuncCategory as C;
260    &[FuncSig {
261        name: "limit",
262        category: C::Context,
263        outputs: 1,
264        description: "cursor limit — clamps extent for smoke testing",
265        help: "Passes through the input value unchanged. Inserted by the compiler\n\
266                   when the `limit` activity parameter is present. The max_items value\n\
267                   is used by the cursor system to stop advancing early.\n\
268                   Parameters:\n  input — cursor wire (u64)\n  max_items — maximum items to yield\n\
269                   Example: row = limit(row, 100)  // stop after 100 items",
270        identity: None,
271        variadic_ctor: None,
272        params: &[
273            ParamSpec {
274                name: "input",
275                slot_type: SlotType::Wire,
276                required: true,
277                example: "row",
278                constraint: None,
279            },
280            ParamSpec {
281                name: "max_items",
282                slot_type: SlotType::ConstU64,
283                required: true,
284                example: "100",
285                constraint: None,
286            },
287        ],
288        arity: Arity::Fixed,
289        commutativity: crate::ast::Commutativity::Positional,
290        default_resolver: None,
291        output_type: crate::dsl::registry::OutputType::Fixed,
292        // Hand registration: no static return-port declaration;
293        // type inference falls back to the name heuristic.
294        output_port: None,
295    }]
296}
297
298/// Build the cursor-limit node by name. Other context nodes
299/// register via the `#[polydat_node]` macro's inventory hook.
300pub(crate) fn build_node(
301    name: &str,
302    _wires: &[crate::compile::assembly::WireRef],
303    _wire_types: &[crate::ast::PortType],
304    consts: &[crate::dsl::factory::ConstArg],
305) -> Option<Result<Box<dyn crate::ast::PolydatNode>, String>> {
306    match name {
307        "limit" => {
308            let max_items = consts.first().map(|c| c.as_u64()).unwrap_or(u64::MAX);
309            Some(Ok(Box::new(CursorLimit::new(max_items))))
310        }
311        _ => None,
312    }
313}
314
315crate::register_nodes!(signatures, build_node);
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn current_epoch_millis_reasonable() {
323        let node = CurrentEpochMillis::new();
324        let mut out = [Value::None];
325        node.eval(&[], &mut out);
326        let millis = out[0].as_u64();
327        // Should be after 2024-01-01 (1704067200000)
328        assert!(millis > 1_704_067_200_000);
329    }
330
331    #[test]
332    fn session_start_frozen() {
333        let node = SessionStartMillis::new();
334        let mut out1 = [Value::None];
335        let mut out2 = [Value::None];
336        node.eval(&[], &mut out1);
337        node.eval(&[], &mut out2);
338        assert_eq!(out1[0].as_u64(), out2[0].as_u64());
339    }
340
341    #[test]
342    fn elapsed_grows() {
343        let node = ElapsedMillis::new();
344        let mut out = [Value::None];
345        node.eval(&[], &mut out);
346        let e1 = out[0].as_u64();
347        // Elapsed should be non-negative
348        assert!(e1 < 1000, "elapsed should be small right after creation");
349    }
350
351    #[test]
352    fn counter_increments() {
353        let node = Counter::new(0);
354        let mut out = [Value::None];
355        node.eval(&[], &mut out);
356        assert_eq!(out[0].as_u64(), 0);
357        node.eval(&[], &mut out);
358        assert_eq!(out[0].as_u64(), 1);
359        node.eval(&[], &mut out);
360        assert_eq!(out[0].as_u64(), 2);
361    }
362
363    #[test]
364    fn counter_starting_at() {
365        let node = Counter::new(100);
366        let mut out = [Value::None];
367        node.eval(&[], &mut out);
368        assert_eq!(out[0].as_u64(), 100);
369        node.eval(&[], &mut out);
370        assert_eq!(out[0].as_u64(), 101);
371    }
372
373    /// Generate a unique env-var name per test so concurrent test
374    /// threads can't collide on the same key. The process env is
375    /// global state; using fixed names like `TEST_VAR` makes
376    /// tests order-dependent.
377    fn unique_var(tag: &str) -> String {
378        use std::time::{SystemTime, UNIX_EPOCH};
379        let nanos = SystemTime::now()
380            .duration_since(UNIX_EPOCH)
381            .unwrap()
382            .as_nanos();
383        format!("__NBRS_TEST_{tag}_{nanos:x}")
384    }
385
386    #[test]
387    fn env_captures_value_at_construction() {
388        let var = unique_var("ENV");
389        unsafe {
390            std::env::set_var(&var, "captured-value");
391        }
392        let node = Env::try_new(var.clone()).expect("env should read the set var");
393        // Mutating the env after construction must NOT change the
394        // node's output — the value is frozen at construction.
395        unsafe {
396            std::env::set_var(&var, "later-value");
397        }
398        let mut out = [Value::None];
399        node.eval(&[], &mut out);
400        assert_eq!(out[0].as_str().to_string(), "captured-value");
401        unsafe {
402            std::env::remove_var(&var);
403        }
404    }
405
406    #[test]
407    fn env_errors_when_var_unset() {
408        let var = unique_var("ENV_MISSING");
409        unsafe {
410            std::env::remove_var(&var);
411        }
412        match Env::try_new(var.clone()) {
413            Ok(_) => panic!("Env::try_new should fail when the var is unset"),
414            Err(err) => {
415                assert!(
416                    err.contains(&var),
417                    "error should name the missing var: {err}"
418                );
419                assert!(
420                    err.contains("env_or"),
421                    "error should suggest env_or as the defaulted alternative: {err}"
422                );
423            }
424        }
425    }
426
427    #[test]
428    fn env_or_uses_default_when_var_unset() {
429        let var = unique_var("ENV_OR_MISSING");
430        unsafe {
431            std::env::remove_var(&var);
432        }
433        let node = EnvOr::new(var.clone(), "fallback".to_string());
434        let mut out = [Value::None];
435        node.eval(&[], &mut out);
436        assert_eq!(out[0].as_str().to_string(), "fallback");
437    }
438
439    #[test]
440    fn env_or_uses_var_value_when_set() {
441        let var = unique_var("ENV_OR_SET");
442        unsafe {
443            std::env::set_var(&var, "real-value");
444        }
445        let node = EnvOr::new(var.clone(), "fallback".to_string());
446        let mut out = [Value::None];
447        node.eval(&[], &mut out);
448        assert_eq!(out[0].as_str().to_string(), "real-value");
449        unsafe {
450            std::env::remove_var(&var);
451        }
452    }
453
454    #[test]
455    fn env_or_captures_at_construction_not_each_eval() {
456        let var = unique_var("ENV_OR_FROZEN");
457        unsafe {
458            std::env::set_var(&var, "first");
459        }
460        let node = EnvOr::new(var.clone(), "ignored-default".to_string());
461        unsafe {
462            std::env::set_var(&var, "second");
463        }
464        let mut out = [Value::None];
465        node.eval(&[], &mut out);
466        assert_eq!(
467            out[0].as_str().to_string(),
468            "first",
469            "env_or must freeze its value at construction; later env mutations are invisible"
470        );
471        unsafe {
472            std::env::remove_var(&var);
473        }
474    }
475
476    #[test]
477    fn tmp_dir_returns_a_path() {
478        let node = TmpDir::new();
479        let mut out = [Value::None];
480        node.eval(&[], &mut out);
481        let s = out[0].as_str().to_string();
482        assert!(!s.is_empty(), "tmp_dir() should produce a non-empty path");
483    }
484
485    #[test]
486    fn tmp_dir_is_stable_across_evals() {
487        let node = TmpDir::new();
488        let mut a = [Value::None];
489        let mut b = [Value::None];
490        node.eval(&[], &mut a);
491        node.eval(&[], &mut b);
492        assert_eq!(a[0].as_str(), b[0].as_str());
493    }
494
495    /// DSL-level integration: env_or / tmp_dir resolve through the
496    /// registry and produce kernels that compile cleanly.
497    #[test]
498    fn env_or_compiles_through_dsl() {
499        let var = unique_var("DSL_ENV_OR");
500        unsafe {
501            std::env::set_var(&var, "x-value");
502        }
503        let src = format!("v := env_or(\"{var}\", \"fallback\")\n",);
504        let kernel = crate::dsl::compile_polydat(&src).expect("compile env_or");
505        unsafe {
506            std::env::remove_var(&var);
507        }
508        // The output should be the captured value. We can't read
509        // the kernel's outputs directly without an eval pass; the
510        // shape check (compiled cleanly, registered in DSL) is
511        // what this test asserts.
512        let names = kernel.program().output_names();
513        assert!(names.contains(&"v"), "expected output 'v' in {names:?}");
514    }
515
516    #[test]
517    fn tmp_dir_compiles_through_dsl_in_string_template() {
518        // Confirms the existing string-template machinery accepts
519        // function calls like `{tmp_dir()}` in Polydat string literals
520        // — no new syntax needed for the resumable-test-fixture
521        // workload's path composition.
522        let src = "path := \"{tmp_dir()}/data\"\n";
523        let kernel =
524            crate::dsl::compile_polydat(src).expect("compile tmp_dir() interpolated in a string");
525        let names = kernel.program().output_names();
526        assert!(
527            names.contains(&"path"),
528            "expected output 'path' in {names:?}"
529        );
530    }
531}