Skip to main content

nmbrs_runtime/wrappers/
gutter.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Gutter wrapper — publishes the phase's contextual left-gutter
5//! cell from workload-declared polydat templates.
6//!
7//! Distinct from the `memo` wrapper: memo owns the `[[ ... ]]`
8//! header line above the phase status; the gutter owns the compact
9//! cell to the LEFT of the phase's detail row, where the display
10//! otherwise auto-derives a completion bar (metered phases) or a
11//! latency trend (daemons). This wrapper lets the workload take
12//! that cell over with its own computed value.
13//!
14//! Declaration forms:
15//!
16//! - `gutter: "<layout template>"` — printf-style polydat layout
17//!   string; the rendered text fills the cell verbatim.
18//! - `gutter: { bar: "<template>" }` — template renders to an
19//!   `f64` fraction in `0..=1`; displayed as the house braille
20//!   completion bar.
21//! - `gutter: { spark: "<template>" }` — template renders to an
22//!   `f64` sample; each publication appends to a per-phase trend
23//!   ring displayed as a sparkline with the current value.
24//! - `gutter: { …, final: <string | {bar|spark|text}> }` — the
25//!   COMPLETION form: evaluated once at phase end and rendered as
26//!   the left-gutter cell of the phase's ✓ outcome DETAIL line
27//!   (the header line's timing triad is never touched). Final
28//!   templates may also reference status-metric names (`{recall}`,
29//!   `{latency_p50}`, …) — resolved from the phase's aggregates
30//!   when no wire matches. Phases with a during-form but no
31//!   `final:` still get ONE final update: the during template
32//!   re-evaluated at phase end.
33//!
34//! Inside a `poll:` drain, the during form is additionally
35//! re-published per poll iteration by the poll wrapper (against
36//! that iteration's captures, like the poll memo) — this wrapper
37//! alone fires only when the drain op completes.
38
39use std::sync::Arc;
40
41use crate::adapter::WrappingDispenser;
42use crate::adapter::{ExecutionError, OpDispenser, OpResult};
43use crate::wrapper_registry::{WrapperName, WrapperRegistration, WrapperSubject};
44
45pub const NAME: WrapperName = WrapperName::new("gutter");
46
47/// One published gutter-cell value. The display consumes this via
48/// the phase render handle; `None` in the activity's slot means
49/// "derive automatically".
50#[derive(Debug, Clone, PartialEq)]
51pub enum GutterSpec {
52    /// Pre-rendered layout text, placed in the cell verbatim
53    /// (truncated to the cell width by the display).
54    Text(String),
55    /// Completion fraction `0..=1` → house braille bar.
56    Bar(f64),
57    /// One trend sample → sparkline ring + current-value label.
58    Spark(f64),
59    /// A key metric: label and value rendered on opposite sides of the cell.
60    /// Declared as `gutter: { final: { name: "recall", value: "{recall}" } }`.
61    /// The pair is explicit rather than split out of one string because the two
62    /// orders both occur in practice (`recall 96%` vs `≈900/s units`), so no
63    /// splitting rule can infer which token is the value.
64    Labeled { name: String, value: String },
65}
66
67/// Trigger: `gutter:` is a string (layout template) or a map with
68/// a `bar:` or `spark:` key.
69fn triggers(s: WrapperSubject) -> bool {
70    let Some(template) = s.op() else {
71        return false;
72    };
73    template
74        .params
75        .get("gutter")
76        .map(|v| v.is_string() || v.is_object())
77        .unwrap_or(false)
78}
79
80fn describe_assignment(s: WrapperSubject) -> Option<String> {
81    let template = s.op()?;
82    let v = template.params.get("gutter")?;
83    if let Some(s) = v.as_str() {
84        if s.is_empty() {
85            return None;
86        }
87        Some(format!("gutter: \"{s}\" (layout)"))
88    } else if let Some(obj) = v.as_object() {
89        let mut parts: Vec<String> = Vec::new();
90        for key in ["bar", "spark", "text"] {
91            if let Some(t) = obj.get(key).and_then(|x| x.as_str()) {
92                parts.push(format!("{key} \"{t}\""));
93            }
94        }
95        match obj.get("final") {
96            Some(f) if f.is_string() => {
97                parts.push(format!("final \"{}\"", f.as_str().unwrap_or("")))
98            }
99            Some(f) if f.is_object() => parts.push("final {…}".to_string()),
100            _ => {}
101        }
102        if parts.is_empty() {
103            None
104        } else {
105            Some(format!("gutter: {}", parts.join(" / ")))
106        }
107    } else {
108        None
109    }
110}
111
112inventory::submit! {
113    WrapperRegistration {
114        name: NAME,
115        // `gutter:` is the sole discriminant — layout string or
116        // `{bar}` / `{spark}` map. Like memo, publication is
117        // independent of every other wrapper's behaviour: it sees
118        // the same wires and writes to its own atomic slot.
119        owned_fields: &["gutter"],
120        triggers,
121        requires_inner: &[super::traverse::NAME],
122        forbids_outer: &[],
123        mutually_exclusive_with: &[],
124        describe_assignment,
125        levels: &[crate::wrapper_registry::WrapperLevel::Op],
126    }
127}
128
129/// The parsed declaration — which cell shape the template feeds.
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum GutterKind {
132    Text,
133    Bar,
134    Spark,
135    /// Key metric: label and value, carried through the template pipeline as a
136    /// single string joined by US (unit separator) so interpolation of both
137    /// halves happens in one pass, then split at render time.
138    Labeled,
139}
140
141/// Parse an op's `gutter:` param value into its `(during, final)`
142/// template forms. Shared by the wrapper-cascade arm (which builds
143/// the [`GutterDispenser`] and stores the specs on the activity) and
144/// the poll wrapper (which re-publishes the during form per poll
145/// iteration so a single long drain op keeps a live cell).
146pub fn parse_specs(
147    v: Option<&serde_json::Value>,
148) -> (Option<(GutterKind, String)>, Option<(GutterKind, String)>) {
149    let parse_forms =
150        |obj: &serde_json::Map<String, serde_json::Value>| -> Option<(GutterKind, String)> {
151            if let Some(m) = obj.get("labeled").and_then(|x| x.as_object()) {
152                let name = m
153                    .get("name")
154                    .and_then(|x| x.as_str())
155                    .unwrap_or("")
156                    .to_string();
157                let value = m
158                    .get("value")
159                    .and_then(|x| x.as_str())
160                    .unwrap_or("")
161                    .to_string();
162                return Some((GutterKind::Labeled, format!("{name}{}{value}", '\u{1f}')));
163            }
164            if let Some(t) = obj.get("bar").and_then(|x| x.as_str()) {
165                Some((GutterKind::Bar, t.to_string()))
166            } else if let Some(t) = obj.get("spark").and_then(|x| x.as_str()) {
167                Some((GutterKind::Spark, t.to_string()))
168            } else {
169                obj.get("text")
170                    .and_then(|x| x.as_str())
171                    .map(|t| (GutterKind::Text, t.to_string()))
172            }
173        };
174    match v {
175        Some(serde_json::Value::String(s)) if !s.is_empty() => {
176            (Some((GutterKind::Text, s.clone())), None)
177        }
178        Some(serde_json::Value::Object(obj)) => {
179            let during = parse_forms(obj);
180            let fin = match obj.get("final") {
181                Some(serde_json::Value::String(s)) if !s.is_empty() => {
182                    Some((GutterKind::Text, s.clone()))
183                }
184                Some(serde_json::Value::Object(fobj)) => parse_forms(fobj),
185                _ => None,
186            };
187            (during, fin)
188        }
189        _ => (None, None),
190    }
191}
192
193/// Render one gutter template against the wires into its publishable
194/// spec. `None` on substitution failure or (for the numeric kinds) a
195/// non-numeric render — callers leave the last good value in place.
196pub(crate) fn render_spec(
197    kind: GutterKind,
198    template: &str,
199    wires: &dyn crate::wires::WireSource,
200) -> Option<GutterSpec> {
201    let rendered = match crate::wires::substitute_via_wires(template, wires) {
202        Ok(s) => s,
203        Err(e) => {
204            crate::diag!(
205                crate::observer::LogLevel::Debug,
206                "gutter: substitution failed for '{template}': {e}"
207            );
208            return None;
209        }
210    };
211    match kind {
212        GutterKind::Labeled => {
213            let (name, value) = rendered
214                .split_once('\u{1f}')
215                .unwrap_or(("", rendered.as_str()));
216            Some(GutterSpec::Labeled {
217                name: name.to_string(),
218                value: value.to_string(),
219            })
220        }
221        GutterKind::Text => Some(GutterSpec::Text(rendered)),
222        GutterKind::Bar | GutterKind::Spark => match rendered.trim().parse::<f64>() {
223            Ok(v) if kind == GutterKind::Bar => Some(GutterSpec::Bar(v.clamp(0.0, 1.0))),
224            Ok(v) => Some(GutterSpec::Spark(v)),
225            Err(_) => {
226                crate::diag!(
227                    crate::observer::LogLevel::Debug,
228                    "gutter: '{template}' rendered to non-numeric '{rendered}'"
229                );
230                None
231            }
232        },
233    }
234}
235
236/// Op-wrapper publishing the phase's gutter-cell spec after each
237/// successful inner op (post-op, so captures from THIS execution
238/// are on the wires — the cell reflects measured state).
239///
240/// No-op on inner errors; substitution or parse failures degrade
241/// to a debug log — the gutter must never fail an otherwise-good
242/// op. Numeric kinds (`bar` / `spark`) additionally require the
243/// rendered string to parse as `f64`.
244pub struct GutterDispenser {
245    inner: Arc<dyn OpDispenser>,
246    kind: GutterKind,
247    template: String,
248    /// Shared slot owned by the activity (see `Activity::gutter`).
249    /// Writes here are visible to the display fold without a
250    /// separate channel.
251    gutter_state: Arc<arc_swap::ArcSwapOption<GutterSpec>>,
252}
253
254impl GutterDispenser {
255    pub fn wrap(
256        inner: Arc<dyn OpDispenser>,
257        kind: GutterKind,
258        template: String,
259        gutter_state: Arc<arc_swap::ArcSwapOption<GutterSpec>>,
260    ) -> Arc<dyn OpDispenser> {
261        Arc::new(Self {
262            inner,
263            kind,
264            template,
265            gutter_state,
266        })
267    }
268
269    fn publish(&self, wires: &dyn crate::wires::WireSource) {
270        if let Some(spec) = render_spec(self.kind, &self.template, wires) {
271            self.gutter_state.store(Some(Arc::new(spec)));
272        }
273    }
274}
275
276impl WrappingDispenser for GutterDispenser {}
277
278impl OpDispenser for GutterDispenser {
279    fn execute<'a>(
280        &'a self,
281        cycle: u64,
282        ctx: &'a crate::fixture::ExecCtx<'a>,
283    ) -> std::pin::Pin<
284        Box<dyn std::future::Future<Output = Result<OpResult, ExecutionError>> + Send + 'a>,
285    > {
286        Box::pin(async move {
287            let result = self.inner.execute(cycle, ctx).await?;
288            self.publish(ctx.wires);
289            Ok(result)
290        })
291    }
292
293    fn inner_dispenser(&self) -> Option<&dyn OpDispenser> {
294        Some(self.inner.as_ref())
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use crate::adapter::{AdapterError, ExecutionError, OpResult};
302    use crate::fixture::{ExecCtx, ResolvedPulls};
303
304    struct FakeInner {
305        error: Option<&'static str>,
306    }
307
308    impl OpDispenser for FakeInner {
309        fn execute<'a>(
310            &'a self,
311            _cycle: u64,
312            _ctx: &'a ExecCtx<'a>,
313        ) -> std::pin::Pin<
314            Box<dyn std::future::Future<Output = Result<OpResult, ExecutionError>> + Send + 'a>,
315        > {
316            Box::pin(async move {
317                if let Some(msg) = self.error {
318                    return Err(ExecutionError::Op(AdapterError {
319                        error_name: "test".into(),
320                        message: msg.into(),
321                        retryable: false,
322                    }));
323                }
324                Ok(OpResult {
325                    body: None,
326                    skipped: false,
327                })
328            })
329        }
330    }
331
332    fn empty_ctx() -> (crate::adapter::ResolvedFields, ResolvedPulls) {
333        let fields = crate::adapter::ResolvedFields::new(vec![], vec![]);
334        let pulls = ResolvedPulls::empty();
335        (fields, pulls)
336    }
337
338    #[tokio::test]
339    async fn text_form_publishes_rendered_layout() {
340        let state = Arc::new(arc_swap::ArcSwapOption::empty());
341        let inner = Arc::new(FakeInner { error: None });
342        let d = GutterDispenser::wrap(
343            inner,
344            GutterKind::Text,
345            "queue depth ok".into(),
346            state.clone(),
347        );
348        let (fields, pulls) = empty_ctx();
349        let ctx = ExecCtx::new(&fields, &pulls);
350        let _ = d.execute(0, &ctx).await.expect("inner ok");
351        assert_eq!(
352            state.load().as_deref(),
353            Some(&GutterSpec::Text("queue depth ok".into()))
354        );
355    }
356
357    #[tokio::test]
358    async fn bar_form_parses_and_clamps_fraction() {
359        let state = Arc::new(arc_swap::ArcSwapOption::empty());
360        let inner = Arc::new(FakeInner { error: None });
361        let d = GutterDispenser::wrap(inner, GutterKind::Bar, "1.7".into(), state.clone());
362        let (fields, pulls) = empty_ctx();
363        let ctx = ExecCtx::new(&fields, &pulls);
364        let _ = d.execute(0, &ctx).await.expect("inner ok");
365        assert_eq!(
366            state.load().as_deref(),
367            Some(&GutterSpec::Bar(1.0)),
368            "fraction must clamp to 0..=1"
369        );
370    }
371
372    #[tokio::test]
373    async fn numeric_parse_failure_leaves_slot_untouched() {
374        let state: Arc<arc_swap::ArcSwapOption<GutterSpec>> =
375            Arc::new(arc_swap::ArcSwapOption::empty());
376        state.store(Some(Arc::new(GutterSpec::Spark(3.0))));
377        let inner = Arc::new(FakeInner { error: None });
378        let d = GutterDispenser::wrap(
379            inner,
380            GutterKind::Spark,
381            "not-a-number".into(),
382            state.clone(),
383        );
384        let (fields, pulls) = empty_ctx();
385        let ctx = ExecCtx::new(&fields, &pulls);
386        let _ = d.execute(0, &ctx).await.expect("inner ok");
387        assert_eq!(
388            state.load().as_deref(),
389            Some(&GutterSpec::Spark(3.0)),
390            "unparseable render must not clobber the last good value"
391        );
392    }
393
394    #[tokio::test]
395    async fn does_not_publish_on_inner_error() {
396        let state: Arc<arc_swap::ArcSwapOption<GutterSpec>> =
397            Arc::new(arc_swap::ArcSwapOption::empty());
398        let inner = Arc::new(FakeInner {
399            error: Some("boom"),
400        });
401        let d = GutterDispenser::wrap(inner, GutterKind::Text, "never".into(), state.clone());
402        let (fields, pulls) = empty_ctx();
403        let ctx = ExecCtx::new(&fields, &pulls);
404        assert!(d.execute(0, &ctx).await.is_err());
405        assert!(
406            state.load().is_none(),
407            "gutter must not publish on inner error"
408        );
409    }
410}