Skip to main content

nmbrs_runtime/
report_anchor.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Anchor resolution for `nmbrs report --add` (SRD-64 §6.1).
5//!
6//! Decides where in the workload YAML a promoted report item
7//! should live: workload root, a named scenario / phase /
8//! op-template, or — when `--contextual <mode>` is used — a
9//! scope derived from the session's emitted data.
10//!
11//! ## Inputs
12//!
13//! - The active session's `metrics.db` (label vocabularies +
14//!   `session_metadata` rows).
15//! - The [`nmbrs_workload::report::ReportItem`] the user is
16//!   promoting, with its `where` / `by` filter clauses
17//!   parsed out.
18//! - The CLI anchor flag — none / `--at` / `--contextual`.
19//!
20//! ## Output
21//!
22//! A [`nmbrs_workload::edit::Anchor`] plus a human-readable
23//! diagnostic string the dispatcher prints before the
24//! YAML write.
25//!
26//! ## Levels supported today
27//!
28//! | Level    | Source                       | Phase D status |
29//! |----------|------------------------------|----------------|
30//! | root     | always available             | shipped        |
31//! | scenario | `session_metadata.scenario`  | shipped        |
32//! | phase    | `label_key.key='phase'`      | shipped        |
33//! | op       | requires schema extension    | hard error     |
34//!
35//! Op-template anchoring needs an `op_template` label key
36//! that the runtime doesn't currently emit. When it does,
37//! adding it here is one match arm + one query.
38
39use std::collections::BTreeSet;
40use std::path::Path;
41
42use nmbrs_workload::edit::Anchor;
43use nmbrs_workload::report::ReportItem;
44
45/// CLI anchor flag, pre-parsed from the `--at` / `--contextual`
46/// command-line surface. Mirrors the SRD-64 §6.1 grammar.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum AnchorFlag {
49    /// Bare `--add` — root anchor, no data inspection.
50    None,
51    /// `--at root`.
52    AtRoot,
53    /// `--at scenario:<name>`.
54    AtScenario(String),
55    /// `--at phase:<name>`.
56    AtPhase(String),
57    /// `--at op:<phase>.<op>`.
58    AtOp { phase: String, op: String },
59    /// `--contextual auto` — walk the data to derive the
60    /// deepest unique level.
61    ContextualAuto,
62    /// `--contextual root` — explicit root, equivalent to
63    /// `--at root` but routes through the data-inspection
64    /// path so `--dry-run` shows the same diagnostic shape.
65    ContextualRoot,
66    /// `--contextual scenario` — error if data spans
67    /// multiple scenarios.
68    ContextualScenario,
69    /// `--contextual phase` — error if data spans multiple
70    /// phases.
71    ContextualPhase,
72    /// `--contextual op` — error today (schema gap).
73    ContextualOp,
74}
75
76impl AnchorFlag {
77    /// Parse the `--at <scope>` value form. Returns `None` for
78    /// unrecognised shapes.
79    pub fn parse_at(value: &str) -> Result<AnchorFlag, String> {
80        match value {
81            "root" => Ok(AnchorFlag::AtRoot),
82            v if v.starts_with("scenario:") => {
83                let name = v.trim_start_matches("scenario:").trim();
84                if name.is_empty() {
85                    Err("--at scenario: requires a scenario name".to_string())
86                } else {
87                    Ok(AnchorFlag::AtScenario(name.to_string()))
88                }
89            }
90            v if v.starts_with("phase:") => {
91                let name = v.trim_start_matches("phase:").trim();
92                if name.is_empty() {
93                    Err("--at phase: requires a phase name".to_string())
94                } else {
95                    Ok(AnchorFlag::AtPhase(name.to_string()))
96                }
97            }
98            v if v.starts_with("op:") => {
99                let body = v.trim_start_matches("op:").trim();
100                let (phase, op) = body
101                    .split_once('.')
102                    .ok_or_else(|| format!("--at op:{body}: expected `op:<phase>.<op>`"))?;
103                if phase.is_empty() || op.is_empty() {
104                    return Err(format!(
105                        "--at op:{body}: phase and op names must be non-empty"
106                    ));
107                }
108                Ok(AnchorFlag::AtOp {
109                    phase: phase.to_string(),
110                    op: op.to_string(),
111                })
112            }
113            _ => Err(format!(
114                "--at value '{value}': expected one of \
115                 `root`, `scenario:<name>`, `phase:<name>`, `op:<phase>.<op>`"
116            )),
117        }
118    }
119
120    /// Parse the `--contextual <mode>` value form.
121    pub fn parse_contextual(value: &str) -> Result<AnchorFlag, String> {
122        match value {
123            "auto" => Ok(AnchorFlag::ContextualAuto),
124            "root" => Ok(AnchorFlag::ContextualRoot),
125            "scenario" => Ok(AnchorFlag::ContextualScenario),
126            "phase" => Ok(AnchorFlag::ContextualPhase),
127            "op" => Ok(AnchorFlag::ContextualOp),
128            _ => Err(format!(
129                "--contextual value '{value}': expected one of \
130                 `auto`, `root`, `scenario`, `phase`, `op`"
131            )),
132        }
133    }
134}
135
136/// Resolved anchor + a diagnostic line describing how the
137/// resolver decided.
138#[derive(Debug, Clone)]
139pub struct AnchorResolution {
140    pub anchor: Anchor,
141    pub diagnostic: String,
142}
143
144/// Resolve an anchor for `item` in `db_path`'s session,
145/// honouring `flag`. Returns the chosen anchor + a one-line
146/// diagnostic the dispatcher prints before writing.
147pub fn resolve(
148    db_path: &Path,
149    item: &ReportItem,
150    flag: &AnchorFlag,
151) -> Result<AnchorResolution, String> {
152    // `--at` forms are direct: no data inspection. They only
153    // need the user-named scope to exist somewhere in the
154    // workload, but that check happens in `edit::add_item`
155    // (which has the parsed workload to walk).
156    match flag {
157        AnchorFlag::None | AnchorFlag::AtRoot => {
158            return Ok(AnchorResolution {
159                anchor: Anchor::Root,
160                diagnostic: "anchor: workload root (default)".to_string(),
161            });
162        }
163        AnchorFlag::AtScenario(name) => {
164            return Ok(AnchorResolution {
165                anchor: Anchor::Scenario(name.clone()),
166                diagnostic: format!("anchor: scenario:{name} (explicit --at)"),
167            });
168        }
169        AnchorFlag::AtPhase(name) => {
170            return Ok(AnchorResolution {
171                anchor: Anchor::Phase(name.clone()),
172                diagnostic: format!("anchor: phase:{name} (explicit --at)"),
173            });
174        }
175        AnchorFlag::AtOp { phase, op } => {
176            return Ok(AnchorResolution {
177                anchor: Anchor::Op {
178                    phase: phase.clone(),
179                    op: op.clone(),
180                },
181                diagnostic: format!("anchor: op:{phase}.{op} (explicit --at)"),
182            });
183        }
184        _ => {}
185    }
186
187    // `--contextual` forms inspect the session db. Open it
188    // once and reuse the connection for both the scenario
189    // and phase queries.
190    let conn = rusqlite::Connection::open(db_path)
191        .map_err(|e| format!("open session db '{}': {e}", db_path.display(),))?;
192
193    let scenarios = scenarios_in_session(&conn)?;
194    let phases = phases_matching_filter(&conn, item)?;
195
196    match flag {
197        AnchorFlag::ContextualRoot => Ok(AnchorResolution {
198            anchor: Anchor::Root,
199            diagnostic: "anchor: workload root (--contextual root)".to_string(),
200        }),
201
202        AnchorFlag::ContextualScenario => match scenarios.len() {
203            0 => Err("no scenario recorded in session metadata; \
204                 cannot anchor at scenario level"
205                .to_string()),
206            1 => {
207                let s = scenarios.iter().next().unwrap().clone();
208                Ok(AnchorResolution {
209                    anchor: Anchor::Scenario(s.clone()),
210                    diagnostic: format!(
211                        "anchor: scenario:{s} (--contextual scenario; \
212                         single scenario in session)"
213                    ),
214                })
215            }
216            _ => Err(format!(
217                "--contextual scenario: session spans multiple scenarios \
218                 ({:?}); pick one with `--at scenario:<name>` or use a \
219                 broader `--contextual root`",
220                scenarios.iter().collect::<Vec<_>>(),
221            )),
222        },
223
224        AnchorFlag::ContextualPhase => match phases.len() {
225            0 => Err("no phases in session match the item's filter; \
226                 cannot anchor at phase level"
227                .to_string()),
228            1 => {
229                let p = phases.iter().next().unwrap().clone();
230                Ok(AnchorResolution {
231                    anchor: Anchor::Phase(p.clone()),
232                    diagnostic: format!(
233                        "anchor: phase:{p} (--contextual phase; \
234                         single phase matched filter)"
235                    ),
236                })
237            }
238            _ => Err(format!(
239                "--contextual phase: filter matches multiple phases \
240                 ({phases:?}); add a `where phase=<name>` filter to \
241                 narrow it, or use `--at phase:<name>` to pick one",
242            )),
243        },
244
245        AnchorFlag::ContextualOp => Err("--contextual op: op-template anchoring needs an \
246             `op_template` label key that the runtime doesn't \
247             emit yet; this is a planned schema extension. Use \
248             `--contextual phase` for now, or `--at op:<phase>.<op>` \
249             to anchor explicitly (the YAML edit primitive accepts \
250             the path)."
251            .to_string()),
252
253        AnchorFlag::ContextualAuto => {
254            // Deepest unique scope:
255            // - 1 phase + 1 scenario → phase anchor
256            // - >1 phase, 1 scenario → scenario anchor
257            // - >1 scenario → root
258            match (scenarios.len(), phases.len()) {
259                (1, 1) => {
260                    let p = phases.iter().next().unwrap().clone();
261                    Ok(AnchorResolution {
262                        anchor: Anchor::Phase(p.clone()),
263                        diagnostic: format!(
264                            "anchor: phase:{p} (--contextual auto; \
265                             unique phase under one scenario)"
266                        ),
267                    })
268                }
269                (1, _) => {
270                    let s = scenarios.iter().next().unwrap().clone();
271                    Ok(AnchorResolution {
272                        anchor: Anchor::Scenario(s.clone()),
273                        diagnostic: format!(
274                            "anchor: scenario:{s} (--contextual auto; \
275                             one scenario, multiple phases)"
276                        ),
277                    })
278                }
279                _ => Ok(AnchorResolution {
280                    anchor: Anchor::Root,
281                    diagnostic: "anchor: workload root (--contextual auto; \
282                         data spans multiple scenarios)"
283                        .to_string(),
284                }),
285            }
286        }
287
288        _ => unreachable!("--at branches handled above"),
289    }
290}
291
292/// Distinct scenario names recorded in the session.
293/// Today the runtime only writes one `scenario` row per
294/// session, so this returns at most one element.
295fn scenarios_in_session(conn: &rusqlite::Connection) -> Result<BTreeSet<String>, String> {
296    // `scenario` is per-execution metadata; read the latest
297    // execution's (falls back to legacy session_metadata).
298    let mut out = BTreeSet::new();
299    if let Some(v) =
300        nmbrs_metrics::reporters::sqlite::latest_execution_metadata_value(conn, "scenario")
301    {
302        out.insert(v);
303    }
304    Ok(out)
305}
306
307/// Distinct `phase` label values that appear in the session
308/// among samples whose label-set matches `item`'s `where`
309/// filter clauses (if any). Today's behaviour: ignore the
310/// filter and return every phase that emitted any sample.
311/// The filter-aware query lands when the metricsql evaluator
312/// gains a session-db backend (SRD-47 storage trait); for
313/// Phase D this looser query is enough to drive the
314/// anchor-walk decision tree, since most workloads have a
315/// small phase fan-out and the user's filter would only ever
316/// narrow the set.
317fn phases_matching_filter(
318    conn: &rusqlite::Connection,
319    item: &ReportItem,
320) -> Result<BTreeSet<String>, String> {
321    let _ = item; // filter awareness deferred — see doc above.
322    let mut out = BTreeSet::new();
323    let mut stmt = conn
324        .prepare("SELECT DISTINCT value FROM instance_label WHERE key = 'phase'")
325        .map_err(|e| format!("phase distinct query: {e}"))?;
326    let rows = stmt
327        .query_map([], |r| r.get::<_, String>(0))
328        .map_err(|e| format!("phase rows: {e}"))?;
329    for row in rows {
330        let v = row.map_err(|e| format!("phase row decode: {e}"))?;
331        out.insert(v);
332    }
333    Ok(out)
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use nmbrs_workload::report::{Kind, ReportItem};
340
341    fn item() -> ReportItem {
342        ReportItem {
343            kind: Kind::Plot,
344            name: "demo".to_string(),
345            body: "over cycle".to_string(),
346            ..Default::default()
347        }
348    }
349
350    fn make_db(label: &str, scenarios: &[&str], phases: &[&str]) -> std::path::PathBuf {
351        let dir = std::env::temp_dir().join(format!("nmbrs-anchor-{label}-{}", std::process::id()));
352        let _ = std::fs::remove_dir_all(&dir);
353        std::fs::create_dir_all(&dir).unwrap();
354        let path = dir.join("metrics.db");
355
356        let conn = rusqlite::Connection::open(&path).unwrap();
357        // Minimal schema mirroring the runtime — enough rows
358        // to drive the anchor queries. Post-cutover: denormalised
359        // `instance_label` table holds `(instance_id, key, value)`
360        // directly (no `label_set` indirection).
361        conn.execute_batch(
362            r#"
363            CREATE TABLE session_metadata (key TEXT, value TEXT);
364            CREATE TABLE instance_label (
365                instance_id INTEGER NOT NULL,
366                key TEXT NOT NULL,
367                value TEXT NOT NULL
368            );
369        "#,
370        )
371        .unwrap();
372        for s in scenarios {
373            conn.execute(
374                "INSERT INTO session_metadata (key, value) VALUES ('scenario', ?1)",
375                [s],
376            )
377            .unwrap();
378        }
379        for (i, p) in phases.iter().enumerate() {
380            let id = (i + 1) as i64;
381            conn.execute(
382                "INSERT INTO instance_label (instance_id, key, value) VALUES (?1, 'phase', ?2)",
383                rusqlite::params![id, p],
384            )
385            .unwrap();
386        }
387        path
388    }
389
390    #[test]
391    fn parse_at_recognises_all_scopes() {
392        assert_eq!(AnchorFlag::parse_at("root").unwrap(), AnchorFlag::AtRoot);
393        assert_eq!(
394            AnchorFlag::parse_at("scenario:foo").unwrap(),
395            AnchorFlag::AtScenario("foo".into())
396        );
397        assert_eq!(
398            AnchorFlag::parse_at("phase:setup").unwrap(),
399            AnchorFlag::AtPhase("setup".into())
400        );
401        assert_eq!(
402            AnchorFlag::parse_at("op:setup.step").unwrap(),
403            AnchorFlag::AtOp {
404                phase: "setup".into(),
405                op: "step".into()
406            }
407        );
408    }
409
410    #[test]
411    fn parse_at_rejects_malformed() {
412        assert!(AnchorFlag::parse_at("scenario:").is_err());
413        assert!(AnchorFlag::parse_at("op:setup").is_err());
414        assert!(AnchorFlag::parse_at("op:setup.").is_err());
415        assert!(AnchorFlag::parse_at("nonsense").is_err());
416    }
417
418    #[test]
419    fn parse_contextual_recognises_all_modes() {
420        assert_eq!(
421            AnchorFlag::parse_contextual("auto").unwrap(),
422            AnchorFlag::ContextualAuto
423        );
424        assert_eq!(
425            AnchorFlag::parse_contextual("root").unwrap(),
426            AnchorFlag::ContextualRoot
427        );
428        assert_eq!(
429            AnchorFlag::parse_contextual("phase").unwrap(),
430            AnchorFlag::ContextualPhase
431        );
432        assert!(AnchorFlag::parse_contextual("garbage").is_err());
433    }
434
435    #[test]
436    fn resolve_none_yields_root_without_db_lookup() {
437        // Pass a non-existent db path — should not be opened.
438        let r = resolve(
439            std::path::Path::new("/nonexistent/db"),
440            &item(),
441            &AnchorFlag::None,
442        )
443        .unwrap();
444        assert!(matches!(r.anchor, Anchor::Root));
445        assert!(r.diagnostic.contains("default"));
446    }
447
448    #[test]
449    fn resolve_at_root_yields_root_without_db_lookup() {
450        let r = resolve(
451            std::path::Path::new("/nonexistent/db"),
452            &item(),
453            &AnchorFlag::AtRoot,
454        )
455        .unwrap();
456        assert!(matches!(r.anchor, Anchor::Root));
457    }
458
459    #[test]
460    fn resolve_at_scenario_yields_scenario_without_db_lookup() {
461        let r = resolve(
462            std::path::Path::new("/nonexistent/db"),
463            &item(),
464            &AnchorFlag::AtScenario("foo".into()),
465        )
466        .unwrap();
467        match r.anchor {
468            Anchor::Scenario(s) => assert_eq!(s, "foo"),
469            other => panic!("expected Scenario, got {other:?}"),
470        }
471    }
472
473    #[test]
474    fn resolve_contextual_auto_picks_phase_when_unique() {
475        let db = make_db("auto_phase", &["default"], &["setup"]);
476        let r = resolve(&db, &item(), &AnchorFlag::ContextualAuto).unwrap();
477        match r.anchor {
478            Anchor::Phase(p) => assert_eq!(p, "setup"),
479            other => panic!("expected Phase, got {other:?}"),
480        }
481        assert!(r.diagnostic.contains("phase:setup"));
482    }
483
484    #[test]
485    fn resolve_contextual_auto_picks_scenario_when_phases_branch() {
486        let db = make_db("auto_scenario", &["default"], &["a", "b"]);
487        let r = resolve(&db, &item(), &AnchorFlag::ContextualAuto).unwrap();
488        match r.anchor {
489            Anchor::Scenario(s) => assert_eq!(s, "default"),
490            other => panic!("expected Scenario, got {other:?}"),
491        }
492    }
493
494    #[test]
495    fn resolve_contextual_phase_errors_on_multiple_phases() {
496        let db = make_db("phase_multi", &["default"], &["a", "b"]);
497        let err = resolve(&db, &item(), &AnchorFlag::ContextualPhase).unwrap_err();
498        assert!(err.contains("multiple phases"), "got: {err}");
499        assert!(err.contains("--at phase:"), "should hint at fix: {err}");
500    }
501
502    #[test]
503    fn resolve_contextual_phase_succeeds_on_unique_phase() {
504        let db = make_db("phase_unique", &["default"], &["only"]);
505        let r = resolve(&db, &item(), &AnchorFlag::ContextualPhase).unwrap();
506        match r.anchor {
507            Anchor::Phase(p) => assert_eq!(p, "only"),
508            other => panic!("expected Phase, got {other:?}"),
509        }
510    }
511
512    #[test]
513    fn resolve_contextual_scenario_succeeds_on_single_scenario() {
514        let db = make_db("scen_unique", &["only_scenario"], &[]);
515        let r = resolve(&db, &item(), &AnchorFlag::ContextualScenario).unwrap();
516        match r.anchor {
517            Anchor::Scenario(s) => assert_eq!(s, "only_scenario"),
518            other => panic!("expected Scenario, got {other:?}"),
519        }
520    }
521
522    #[test]
523    fn resolve_contextual_op_errors_with_schema_gap_message() {
524        let db = make_db("op_unsupported", &["default"], &["a"]);
525        let err = resolve(&db, &item(), &AnchorFlag::ContextualOp).unwrap_err();
526        assert!(err.contains("op-template anchoring"));
527        assert!(
528            err.contains("schema extension"),
529            "should call out the schema gap: {err}"
530        );
531        assert!(
532            err.contains("--contextual phase"),
533            "should suggest the available alternative: {err}"
534        );
535    }
536
537    #[test]
538    fn resolve_contextual_root_skips_data_inspection_decisions() {
539        let db = make_db("ctx_root", &["default"], &["a", "b"]);
540        let r = resolve(&db, &item(), &AnchorFlag::ContextualRoot).unwrap();
541        assert!(matches!(r.anchor, Anchor::Root));
542        assert!(r.diagnostic.contains("--contextual root"));
543    }
544}