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}