Skip to main content

graphrefly_graph/
profile.rs

1//! `Graph::resource_profile` — snapshot-based runtime profile per
2//! canonical R3.6.3 (`docs/implementation-plan-13.6-canonical-spec.md:984`).
3//!
4//! D285 (2026-05-24, cross-track-ledger §1 D283 row, lifts D004 R3.6.3
5//! deferral). Mirrors pure-ts `Graph.resourceProfile` /
6//! `graphProfile()` at `packages/pure-ts/src/graph/profile.ts` —
7//! **minus the value-size fields** (`valueSizeBytes` per node,
8//! `totalValueSizeBytes` aggregate, `hotspots.byValueSize`) per the
9//! D284 `Impl`-contract amendment. Rust cache is a `HandleId` (u64);
10//! user values `T` live in the binding-side registry, so a true size
11//! requires a per-node FFI crossing — the amendment closed that field
12//! class instead of forcing the speculative substrate widening (D196,
13//! value-#6 pre-design win). See parity `types.ts` `ImplNodeProfile`
14//! docstring for the canonical rationale.
15//!
16//! # Layering
17//!
18//! Pure read over `&dyn CoreFull` + the `Graph` namespace state — no
19//! Core mutation. Walks `describe_of(core, &inner, None)` for topology
20//! shape (canonical Appendix B JSON form) + `CoreFull::sink_count_of`
21//! (D285 widening) for per-node subscriber counts. Orphan
22//! categorization mirrors pure-ts at `profile.ts:129-138`.
23
24use std::cell::RefCell;
25use std::rc::Rc;
26
27use graphrefly_core::CoreFull;
28use serde::Serialize;
29
30use crate::describe::{describe_of, NodeStatus, NodeTypeStr};
31use crate::graph::{Graph, GraphInner};
32
33/// Orphan classification for a [`NodeProfile`]. `None` when the node
34/// has at least one subscriber, or when it is a `state` node (state
35/// has no fn to be "idle" about — excluded by construction at
36/// `profile.ts:129-138`).
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
38#[serde(rename_all = "kebab-case")]
39pub enum OrphanKind {
40    /// Effect node with zero subscribers — classic leak pattern
41    /// (pre-existing class in pure-ts before the broader `idle-*`
42    /// categorization).
43    OrphanEffect,
44    /// Derived node with zero subscribers — wasted compute path if it
45    /// ever activates; often indicates a factory forgot keepalive.
46    IdleDerived,
47    /// Producer node with zero subscribers — no external consumer;
48    /// often an over-eager factory or forgotten cleanup.
49    IdleProducer,
50}
51
52/// Per-node profile entry. Mirrors the post-D284 narrower
53/// [`packages/parity-tests/impls/types.ts`](../../../../../../graphrefly-ts/packages/parity-tests/impls/types.ts)
54/// `ImplNodeProfile` shape exactly (no `valueSizeBytes`).
55///
56/// JSON field names are camelCase to match the cross-arm parity wire
57/// contract (`subscriberCount` / `depCount` / `isOrphanEffect` /
58/// `orphanKind`) — the parity scenarios in
59/// `scenarios/graph/resource-profile.test.ts` assert on those keys.
60#[derive(Debug, Clone, Serialize)]
61#[serde(rename_all = "camelCase")]
62pub struct NodeProfile {
63    /// Qualified path within the graph (local — recursive mount-walking
64    /// is not in scope per R3.6.3's snapshot framing).
65    pub path: String,
66    /// Node type: `"state"` / `"derived"` / `"dynamic"` / `"producer"` /
67    /// `"effect"`.
68    #[serde(rename = "type")]
69    pub r#type: String,
70    /// Lifecycle status as a string (matches canonical Appendix B).
71    pub status: String,
72    /// Number of downstream external subscribers (sinks added via
73    /// `Core::subscribe`). Computed via [`CoreFull::sink_count_of`].
74    pub subscriber_count: usize,
75    /// Number of upstream dependencies (length of the node's `_deps`
76    /// array; recovered from `describe()` output).
77    pub dep_count: usize,
78    /// True if this is an `effect` node with no external subscribers —
79    /// the classic leak pattern (pre-existing pure-ts class kept
80    /// separately from the broader `orphan_kind` categorization for
81    /// back-compat with `orphan_effects` consumers).
82    pub is_orphan_effect: bool,
83    /// Orphan category — `None` when the node has subscribers OR when
84    /// it is a `state` node (state has no fn ⇒ can never be "idle").
85    pub orphan_kind: Option<OrphanKind>,
86}
87
88/// Aggregate profile returned by [`Graph::resource_profile`]. Mirrors
89/// the post-D284 narrower
90/// [`packages/parity-tests/impls/types.ts`](../../../../../../graphrefly-ts/packages/parity-tests/impls/types.ts)
91/// `ImplGraphProfileResult` shape (no `totalValueSizeBytes`, no
92/// `hotspots.byValueSize`). JSON keys are camelCase to match the
93/// cross-arm parity wire contract.
94#[derive(Debug, Clone, Serialize)]
95#[serde(rename_all = "camelCase")]
96pub struct GraphProfileResult {
97    /// Total local node count (NOT recursive into mounted children).
98    pub node_count: usize,
99    /// Total local edge count.
100    pub edge_count: usize,
101    /// Local mounted subgraph count.
102    pub subgraph_count: usize,
103    /// All local node profiles in insertion order.
104    pub nodes: Vec<NodeProfile>,
105    /// Top-N hotspots by dimension. Each list is sorted descending +
106    /// truncated to `top_n` (default 10).
107    pub hotspots: Hotspots,
108    /// Every orphan across types — `effect`, `derived`, `producer`
109    /// with zero subscribers. See [`NodeProfile::orphan_kind`].
110    pub orphans: Vec<NodeProfile>,
111    /// Effect nodes with no external subscribers (legacy field; subset
112    /// of [`Self::orphans`] kept separately for back-compat with the
113    /// pure-ts `orphanEffects` consumer surface).
114    pub orphan_effects: Vec<NodeProfile>,
115}
116
117/// Top-N hotspots returned by [`GraphProfileResult::hotspots`]. D284
118/// dropped the `by_value_size` ranking (see [`crate::profile`] module
119/// docstring for the rationale). JSON keys are camelCase
120/// (`bySubscriberCount` / `byDepCount`) to match the cross-arm parity
121/// wire contract.
122#[derive(Debug, Clone, Serialize)]
123#[serde(rename_all = "camelCase")]
124pub struct Hotspots {
125    /// Top nodes by external subscriber count, descending.
126    pub by_subscriber_count: Vec<NodeProfile>,
127    /// Top nodes by dependency count, descending.
128    pub by_dep_count: Vec<NodeProfile>,
129}
130
131/// Options for [`Graph::resource_profile`].
132#[derive(Debug, Clone, Copy, Default)]
133pub struct GraphProfileOptions {
134    /// Hotspot list cap (default 10 — matches pure-ts).
135    pub top_n: Option<usize>,
136}
137
138impl Graph {
139    /// Snapshot-based runtime profile per canonical R3.6.3 (D285).
140    ///
141    /// Walks the local namespace + mount tree once via
142    /// [`describe_of`], queries `CoreFull::sink_count_of` per local
143    /// node, computes orphan categorization + hotspot rankings. Pure
144    /// read — no Core mutation.
145    ///
146    /// Mirrors the post-D284 narrower [`GraphProfileResult`] shape that
147    /// drops value-size fields (see [`crate::profile`] module
148    /// docstring).
149    #[must_use]
150    pub fn resource_profile(
151        &self,
152        core: &dyn CoreFull,
153        opts: Option<GraphProfileOptions>,
154    ) -> GraphProfileResult {
155        resource_profile_of(core, &self.inner, opts)
156    }
157}
158
159/// Free fn form for the rare case where a caller has a bare
160/// `Rc<RefCell<GraphInner>>` (e.g. a future in-wave defer closure)
161/// rather than a full [`Graph`] handle.
162fn resource_profile_of(
163    core: &dyn CoreFull,
164    inner_arc: &Rc<RefCell<GraphInner>>,
165    opts: Option<GraphProfileOptions>,
166) -> GraphProfileResult {
167    let top_n = opts.and_then(|o| o.top_n).unwrap_or(10);
168
169    // Describe once for topology — local nodes, edges, subgraph names.
170    let desc = describe_of(core, inner_arc, None);
171
172    // Build name → NodeId reverse lookup so we can query
173    // `sink_count_of(id)` per local node. The `describe_of` output
174    // is keyed by name; the NodeIds live in `GraphInner.names`.
175    let names_to_id: Vec<(String, graphrefly_core::NodeId)> = {
176        let inner = inner_arc.borrow_mut();
177        inner.names.iter().map(|(n, id)| (n.clone(), *id)).collect()
178    };
179
180    let mut profiles: Vec<NodeProfile> = Vec::with_capacity(names_to_id.len());
181
182    for (path, node_id) in &names_to_id {
183        // describe()'s `_anon_<id>` fallback path: a name that exists
184        // in `names` but somehow missed describe — skip. Invariant-
185        // unreachable for the post-D279 describe shape.
186        let Some(node_desc) = desc.nodes.get(path) else {
187            continue;
188        };
189
190        let type_str = node_type_str(node_desc.r#type);
191        let status_str = node_status_str(node_desc.status);
192        let subscriber_count = core.sink_count_of(*node_id);
193        let dep_count = node_desc.deps.len();
194
195        let is_orphan_effect = type_str == "effect" && subscriber_count == 0;
196        let orphan_kind = if subscriber_count == 0 {
197            match type_str {
198                "effect" => Some(OrphanKind::OrphanEffect),
199                "derived" | "dynamic" => Some(OrphanKind::IdleDerived),
200                "producer" => Some(OrphanKind::IdleProducer),
201                // `state` is excluded by construction — no fn to be
202                // idle about. Mirrors pure-ts `profile.ts:129-138`.
203                _ => None,
204            }
205        } else {
206            None
207        };
208
209        profiles.push(NodeProfile {
210            path: path.clone(),
211            r#type: type_str.to_string(),
212            status: status_str.to_string(),
213            subscriber_count,
214            dep_count,
215            is_orphan_effect,
216            orphan_kind,
217        });
218    }
219
220    let top_by = |key: fn(&NodeProfile) -> usize| -> Vec<NodeProfile> {
221        let mut sorted: Vec<NodeProfile> = profiles.clone();
222        // Descending sort via `Reverse(key(n))`.
223        sorted.sort_by_key(|n| std::cmp::Reverse(key(n)));
224        sorted.truncate(top_n);
225        sorted
226    };
227
228    let orphans: Vec<NodeProfile> = profiles
229        .iter()
230        .filter(|p| p.orphan_kind.is_some())
231        .cloned()
232        .collect();
233    let orphan_effects: Vec<NodeProfile> = profiles
234        .iter()
235        .filter(|p| p.is_orphan_effect)
236        .cloned()
237        .collect();
238
239    GraphProfileResult {
240        node_count: profiles.len(),
241        edge_count: desc.edges.len(),
242        subgraph_count: desc.subgraphs.len(),
243        hotspots: Hotspots {
244            by_subscriber_count: top_by(|p| p.subscriber_count),
245            by_dep_count: top_by(|p| p.dep_count),
246        },
247        orphans,
248        orphan_effects,
249        nodes: profiles,
250    }
251}
252
253fn node_type_str(t: NodeTypeStr) -> &'static str {
254    match t {
255        NodeTypeStr::State => "state",
256        NodeTypeStr::Derived => "derived",
257        NodeTypeStr::Dynamic => "dynamic",
258        NodeTypeStr::Producer => "producer",
259        NodeTypeStr::Effect => "effect",
260        NodeTypeStr::Operator => "operator",
261    }
262}
263
264fn node_status_str(s: NodeStatus) -> &'static str {
265    match s {
266        NodeStatus::Sentinel => "sentinel",
267        NodeStatus::Pending => "pending",
268        NodeStatus::Dirty => "dirty",
269        NodeStatus::Settled => "settled",
270        NodeStatus::Resolved => "resolved",
271        NodeStatus::Completed => "completed",
272        NodeStatus::Errored => "errored",
273    }
274}