Skip to main content

majordomus_cli/bench/
projection.rs

1//! Targets from the registry. Nothing here names a capability: every target is an entry
2//! with a required benchmark policy, crossed with the transports its exposure declares
3//! and the cases its input type provides, plus the system targets, plus every page of
4//! the UI: each screen and panel the UI's own route list declares (`ui::SCREENS`,
5//! `ui::PANELS`, the lists its dispatcher is held to), over the pinned context a one-shot
6//! process holds and, for the screens, over the live view a served process follows the
7//! repository with.
8
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13use crate::capability::{CachePolicy, CapabilityKind, CapabilityRegistry, CaseContext, Context};
14
15use super::system::SystemTarget;
16
17/// The way a target is reached.
18#[derive(
19    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
20)]
21#[serde(rename_all = "snake_case")]
22pub enum Transport {
23    /// The executor, in process: the handler's own cost.
24    Direct,
25    /// A real `majordomus mcp` child process over stdio.
26    Mcp,
27    /// A real loopback socket.
28    Http,
29}
30
31impl Transport {
32    /// Every transport, in order.
33    pub const ALL: [Transport; 3] = [Transport::Direct, Transport::Mcp, Transport::Http];
34
35    /// The name as serialised.
36    pub fn name(self) -> &'static str {
37        match self {
38            Transport::Direct => "direct",
39            Transport::Mcp => "mcp",
40            Transport::Http => "http",
41        }
42    }
43
44    /// The transport for a name.
45    pub fn parse(text: &str) -> Option<Self> {
46        match text {
47            "direct" => Some(Transport::Direct),
48            "mcp" => Some(Transport::Mcp),
49            "http" => Some(Transport::Http),
50            _ => None,
51        }
52    }
53}
54
55/// Which reading of the repository a page request is answered from.
56///
57/// ```
58/// use majordomus_cli::bench::View;
59/// assert_ne!(View::Pinned, View::Live, "a served process pays what a pinned one does not");
60/// ```
61#[derive(
62    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
63)]
64#[serde(rename_all = "snake_case")]
65pub enum View {
66    /// One context, never re-read: what a benchmark or a one-shot process holds.
67    Pinned,
68    /// The live view `serve` answers from: every request stamps the watched git control
69    /// files, and a moved stamp reloads the layer before the answer.
70    Live,
71}
72
73impl View {
74    /// The view's name as a result document serialises it and as the generated target
75    /// list prints it: `pinned` or `live`.
76    ///
77    /// ```
78    /// use majordomus_cli::bench::View;
79    /// assert_eq!(View::Pinned.name(), "pinned");
80    /// assert_eq!(View::Live.name(), "live");
81    /// ```
82    pub fn name(self) -> &'static str {
83        match self {
84            View::Pinned => "pinned",
85            View::Live => "live",
86        }
87    }
88}
89
90/// What a target is. A data enum: the capability variant carries the case input and is
91/// the common one, so it is not boxed for the sake of the small system variant.
92#[allow(clippy::large_enum_variant)]
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
94#[serde(tag = "kind", rename_all = "snake_case")]
95pub enum TargetKind {
96    /// One capability, one transport, one case.
97    Capability {
98        /// The canonical id.
99        id: String,
100        /// The module.
101        module: String,
102        /// The transport.
103        transport: Transport,
104        /// The case name, from the input type.
105        case: String,
106        /// The case input, as every runner serialises it.
107        input: Value,
108        /// The cache policy the executor applies; cold and warm are measured when it is enabled.
109        cache: CachePolicy,
110        /// The MCP tool name, when the transport is MCP.
111        #[serde(skip_serializing_if = "Option::is_none")]
112        tool: Option<String>,
113        /// The HTTP method and path, when the transport is HTTP.
114        #[serde(skip_serializing_if = "Option::is_none")]
115        route: Option<(String, String)>,
116    },
117    /// A transport's own operation.
118    System {
119        /// Which one.
120        target: SystemTarget,
121    },
122    /// One page of the UI, read with `GET` and `Accept: text/html` over a real socket.
123    Page {
124        /// The route, from the UI's own route list.
125        route: String,
126        /// The reading of the repository it is answered from.
127        view: View,
128        /// A panel: a route the UI defers, because the source it waits on is too slow for
129        /// a screen to wait for. It is measured only by a profile that admits deferred
130        /// panels (`full`), with fewer samples, so that it cannot dominate a quick run.
131        deferred: bool,
132    },
133}
134
135/// One thing to time.
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
137pub struct BenchmarkTarget {
138    /// The stable key: `<id>|<transport>|<case>` for a capability, `system.<transport>.<name>` otherwise.
139    pub key: String,
140    /// What it is.
141    pub kind: TargetKind,
142}
143
144impl BenchmarkTarget {
145    /// The transport.
146    pub fn transport(&self) -> Transport {
147        match &self.kind {
148            TargetKind::Capability { transport, .. } => *transport,
149            TargetKind::System { target } => target.transport(),
150            TargetKind::Page { .. } => Transport::Http,
151        }
152    }
153
154    /// The capability id, for a capability target.
155    pub fn capability_id(&self) -> Option<&str> {
156        match &self.kind {
157            TargetKind::Capability { id, .. } => Some(id),
158            TargetKind::System { .. } | TargetKind::Page { .. } => None,
159        }
160    }
161
162    /// Is this a panel the UI defers?
163    ///
164    /// ```
165    /// use majordomus_cli::bench::{BenchmarkTarget, TargetKind, View};
166    /// let page = |deferred| BenchmarkTarget {
167    ///     key: "system.http.ui.panels.drain".into(),
168    ///     kind: TargetKind::Page {
169    ///         route: "/ui/panels/drain".into(),
170    ///         view: View::Pinned,
171    ///         deferred,
172    ///     },
173    /// };
174    /// assert!(page(true).is_deferred());
175    /// assert!(!page(false).is_deferred());
176    /// ```
177    pub fn is_deferred(&self) -> bool {
178        matches!(self.kind, TargetKind::Page { deferred: true, .. })
179    }
180}
181
182/// Every page of the UI as a target: each screen over the pinned context and over the live
183/// view, each panel over the pinned context only (what following git costs is the same
184/// stamp on every route, and the screens measure it), deferred.
185fn ui_pages() -> Vec<BenchmarkTarget> {
186    let page = |route: &str, view: View, deferred: bool| BenchmarkTarget {
187        key: page_key(route, view),
188        kind: TargetKind::Page {
189            route: route.to_string(),
190            view,
191            deferred,
192        },
193    };
194    let mut pages = Vec::new();
195    for view in [View::Pinned, View::Live] {
196        pages.extend(
197            crate::ui::SCREENS
198                .iter()
199                .map(|screen| page(screen.route, view, false)),
200        );
201    }
202    pages.extend(
203        crate::ui::PANELS
204            .iter()
205            .map(|route| page(route, View::Pinned, true)),
206    );
207    pages
208}
209
210/// `system.http.ui.<route>` over the pinned context, `system.http.live.ui.<route>` over
211/// the live view; the route's segments below the prefix joined by dots, `index` for the
212/// prefix itself.
213///
214/// ```text
215/// /ui                        -> system.http.ui.index
216/// /ui/panels/pull-requests   -> system.http.ui.panels.pull_requests
217/// ```
218fn page_key(route: &str, view: View) -> String {
219    let below = route
220        .strip_prefix(crate::ui::PREFIX)
221        .unwrap_or(route)
222        .trim_matches('/');
223    let name = if below.is_empty() {
224        "index".to_string()
225    } else {
226        below.replace('/', ".").replace('-', "_")
227    };
228    match view {
229        View::Pinned => format!("system.http.ui.{name}"),
230        View::Live => format!("system.http.live.ui.{name}"),
231    }
232}
233
234/// Every target of a registry against one repository.
235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
236pub struct BenchmarkProjection {
237    /// The targets, capabilities first (by id, transport, case), then the system ones,
238    /// then the UI's pages.
239    pub targets: Vec<BenchmarkTarget>,
240}
241
242impl BenchmarkProjection {
243    /// Derive the targets: the cases need the index, so the projection is of a context.
244    pub fn from_context(ctx: &Context) -> Self {
245        let registry: &CapabilityRegistry = &ctx.registry;
246        let case_ctx = CaseContext { index: &ctx.index };
247        let mut targets = Vec::new();
248        for c in registry.iter() {
249            if !c.kind.is_executable() || !c.stability.executable() {
250                continue;
251            }
252            if !c.benchmark.is_target() {
253                continue;
254            }
255            let Some(provider) = registry.cases(c.id.as_str()) else {
256                continue;
257            };
258            let cases = provider(&case_ctx);
259            for transport in Transport::ALL {
260                let tool = c.exposure.mcp.as_ref().and_then(|m| m.tool.clone());
261                let route = c
262                    .exposure
263                    .http
264                    .as_ref()
265                    .map(|h| (h.method.as_str().to_string(), h.path.clone()));
266                let exposed = match transport {
267                    Transport::Direct => true,
268                    Transport::Mcp => tool.is_some(),
269                    Transport::Http => route.is_some(),
270                };
271                if !exposed {
272                    continue;
273                }
274                for case in &cases {
275                    targets.push(BenchmarkTarget {
276                        key: format!("{}|{}|{}", c.id, transport.name(), case.name),
277                        kind: TargetKind::Capability {
278                            id: c.id.to_string(),
279                            module: c.module.as_str().to_string(),
280                            transport,
281                            case: case.name.to_string(),
282                            input: case.input.clone(),
283                            cache: c.cache,
284                            tool: (transport == Transport::Mcp)
285                                .then(|| tool.clone())
286                                .flatten(),
287                            route: (transport == Transport::Http)
288                                .then(|| route.clone())
289                                .flatten(),
290                        },
291                    });
292                }
293            }
294        }
295        for s in SystemTarget::ALL {
296            targets.push(BenchmarkTarget {
297                key: s.key().to_string(),
298                kind: TargetKind::System { target: s },
299            });
300        }
301        targets.extend(ui_pages());
302        BenchmarkProjection { targets }
303    }
304
305    /// The UI's pages, in the order the targets list them.
306    ///
307    /// ```
308    /// use majordomus_cli::bench::{BenchmarkProjection, TargetKind};
309    /// # fn example(ctx: &majordomus_cli::capability::Context) {
310    /// let projection = BenchmarkProjection::from_context(ctx);
311    /// assert!(projection.pages().all(|t| matches!(t.kind, TargetKind::Page { .. })));
312    /// assert!(projection.pages().any(|t| t.key == "system.http.live.ui.index"));
313    /// # }
314    /// ```
315    pub fn pages(&self) -> impl Iterator<Item = &BenchmarkTarget> {
316        self.targets
317            .iter()
318            .filter(|t| matches!(t.kind, TargetKind::Page { .. }))
319    }
320
321    /// The targets of one transport.
322    pub fn by_transport(&self, transport: Transport) -> impl Iterator<Item = &BenchmarkTarget> {
323        self.targets
324            .iter()
325            .filter(move |t| t.transport() == transport)
326    }
327
328    /// The targets of one capability.
329    pub fn of_capability<'a>(&'a self, id: &'a str) -> impl Iterator<Item = &'a BenchmarkTarget> {
330        self.targets
331            .iter()
332            .filter(move |t| t.capability_id() == Some(id))
333    }
334
335    /// Is a capability exposed on a transport a target there? Coverage asks this.
336    pub fn covers(&self, id: &str, transport: Transport) -> bool {
337        self.of_capability(id).any(|t| t.transport() == transport)
338    }
339
340    /// Does the kind of a target make sense for a command? Commands are targets like
341    /// queries; their side effect is this process's memory.
342    pub fn is_command(registry: &CapabilityRegistry, id: &str) -> bool {
343        registry
344            .get(id)
345            .is_some_and(|c| c.kind == CapabilityKind::Command)
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    /// The pages are the UI's own route list, read and never repeated: every screen over
354    /// both views, every panel deferred over the pinned context, and one key per page.
355    #[test]
356    fn every_ui_screen_and_panel_is_a_page_target_and_only_they_are() {
357        let pages = ui_pages();
358        for screen in crate::ui::SCREENS {
359            for view in [View::Pinned, View::Live] {
360                assert!(
361                    pages.iter().any(|t| t.kind
362                        == TargetKind::Page {
363                            route: screen.route.to_string(),
364                            view,
365                            deferred: false,
366                        }),
367                    "{} over {}",
368                    screen.route,
369                    view.name()
370                );
371            }
372        }
373        for panel in crate::ui::PANELS {
374            let of: Vec<_> = pages
375                .iter()
376                .filter(|t| matches!(&t.kind, TargetKind::Page { route, .. } if route == panel))
377                .collect();
378            assert_eq!(of.len(), 1, "{panel}: once, pinned");
379            assert!(of[0].is_deferred(), "{panel} is deferred");
380        }
381        assert_eq!(
382            pages.len(),
383            2 * crate::ui::SCREENS.len() + crate::ui::PANELS.len()
384        );
385        let mut keys: Vec<&str> = pages.iter().map(|t| t.key.as_str()).collect();
386        keys.sort_unstable();
387        keys.dedup();
388        assert_eq!(keys.len(), pages.len(), "a key per page");
389        assert!(pages.iter().all(|t| t.transport() == Transport::Http));
390        assert!(pages.iter().all(|t| t.capability_id().is_none()));
391    }
392
393    #[test]
394    fn a_page_key_names_its_view_and_its_route() {
395        assert_eq!(page_key("/ui", View::Pinned), "system.http.ui.index");
396        assert_eq!(page_key("/ui/", View::Live), "system.http.live.ui.index");
397        assert_eq!(
398            page_key("/ui/panels/pull-requests", View::Pinned),
399            "system.http.ui.panels.pull_requests"
400        );
401        assert_eq!(
402            page_key("/ui/roadmap", View::Live),
403            "system.http.live.ui.roadmap"
404        );
405    }
406}