Skip to main content

nmbrs_runtime/checkpoint/
params_scope.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! SRD-107 — the consumed-params derivation.
5//!
6//! Which workload params does a phase's scope actually consume?
7//! The answer is DERIVED, never declared (a declaration surface
8//! would drift from the programs and reintroduce stale-skip bugs
9//! by hand):
10//!
11//! - **GK backward closure** — polydat owns every step of it
12//!   ([`polydat::kernel::PolydatProgram::owned_extern_closure`]
13//!   seeds from owned outputs; `resolve_externs_through` walks
14//!   the scope chain; both are projections of the ONE
15//!   construction-time node inventory). Names still unresolved
16//!   past the workload root that match declared params are
17//!   consumed from the params module. Aliasing through upstream
18//!   rebindings (`alias := run_tag` at the root, phase reads
19//!   `alias`) resolves correctly with no textual guessing.
20//! - **Textual union** — `{name}` interpolation sites in the
21//!   phase's canonical config (op statement text, governance
22//!   fields like `cycles:`/`timeout:`) never enter compiled
23//!   programs; a substring scan over the canonical config text
24//!   covers them. False positives only over-invalidate, and only
25//!   when that specific param changes — the safe direction.
26//!
27//! The result maps each consumed name to a digest of its CURRENT
28//! raw value; equality of stored vs freshly computed digests is
29//! the per-param leg of SRD-107's three-way skip-validity check.
30
31use std::collections::{BTreeMap, BTreeSet, HashMap};
32use std::sync::Arc;
33
34use crate::scope_kernel::ScopeKernel;
35use polydat::kernel::PolydatProgram;
36
37/// SHA-256 hex of a param's raw string value — the stored
38/// representation is a digest, never the value itself, so rows
39/// stay uniform and value contents (hosts, credentials-adjacent
40/// strings) don't land in provenance stores.
41pub(crate) fn value_digest(value: &str) -> String {
42    use sha2::{Digest, Sha256};
43    let mut h = Sha256::new();
44    h.update(b"nmbrs-param-value-v1\n");
45    h.update(value.as_bytes());
46    h.finalize().iter().map(|b| format!("{b:02x}")).collect()
47}
48
49/// Derive the phase's consumed-params map: `name -> value_digest`
50/// over exactly the params the phase's scope consumes.
51///
52/// `ancestors_below_session` is innermost-first (immediate parent
53/// … workload root), the params module EXCLUDED — the same shape
54/// [`crate::scope_tree::ScopeTree::ancestor_kernels_split`]
55/// returns. `phase_config_text` is the canonical config
56/// serialization ([`super::phase_config_canonical_text`]), shared
57/// with the config digest so the two legs read one surface.
58pub(crate) fn consumed_params(
59    own_program: &PolydatProgram,
60    op_template_programs: &[Arc<PolydatProgram>],
61    ancestors_below_session: &[Arc<ScopeKernel>],
62    phase_config_text: &str,
63    params: &HashMap<String, String>,
64) -> BTreeMap<String, String> {
65    // ALL graph reasoning lives in polydat (SRD-107's one-walker
66    // rule): the owned-output extern slices seed the walk, and
67    // `resolve_externs_through` resolves them up the scope chain
68    // to the terminal set the params module must satisfy. This
69    // function only composes those projections with the textual
70    // scan and the value digests.
71    let mut seed: Vec<String> = own_program.owned_extern_closure();
72    for prog in op_template_programs {
73        seed.extend(prog.owned_extern_closure());
74    }
75    let ancestor_programs: Vec<std::sync::Arc<PolydatProgram>> = ancestors_below_session
76        .iter()
77        .map(|k| k.program().clone())
78        .collect();
79    let ancestor_refs: Vec<&PolydatProgram> =
80        ancestor_programs.iter().map(|p| p.as_ref()).collect();
81    let terminal: BTreeSet<String> = PolydatProgram::resolve_externs_through(seed, &ancestor_refs)
82        .into_iter()
83        .collect();
84
85    // Terminal intersection + textual union.
86    let mut out = BTreeMap::new();
87    for (name, value) in params {
88        let gk = terminal.contains(name);
89        let textual = phase_config_text.contains(&format!("{{{name}}}"));
90        if gk || textual {
91            out.insert(name.clone(), value_digest(value));
92        }
93    }
94    out
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    fn kernel(source: &str) -> Arc<ScopeKernel> {
102        Arc::new(
103            crate::bindings::compile_scope_kernel(source, &Default::default())
104                .expect("compile test kernel"),
105        )
106    }
107
108    fn params(pairs: &[(&str, &str)]) -> HashMap<String, String> {
109        pairs
110            .iter()
111            .map(|(k, v)| (k.to_string(), v.to_string()))
112            .collect()
113    }
114
115    fn names(map: &BTreeMap<String, String>) -> Vec<&str> {
116        map.keys().map(String::as_str).collect()
117    }
118
119    #[test]
120    fn direct_extern_consumption() {
121        let phase = kernel("extern p1: String\nout := p1\n");
122        let got = consumed_params(
123            &phase.program(),
124            &[],
125            &[],
126            "",
127            &params(&[("p1", "a"), ("p2", "b")]),
128        );
129        assert_eq!(names(&got), vec!["p1"]);
130    }
131
132    #[test]
133    fn alias_rebinding_resolves_through_ancestor() {
134        // Root rebinds `alias := run_tag`; the phase reads `alias`.
135        // The closure must land on run_tag — the aliasing case that
136        // defeats textual scanning.
137        let root = kernel("extern run_tag: String\nalias := run_tag\n");
138        let phase = kernel("extern alias: String\nout := alias\n");
139        let got = consumed_params(
140            &phase.program(),
141            &[],
142            &[root],
143            "",
144            &params(&[("run_tag", "a"), ("other", "b")]),
145        );
146        assert_eq!(names(&got), vec!["run_tag"]);
147    }
148
149    #[test]
150    fn sibling_outputs_do_not_drag_their_params_in() {
151        // THE precision test: the root produces `a` from p1 and
152        // `b` from p2; a phase consuming only `a` must consume
153        // only p1 — whole-program over-approximation would pull
154        // p2 and reintroduce whole-module invalidation.
155        let root = kernel("extern p1: String\nextern p2: String\na := p1\nb := p2\n");
156        let phase = kernel("extern a: String\nout := a\n");
157        let got = consumed_params(
158            &phase.program(),
159            &[],
160            &[root],
161            "",
162            &params(&[("p1", "x"), ("p2", "y")]),
163        );
164        assert_eq!(names(&got), vec!["p1"]);
165    }
166
167    #[test]
168    fn textual_interpolation_site_is_consumed() {
169        let phase = kernel("out := 1\n");
170        let got = consumed_params(
171            &phase.program(),
172            &[],
173            &[],
174            r#"{"ops":{"q":{"stmt":"SELECT * FROM {keyspace}.t"}}}"#,
175            &params(&[("keyspace", "ks"), ("unrelated", "z")]),
176        );
177        assert_eq!(names(&got), vec!["keyspace"]);
178    }
179
180    #[test]
181    fn op_template_externs_seed_the_walk() {
182        let root = kernel("extern p1: String\nfield := p1\n");
183        let phase = kernel("out := 1\n");
184        let op_template = kernel("extern field: String\nrow := field\n");
185        let got = consumed_params(
186            &phase.program(),
187            &[op_template.program().clone()],
188            &[root],
189            "",
190            &params(&[("p1", "x"), ("p2", "y")]),
191        );
192        assert_eq!(names(&got), vec!["p1"]);
193    }
194
195    #[test]
196    fn iteration_var_resolved_by_scope_is_not_a_param() {
197        // A comprehension scope produces `section` from a literal;
198        // the phase's `section` extern resolves there and never
199        // reaches the params module.
200        let comprehension = kernel("section := \"b\"\n");
201        let phase = kernel("extern section: String\nout := section\n");
202        let got = consumed_params(
203            &phase.program(),
204            &[],
205            &[comprehension],
206            "",
207            &params(&[("run_tag", "a")]),
208        );
209        assert!(got.is_empty(), "got: {got:?}");
210    }
211
212    #[test]
213    fn coordinates_are_excluded_and_empty_set_is_empty() {
214        let phase = kernel("input cycle: u64\nout := cycle\n");
215        let got = consumed_params(&phase.program(), &[], &[], "", &params(&[("p1", "a")]));
216        assert!(got.is_empty(), "got: {got:?}");
217    }
218
219    /// The derivation counts a program's extern as consumed only
220    /// when it feeds an output the program OWNS. A bare
221    /// `compile_polydat_interpreter` re-exports every declared extern as an
222    /// output WITHOUT the `inherited` passthrough marking (that
223    /// marking is applied by the runtime's scope synthesis), so
224    /// this fixture legitimately reads as consumption. The
225    /// production shape — cascade-declared extern slots whose
226    /// re-exports ARE marked inherited and therefore do NOT count
227    /// — is pinned end-to-end by `refine_prereq_validity::
228    /// unconsumed_param_flip_still_skips_the_prereq`.
229    #[test]
230    fn bare_compile_extern_reexport_reads_as_owned() {
231        let phase = kernel("extern p1: String\nout := 1\n");
232        let got = consumed_params(&phase.program(), &[], &[], "", &params(&[("p1", "a")]));
233        assert_eq!(
234            names(&got),
235            vec!["p1"],
236            "bare-compile re-exports carry no inherited marking"
237        );
238    }
239
240    #[test]
241    fn value_digest_tracks_the_value() {
242        assert_eq!(value_digest("a"), value_digest("a"));
243        assert_ne!(value_digest("a"), value_digest("b"));
244    }
245}