code_ranker_graph/builtin/write.rs
1//! Computing built-in metric values onto a node — the per-unit derivation engines
2//! and the `write_metrics` / `write_derived` entry points. Split out of the catalog
3//! module (`super`) so each file stays a small, single-concern unit. Reads the
4//! catalog (`super::BUILTIN` / `super::FieldDef`) via the parent module; the
5//! `super::` and `mod` edges are non-flow, so this split adds no coupling.
6//!
7//! Two engines, by input dependency:
8//! - [`DERIVED`] — `[fields.*]` over tier-1 counts only, evaluated per file from the
9//! raw (unrounded) inputs in [`write_metrics`] (the pre-graph tier-2 step);
10//! - [`GRAPH_DERIVED`] — `[fields.*]` that read a graph-measured key (e.g. `hk` over
11//! `fan_in`/`fan_out`), evaluated by [`write_derived`] after the coupling pass.
12
13use super::{BUILTIN, FieldDef};
14use crate::attrs::num_attr;
15use crate::registry::{Engine, MetricDef, references};
16use code_ranker_plugin_api::attrs::AttrValue;
17use code_ranker_plugin_api::metrics::MetricInputs;
18use code_ranker_plugin_api::node::Node;
19use std::collections::BTreeMap;
20use std::sync::LazyLock;
21
22/// Graph-measured attribute keys a `[fields.*]` formula may read. A field whose
23/// formula references any of these is **graph-derived**: it is evaluated after the
24/// coupling/cycle graph pass (by [`write_derived`]), not in the per-file tier-2
25/// step ([`write_metrics`]), which runs before the graph exists.
26const GRAPH_KEYS: &[&str] = &["fan_in", "fan_out", "fan_out_external", "cycle"];
27
28/// Does this formula read a graph-measured key (→ must run after the graph pass)?
29fn is_graph_derived(formula: &str) -> bool {
30 GRAPH_KEYS.iter().any(|k| references(formula, k))
31}
32
33/// One `[fields.*]` engine over the subset of fields selected by `keep` (by their
34/// `formula_cel`), paired with the defs (for the per-key `omit_at` when writing).
35fn build_engine(keep: impl Fn(&str) -> bool) -> (BTreeMap<String, MetricDef>, Engine) {
36 let defs: BTreeMap<String, MetricDef> = BUILTIN
37 .fields
38 .iter()
39 .filter_map(|(k, f): (&String, &FieldDef)| {
40 f.formula_cel
41 .as_ref()
42 .filter(|cel| keep(cel))
43 .map(|cel| (k.clone(), derived_def(cel, f.omit_at)))
44 })
45 .collect();
46 let engine = Engine::compile(&defs).expect("metrics/builtin.toml fields compile");
47 (defs, engine)
48}
49
50/// Pre-graph derived engine: the `[fields.*]` whose formula reads only tier-1
51/// counts, evaluated per file from the raw (unrounded) inputs in [`write_metrics`].
52pub(crate) static DERIVED: LazyLock<(BTreeMap<String, MetricDef>, Engine)> =
53 LazyLock::new(|| build_engine(|cel| !is_graph_derived(cel)));
54
55/// Post-graph derived engine: the `[fields.*]` whose formula reads a graph-measured
56/// key (e.g. `hk` over `fan_in`/`fan_out`), evaluated by [`write_derived`] once the
57/// coupling pass has annotated those counts onto the nodes.
58static GRAPH_DERIVED: LazyLock<(BTreeMap<String, MetricDef>, Engine)> =
59 LazyLock::new(|| build_engine(is_graph_derived));
60
61fn derived_def(cel: &str, omit_at: f64) -> MetricDef {
62 MetricDef {
63 formula_cel: cel.to_string(),
64 value_type: "float".to_string(),
65 omit_at,
66 ..MetricDef::default()
67 }
68}
69
70/// Write the per-unit built-in metrics onto `node`: the tier-1 measured values
71/// (the LOC block is emitted only when `sloc > 0`) plus the **pre-graph** derived
72/// metrics ([`DERIVED`]) computed from the raw tier-1 counts. Graph-derived fields
73/// (e.g. `hk`) are written later by [`write_derived`], once the coupling pass has
74/// run. Each value is dropped at its `omit_at`.
75pub fn write_metrics(node: &mut Node, i: &MetricInputs) {
76 {
77 let mut put = |key: &str, v: f64| {
78 let a = num_attr(v);
79 if a == num_attr(0.0) {
80 node.attrs.remove(key);
81 } else {
82 node.attrs.insert(key.to_string(), a);
83 }
84 };
85 put("cognitive", i.cognitive);
86 put("exits", i.exits);
87 put("args", i.args);
88 put("closures", i.closures);
89 // Halstead/AST base counts — emitted so derived formulas can show their
90 // live derivation line in the viewer (each dropped at 0 by `put`).
91 put("eta1", i.eta1);
92 put("eta2", i.eta2);
93 put("n1", i.n1);
94 put("n2", i.n2);
95 put("spaces", i.spaces);
96 put("branches", i.branches);
97 put("span_sloc", i.span_sloc);
98 if i.sloc > 0.0 {
99 put("sloc", i.sloc);
100 put("lloc", i.lloc);
101 put("cloc", i.cloc);
102 put("blank", i.blank);
103 }
104 put("tloc", i.tloc);
105 }
106
107 let (defs, engine) = &*DERIVED;
108 // Built-in derived metrics are pure arithmetic over the tier-1 counts — no
109 // path/string inputs needed.
110 for (key, value) in engine.eval_node(&inputs_map(i), &BTreeMap::new()) {
111 let omit = defs.get(&key).map(|d| d.omit_at).unwrap_or(0.0);
112 let a = num_attr(value);
113 if a == num_attr(omit) {
114 node.attrs.remove(&key);
115 } else {
116 node.attrs.insert(key, a);
117 }
118 }
119}
120
121/// The tier-1 inputs as a name→value map (the variables derived formulas read).
122fn inputs_map(i: &MetricInputs) -> BTreeMap<String, f64> {
123 BTreeMap::from([
124 ("eta1".to_string(), i.eta1),
125 ("eta2".to_string(), i.eta2),
126 ("n1".to_string(), i.n1),
127 ("n2".to_string(), i.n2),
128 ("spaces".to_string(), i.spaces),
129 ("branches".to_string(), i.branches),
130 ("cognitive".to_string(), i.cognitive),
131 ("exits".to_string(), i.exits),
132 ("args".to_string(), i.args),
133 ("closures".to_string(), i.closures),
134 ("sloc".to_string(), i.sloc),
135 ("lloc".to_string(), i.lloc),
136 ("cloc".to_string(), i.cloc),
137 ("blank".to_string(), i.blank),
138 ("tloc".to_string(), i.tloc),
139 ("span_sloc".to_string(), i.span_sloc),
140 ])
141}
142
143/// Keys a graph-derived formula may read, seeded to `0` so an absent (no-signal,
144/// dropped) attribute resolves the same way the complete tier-1 map does in
145/// [`write_metrics`] — otherwise a formula referencing e.g. `fan_in` on an
146/// uncoupled node would fail to resolve the variable (→ the field is omitted)
147/// instead of evaluating it at zero.
148const GRAPH_DERIVED_SEED: &[&str] = &[
149 "eta1",
150 "eta2",
151 "n1",
152 "n2",
153 "spaces",
154 "branches",
155 "cognitive",
156 "exits",
157 "args",
158 "closures",
159 "sloc",
160 "lloc",
161 "cloc",
162 "blank",
163 "tloc",
164 "span_sloc",
165 "fan_in",
166 "fan_out",
167 "fan_out_external",
168];
169
170/// Compute the **graph-derived** built-in metrics ([`GRAPH_DERIVED`], e.g. `hk`)
171/// onto `node`, AFTER the coupling pass has written `fan_in`/`fan_out`. Reads the
172/// node's own (already-rounded) numeric attributes — exactly the values the former
173/// Rust `hk` read via `attr_f64` — so emitted values are unchanged. Pre-graph
174/// fields (volume/mi/…) are NOT recomputed here: they are written by
175/// [`write_metrics`] from the raw, unrounded tier-1 counts and must not be
176/// re-derived from the rounded node attributes. Each value is dropped at its
177/// `omit_at`. No-op when no `[fields.*]` is graph-derived.
178pub fn write_derived(node: &mut Node) {
179 let (defs, engine) = &*GRAPH_DERIVED;
180 if defs.is_empty() {
181 return;
182 }
183 let mut attrs: BTreeMap<String, f64> = GRAPH_DERIVED_SEED
184 .iter()
185 .map(|k| ((*k).to_string(), 0.0))
186 .collect();
187 for (k, v) in &node.attrs {
188 match v {
189 AttrValue::Int(i) => {
190 attrs.insert(k.clone(), *i as f64);
191 }
192 AttrValue::Float(f) => {
193 attrs.insert(k.clone(), *f);
194 }
195 AttrValue::Str(_) | AttrValue::Bool(_) => {}
196 }
197 }
198 for (key, value) in engine.eval_node(&attrs, &BTreeMap::new()) {
199 let omit = defs.get(&key).map(|d| d.omit_at).unwrap_or(0.0);
200 let a = num_attr(value);
201 if a == num_attr(omit) {
202 node.attrs.remove(&key);
203 } else {
204 node.attrs.insert(key, a);
205 }
206 }
207}