Skip to main content

srcgraph_metrics/
lib.rs

1//! Code-analysis metrics over a `petgraph::Graph<N, E>` where
2//! `N: srcgraph_core::ClassNode` and `E: srcgraph_core::EdgeKind`.
3//!
4//! See `DESIGN.md` at the workspace root for the phased rollout plan
5//! (Phase 1: scc, lcom4, betweenness).
6//!
7//! @yah:relay(R153, "Phase 2 metrics: instability + cyclomatic + halstead + entropy")
8//! @yah:at(2026-05-14T17:19:47Z)
9//! @yah:status(open)
10//! @yah:assignee(agent:claude)
11//! @yah:handoff("Phase 1 benchmark validated (56.9x vs networkx on real graph). Open Phase 2 per DESIGN.md:58-63 — four per-class metrics that operate on data already in the GraphML schema, no new graph traversal needed.")
12//! @yah:next("T1 instability (Ce/(Ca+Ce) + SDP violations)")
13//! @yah:next("T2 cyclomatic per-method CC + bimodality (dip statistic)")
14//! @yah:next("T3 halstead V/D/E/B from η₁/η₂/N₁/N₂")
15//! @yah:next("T4 entropy Shannon over identifier tokens")
16//!
17//! @yah:ticket(R153-T1, "metrics::instability — Martin's I = Ce/(Ca+Ce) + SDP violations")
18//! @yah:at(2026-05-14T17:19:56Z)
19//! @yah:status(review)
20//! @yah:assignee(agent:claude)
21//! @yah:parent(R153)
22//! @yah:verify("cargo test -p srcgraph-metrics --lib instability")
23//! @yah:handoff("Implemented metrics::instability at namespace granularity: compute_instability returns NamespaceInstability { ca, ce, instability } per package, counting only inter-namespace edges (intra-package edges are package-internal cohesion, not coupling). find_sdp_violations flags inter-namespace edges where I(src) < I(tgt) — stable depending on unstable — sorted by gap descending. Isolated namespaces (Ca+Ce=0) yield instability=None and are skipped in violation detection. 8 unit tests cover the formula edges, intra/inter-namespace split, parallel edges, isolated nodes, SDP detection, and sort order.")
24//!
25//! @yah:ticket(R153-T2, "metrics::cyclomatic — per-method CC + bimodality (dip statistic)")
26//! @yah:at(2026-05-14T17:19:56Z)
27//! @yah:status(review)
28//! @yah:assignee(agent:claude)
29//! @yah:parent(R153)
30//! @yah:verify("cargo test -p srcgraph-metrics --lib cyclomatic")
31//! @yah:handoff("Implemented metrics::cyclomatic. Added cyclomatic_complexity() accessor to the ClassNode trait (defaults None; OwnedClassNode returns its parsed JSON). parse_methods reads the {methods:[{name,complexity}]} blob; compute_stats yields mean/median/min/max/std/total; detect_bimodality mirrors the Python heuristic (sort, largest gap, threshold = max(3, 3×median_gap), ≥2 on each side). compute_cyclomatic walks every node and returns CyclomaticReport with None fields for nodes whose blob is absent or malformed. 9 tests cover parse, stats, the short/flat/cluster/unbalanced bimodality cases, and full-graph traversal.")
32//! @yah:assumes("Field name is `dip_score` for continuity with the Python panel even though we use a normalized gap-ratio heuristic, not the true Hartigan dip. If a downstream consumer expects the statistical p-value, swap in a dip-test crate later — the type and call site stay the same.")
33//!
34//! @yah:ticket(R153-T3, "metrics::halstead — V/D/E/B from η₁/η₂/N₁/N₂ already extracted")
35//! @yah:at(2026-05-14T17:19:56Z)
36//! @yah:status(review)
37//! @yah:assignee(agent:claude)
38//! @yah:parent(R153)
39//! @yah:verify("cargo test -p srcgraph-metrics --lib halstead")
40//! @yah:handoff("Implemented metrics::halstead. Exposed halstead_eta1/eta2/n1/n2 accessors on the ClassNode trait (default 0); halstead_metrics(η₁,η₂,N₁,N₂) returns (V, D, E, B, η, N) using the Python edge-case rules (η=0 → V=0; η₂=0 → D=0); compute_halstead walks every node into a Halstead struct. 4 tests cover the worked example, both zero edge cases, and full-graph traversal.")
41//!
42//! @yah:ticket(R153-T4, "metrics::entropy — Shannon entropy over identifier tokens")
43//! @yah:at(2026-05-14T17:19:56Z)
44//! @yah:status(review)
45//! @yah:assignee(agent:claude)
46//! @yah:parent(R153)
47//! @yah:verify("cargo test -p srcgraph-metrics --lib entropy")
48//! @yah:handoff("Implemented metrics::entropy at token-frequency granularity (per the ticket's --next bullets, not the Python responsibility-distribution variant): added method_tokens() accessor to the ClassNode trait; token_histogram pools every token across every method into a frequency map; entropy_from_counts and shannon_entropy compute H = -Σ p·log₂(p). compute_entropy returns TokenEntropy { entropy, distinct, total } per class; ≤1 distinct token yields 0.0, missing/malformed blob yields None. 7 tests cover the formula, pooling, and trait-level traversal.")
49//! @yah:assumes("The Python reference (analysis/entropy.py) computes entropy over methodConnectivity component sizes, not identifier tokens. This implementation follows the ticket's --next bullets (methodTokens histogram). If the visiting tool's panel needs the responsibility-distribution variant, add it as a second function — don't replace this one.")
50//!
51//! @yah:relay(R155, "Phase 3 metrics: clone_detection + association_rules + process_mining")
52//! @yah:at(2026-05-14T17:35:26Z)
53//! @yah:status(open)
54//! @yah:assignee(agent:claude)
55//! @yah:next("T1 clone_detection — n-gram fingerprints over methodTokens, similarity pairs, family grouping")
56//! @yah:next("T2 association_rules — apriori-style itemset growth for class-co-occurrence rules")
57//! @yah:next("T3 process_mining — Petri net construction from callSequences + conformance scoring")
58//!
59//! @yah:ticket(R155-T1, "metrics::clone_detection — n-gram fingerprints + similarity pairs + family grouping")
60//! @yah:at(2026-05-14T17:35:34Z)
61//! @yah:status(review)
62//! @yah:assignee(agent:claude)
63//! @yah:parent(R155)
64//! @yah:verify("cargo test -p srcgraph-metrics --lib clone_detection")
65//! @yah:handoff("Implemented metrics::clone_detection. Added method_fingerprints() accessor to the ClassNode trait (defaults None; OwnedClassNode returns the parsed JSON). parse_method_fingerprints reads the {methods:[{name,tokens,line,endLine,params}]} blob; extract_ngrams builds n-gram sets from space-separated token streams (short streams collapse to one tuple, per Python ref); ngram_jaccard / jaccard compute |A∩B|/|A∪B| with the empty/empty=0 convention. detect_clone_pairs is O(n²) with two prunes: 2:1 token-count ratio gate and same-class-same-name skip (partial-class siblings). group_clone_families runs path-compressed union-find over pair indices, computes per-family avg similarity, sorts families by size desc, assigns deterministic ids. compute_clone_analysis walks the graph (accepting both Value::Object and Value::String-encoded blobs for GraphML round-trip safety) and returns CloneAnalysis { pairs, families, total_methods, cloned_methods, clone_ratio }. 12 tests cover n-gram edges, Jaccard, both prune paths, transitive family grouping, family ordering+id assignment, full-graph traversal, and string-encoded blob handling.")
66//! @yah:assumes("methodFingerprints tokens is a space-separated normalised-token-class string (matches Python ref + extractor schema). The Python reference has a separate extract_semantic_diff() helper that wasn't ported — the panel uses it for side-by-side display only, not for detection. Add later if the Rust path needs to drive the UI.")
67//!
68//! @yah:ticket(R155-T2, "metrics::association_rules — apriori itemset growth over class-co-occurrence")
69//! @yah:at(2026-05-14T17:35:34Z)
70//! @yah:status(review)
71//! @yah:assignee(agent:claude)
72//! @yah:parent(R155)
73//! @yah:verify("cargo test -p srcgraph-metrics --lib association_rules")
74//! @yah:handoff("Implemented metrics::association_rules. Added call_sequences() accessor to the ClassNode trait (defaults None; OwnedClassNode returns the parsed JSON). parse_call_sequences reads the {sequences:[{method,calls,…}]} blob. mine_frequent_itemsets is Apriori bottom-up by k=1..=5 (Python-ref cap): direct count for 1-itemsets, then all k-combos of frequent 1-items gated on the (k-1)-subset property + support; returns FrequentItemset {items, support} sorted by support desc with item-list tiebreaker. generate_rules emits A→C for every non-empty proper subset of each frequent itemset, scoring confidence = sup(A∪C)/sup(A) and lift = confidence / (sup(C)/n); sorted confidence desc → support desc → antecedent asc → consequent asc. classify_rule maps confidence to invariant (≥0.99) / strong (≥0.85) / moderate (≥0.5). compute_association_analysis walks the graph (accepts both Value::Object and string-encoded blobs for GraphML round-trip), filters sequences with calls.len()>=2 into BTreeSet transactions, and returns AssociationAnalysis {transactions, rules, num_rules, invariants, strong, moderate, itemsets}. 20 tests cover parse, combinations helper, mining (singletons/pairs/min_support/empty/sort order), rule generation (high-conf detection/sort/classification/empty), classify thresholds, and full-graph traversal incl. string-encoded blob + short-sequence skip.")
75//! @yah:assumes("min_support is literal in the API (caller-supplied), not the Python adaptive max(2, n/10) — the analysis result returns transactions count so callers wanting the adaptive pattern can run a two-pass mine. PyO3 binding (R152-T5) is the natural place to add an adaptive wrapper.")
76//!
77//! @yah:ticket(R155-T3, "metrics::process_mining — Petri net construction from callSequences + conformance")
78//! @yah:at(2026-05-14T17:35:34Z)
79//! @yah:status(review)
80//! @yah:assignee(agent:claude)
81//! @yah:parent(R155)
82//! @yah:verify("cargo test -p srcgraph-metrics --lib process_mining")
83//! @yah:handoff("Implemented metrics::process_mining. Reuses association_rules::parse_call_sequences (same {sequences:[{method,calls}]} blob, same string-or-object GraphML tolerance). build_petri_net runs an alpha-miner pass: collects activities + direct-succession counts + first/last activities, classifies ordered pairs as causal (one-way only) vs parallel (both directions); emits one t_<act> transition per activity (sorted), p_start → first activities, last activities → p_end, plus one p_<i> intermediate per causal pair wired t_a→p_i→t_b. classify_transitions reclassifies each transition by local arc topology: choice (input place feeds >1 transition), loop (output→transition whose output reaches one of our inputs — one-hop back-edge per Python), parallel (output feeds >1 transition), else mandatory. compute_conformance derives valid (t_a→t_b) successions from arcs, then counts sequences whose every consecutive pair is in that set; sequences with <2 calls trivially conform (matches Python). compute_process_mining walks the graph (BTreeMap-staged for determinism), skips nodes with <2 sequences, builds+classifies+scores per node, and returns ProcessMiningAnalysis { nodes: [NodeProcessMining { node_id, class_name, net, conformance, num_places, num_transitions, num_arcs }], total }. 16 tests cover build (empty/linear/single-call/branching/parallel-pair/arc-validity), classify (linear-mandatory/branching), conformance (perfect/empty/violation/short-seq/range), and full-graph walk (skips thin+blobless, accepts string-encoded blob, empty graph).")
84//! @yah:assumes("Parallel pairs (a,b seen both ways) yield NO intermediate place — they're noted by the direct-succession counter but the Python ref only emits places for *causal* pairs, so we match that. If a downstream consumer wants explicit parallel-split nodes in the rendered net, add them as a second pass (kind=\"parallel-split\") rather than retrofitting build_petri_net.")
85
86pub mod scc;
87pub mod lcom4;
88pub mod betweenness;
89pub mod instability;
90pub mod cyclomatic;
91pub mod halstead;
92pub mod entropy;
93pub mod clone_detection;
94pub mod association_rules;
95pub mod process_mining;