14 KiB
cstat code complexity score v0
Status
Implemented by the scorecard CLI command and src/scorecard.rs.
This score is intentionally heuristic. It is the first deterministic target for
agents to optimize before the project has behavioral data from agent cleanup
loops. Incompatible formula or JSON changes must bump score_version.
Purpose
code_complexity_cost_v0 measures structural reasoning burden in a Rust
codebase.
Lower cost means the code is structurally cleaner according to this metric. The score is designed for agent optimization: an agent can compare before/after runs and try to reduce the scalar by simplifying functions, reducing oversized files, lowering coupling, shrinking excessive abstraction surface, and removing stale code.
The score does not prove correctness, safety, maintainability, public API fitness, or behavior preservation. Those are harness responsibilities.
Non-goals
The score must not include:
- test pass/fail status;
- benchmark pass/fail status;
- coverage pass/fail status;
- reward shaping;
- task-specific acceptance criteria;
- model training logic;
- patch generation;
- semantic proof of dead-code safety;
- rustc-level name resolution or macro expansion requirements for v0.
The harness may reject an attempted optimization if tests, benchmarks, coverage, protected files, or behavior checks fail. That rejection is separate from the complexity score.
Optimization contract
The agent-facing objective is:
minimize code_complexity_cost_v0
The environment-facing objective is:
minimize code_complexity_cost_v0 subject to harness guards passing
The score itself is pure structural cost. It does not contain verification signals or guardrail penalties.
Scope
The score covers all discovered Rust code:
- production code;
- test code;
- benchmark code;
- any future discovered Rust scope such as examples.
All scopes contribute to the default total with equal weight. Tests and benches are code; if they are structurally complex, they increase the score. The harness is responsible for preventing agents from deleting, weakening, or bypassing those scopes.
The output must still include a scope breakdown so users can understand where cost comes from:
"scope_breakdown": {
"production": { "cost": 900.0 },
"tests": { "cost": 120.0 },
"benches": { "cost": 30.0 }
}
Scope labels are explanatory. They do not change scoring weight in v0.
Primary scalar
code_complexity_cost =
function_complexity_cost
+ file_concentration_cost
+ module_coupling_cost
+ abstraction_surface_cost
+ api_surface_cost
+ stale_surface_cost
Also emit a normalized companion scalar:
code_complexity_cost_per_kloc = code_complexity_cost / max(1.0, code_lines / 1000.0)
Use code_complexity_cost for before/after comparisons inside the same repo.
Use code_complexity_cost_per_kloc only for rough cross-repo comparisons.
Component 1: function complexity
Function complexity is the primary local reasoning-cost signal.
For each discovered Rust function or method:
function_cost =
2.0 * max(0, cyclomatic - 5)
+ 3.0 * max(0, nesting_depth - 3)
+ 0.2 * max(0, line_count - 50)
+ 0.5 * max(0, body_stmt_count - 20)
+ 1.0 * max(0, signature_score - 6)
Where:
cyclomatic = branch_points + 1
signature_score =
param_count
+ return_type_complexity
+ generic_param_count
+ trait_bound_count
+ where_predicate_count
Then:
function_complexity_cost = sum(function_cost)
Rationale:
- Branching increases path reasoning.
- Nesting increases local context stack depth.
- Long functions increase scan burden, but line count has low weight to avoid rewarding code golf.
- Body statement count adds a syntax-level size signal that is less sensitive to formatting than physical lines.
- Signature complexity captures call-boundary and API reasoning cost.
- Thresholds avoid penalizing ordinary small functions.
Component 2: file concentration
File concentration measures how much code and symbol surface is packed into one file.
For each discovered Rust file:
file_cost =
0.04 * max(0, code_lines - 400)
+ 0.25 * max(0, total_symbols - 35)
+ 1.0 * max(0, function_count - 25)
Then:
file_concentration_cost = sum(file_cost)
Rationale:
- Large files require more navigation and context loading.
- Symbol piles make a file harder to summarize.
- Function piles usually indicate several concepts sharing one module.
- The thresholds keep normal files free and penalize concentration rather than raw existence of code.
Component 3: module coupling
Module coupling measures source-module navigation burden and architectural tangling.
module_coupling_cost =
sum_per_module(
2.0 * max(0, out_degree - 5)
+ 1.0 * max(0, in_degree - 10)
)
+ sum_bidirectional_pairs(20.0 + 5.0 * pair_strength)
+ sum_low_cohesion_modules(
if function_count >= 4:
2.0 * function_count * max(0.0, 0.55 - combined_cohesion)
)
Rationale:
- Fan-out means a module must know about many neighbors, so it is weighted more heavily than fan-in.
- Fan-in can indicate useful central code, so it receives a higher threshold and lower weight.
- Bidirectional pairs are strong architecture smells and receive a large fixed penalty.
- Low cohesion matters only when a module has enough functions for cohesion to be meaningful.
For v0, use existing dependency, module-degree, coupling-pair, and cohesion
signals. If full graph-cycle detection is added later, it should be a new field
or a score_version bump.
Component 4: abstraction surface
Abstraction surface measures named conceptual inventory independent of line count.
Per file or module, using symbol counts:
abstraction_surface_cost =
0.5 * max(0, structs - 12)
+ 0.5 * max(0, enums - 8)
+ 1.0 * max(0, traits - 4)
+ 0.5 * max(0, trait_impls - 8)
+ 0.2 * max(0, consts + statics - 10)
Then sum across files/modules.
Rationale:
- Named concepts are real reasoning surface even when individual functions are small.
- Traits are weighted higher because they introduce abstraction and dispatch reasoning.
- This component should not punish ordinary data modeling; it only penalizes excessive local surface.
Component 5: API surface
API surface measures interface area that callers outside the local module may depend on.
api_surface_cost =
sum_visible_symbols(api_visibility_weight * api_kind_weight)
+ sum_visible_functions(api_visibility_weight * 0.5 * max(0, signature_score - 3))
Where:
api_visibility_weight:
pub = 1.0
pub(crate) = 0.4
api_kind_weight:
function/method = 1.0
struct = 1.0
enum = 1.0
trait = 2.0
const/static = 0.5
Then:
api_surface_cost = sum(api symbol costs)
Rationale:
pubcreates repository-external compatibility surface.pub(crate)creates crate-wide non-local reasoning surface, but is cheaper because it remains internally changeable.- Traits are weighted higher because they expose behavioral contracts and dispatch/implementation reasoning.
- Visible functions receive a small signature surcharge so complex public call boundaries cost more than simple public call boundaries.
- This component may encourage visibility reduction; public API compatibility checks and human review decide whether a reduction is allowed.
For v1, only direct pub and pub(crate) item visibility are counted. Private
items and narrower restricted visibilities such as pub(super) are not counted.
Component 6: stale surface
Stale surface measures code that appears unused by current static evidence.
stale_surface_cost = 0.5 * static_dead_code_candidate_count
Rationale:
- Unused code still imposes reading, search, and maintenance cost.
- The weight is low because static dead-code candidates can be false positives around macros, public API usage, trait-object dispatch, build scripts, and string-based dispatch.
- The score may encourage deletion; the harness and human review decide whether deletion is allowed.
JSON output contract
The score command should emit compact JSON by default when --json is passed:
{
"cstat_version": "0.1.0",
"score_version": "code_complexity_cost_v1",
"target": ".",
"code_complexity_cost": 1234.5,
"code_complexity_cost_per_kloc": 104.7,
"component_costs": {
"function_complexity": {
"cost": 800.0,
"functions_scored": 120
},
"file_concentration": {
"cost": 120.0,
"files_scored": 14
},
"module_coupling": {
"cost": 180.0,
"modules_scored": 14
},
"abstraction_surface": {
"cost": 90.0,
"symbols_scored": 300
},
"api_surface": {
"cost": 20.0,
"public_symbols": 12,
"crate_symbols": 8
},
"stale_surface": {
"cost": 44.5,
"candidate_count": 89
}
},
"scope_breakdown": {
"production": {
"cost": 900.0,
"functions_scored": 100,
"files_scored": 10
},
"tests": {
"cost": 300.0,
"functions_scored": 20,
"files_scored": 4
},
"benches": {
"cost": 34.5,
"functions_scored": 2,
"files_scored": 1
}
},
"top_contributors": [
{
"kind": "function",
"scope": "production",
"file": "src/main.rs",
"function": "main",
"cost": 115.4,
"reasons": {
"cyclomatic": 45,
"nesting_depth": 4,
"line_count": 212,
"body_stmt_count": 11,
"signature_score": 1
},
"component_costs": {
"branching": 80.0,
"nesting": 3.0,
"span": 32.4,
"signature": 0.0
}
}
],
"metadata": {
"rust_files": 25,
"code_lines": 11793,
"parse_error_files": 0
}
}
Required stable fields:
cstat_versionscore_versiontargetcode_complexity_costcode_complexity_cost_per_kloccomponent_costsscope_breakdowntop_contributorsmetadata
Field additions are allowed within the same score version if they do not change
existing field meaning. Formula changes, field removals, or semantic changes
must bump score_version.
Human output contract
Human output should be short and explanatory:
cstat code complexity score
score version: code_complexity_cost_v1
lower is cleaner; harness guards behavior separately
total cost: 1234.5
cost / KLOC: 104.7
components:
function complexity: 800.0
file concentration: 120.0
module coupling: 180.0
abstraction surface: 90.0
api surface: 20.0
stale surface: 44.5
top contributors:
1. function src/main.rs::main 115.4
2. module src/flow 73.0
3. file src/coverage.rs 47.4
Do not print every underlying probe. This command is a scorecard, not an all
command.
Implementation guide
Recommended module:
src/scorecard.rs
Recommended CLI:
cstat scorecard --path <project-or-file> [--json] [--top N] [-v]
Initial implementation should support project mode first. Selected-file mode can be added later if it naturally falls out of the data model.
Implementation steps:
- Parse/discover Rust files using existing project discovery.
- Parse symbols with
ast_parser::parse_project. - Build function rows using the same raw fields as
branching,signature, andspan. - Count line and symbol concentration per file using existing
locandsymbolsdata where possible. - Compute dependency/coupling/cohesion data using existing
depslogic. - Compute API surface from visible parsed symbols and function signatures.
- Compute stale surface using existing
dead_codeanalysis. - Classify each function/file into a scope label for reporting:
productiontestsbenches- future labels as needed
- Compute component costs.
- Sum total cost.
- Build top contributors from function, file, module, abstraction, API surface, and stale contributors.
- Render compact human output or stable JSON.
Avoid shelling out to existing CLI commands from the implementation. Reuse the same Rust collectors directly so the score is fast, deterministic, and testable.
Scope classification guide
Scope classification is only for explanation and breakdown in v0. It does not change scoring weight.
Suggested rules:
benches: file underbenches/or benchmark-recognized function where known;tests: file undertests/, function with#[test], or code inside obvious#[cfg(test)]modules where existing analysis can identify it;production: default for discovered source that is not classified above.
If classification is ambiguous, classify as production and avoid hiding cost.
The score should remain deterministic.
Top contributors
Emit enough contributor detail for an agent to choose a bounded cleanup target.
Contributor kinds:
functionfilemoduleabstraction_surfacestale_surface
Each contributor should include:
kindscopefileormodulefunctionwhen applicablecost- raw reason fields used to compute cost
- per-reason component costs when applicable
Default top count: 20.
Known limitations
- The weights are guessed, not learned.
- Physical line count can be affected by formatting, so it has low weight and is paired with body statement count.
- Static dead-code detection can be wrong.
- Dependency extraction is source-level and can miss macro-generated edges.
- Symbol counts come from syntax parsing, not full semantic analysis.
- A lower score does not prove better architecture in every local case.
- The harness must prevent destructive or behavior-changing optimizations.
These limitations are acceptable for v0 because the score is deterministic, explainable, and calibrated enough to start collecting before/after cleanup data.