Skip to main content

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}