Skip to content

[feature] Complexity Score Combination#32

Open
seojeongm wants to merge 6 commits into
mainfrom
feat/31-complexity-score-combination
Open

[feature] Complexity Score Combination#32
seojeongm wants to merge 6 commits into
mainfrom
feat/31-complexity-score-combination

Conversation

@seojeongm

Copy link
Copy Markdown
Collaborator

Summary

Implements #31 — combines the per-file complexity signals from #30 (cyclomatic complexity, block nesting depth, logical LOC) with inbound/outbound link counts into a single complexity_score per file, using percentile-rank normalization instead of min-max so a single outlier file doesn't compress everyone else's score toward 0.

  • Adds complexity_score REAL NOT NULL DEFAULT 0.0 to the file table, with a migration path for existing DBs (CREATE TABLE IF NOT EXISTS alone won't touch already-created tables).
  • Adds percentile_rank_normalize alongside the existing minmax_normalize in Scoring.cpp — PageRank/BC's min-max path is untouched.
  • Adds ComplexityScorer (compute + computeAndWrite), following the same pure-compute/DB-IO split already used by PageRank/AlgoDbWriter.
  • Adds 10 new AlgoConfig keys (5 top-level weights, 2 pre-blend pairs, 1 include-generated flag), read/written via reading_sequence_config the same way alpha/beta/damping already are — no recompile needed to retune.
  • Wires ComplexityScorer::computeAndWrite into AlgoRunner::run, so TelescodeAlgo <db_path> computes it end-to-end alongside the existing reading-sequence pipeline.

Design notes

  • Percentile-rank vs min-max: min-max rescales by range, so one extreme outlier (e.g. a 5,000-line file) compresses every other file toward 0. Percentile-rank is position-based (rank(i) = #{values strictly less than v[i]} / (N-1)), so it only cares about relative ordering.
  • Pre-blend: CC and nesting each get a 0.8·max + 0.2·avg blend before percentile-ranking (configurable); logical_loc, inbound, and outbound feed in unblended.
  • Weight validation: if a weight group doesn't sum to 1.0 (±0.001), it's auto-normalized by ratio with a stderr warning rather than producing out-of-range scores.
  • Generated files: excluded from the scoring population by default (WHERE is_generated = 0); includable via complexity_include_generated. Excluded files stay at the column default (0.0) — this is unambiguous since is_generated is already its own column callers can filter on.
  • In/out-bound counts: read directly from Graph.radj/Graph.adj at scoring time (same pattern PageRank::compute already uses for out_deg), not persisted as file table columns — avoids a second source of truth that would need invalidation on incremental updates.

Commits

  • feat(db): add complexity_score column to file table with migration
  • feat(algo): add complexity scoring config keys to AlgoConfig
  • feat(algo): add percentile_rank_normalize helper with unit tests
  • feat(algo): add ComplexityScorer::compute (blend, percentile-rank, weighted sum)
  • feat(algo): add ComplexityScorer::computeAndWrite (DB read/write + graph join)
  • feat(algo): wire ComplexityScorer into AlgoRunner::run

Test plan

Unit tests (15 new cases: percentile-rank edge cases, blend/weighted-sum correctness against a hand-computed fixture, weight auto-normalization, DB round-trip via computeAndWrite):

cmake -S . -B build
cmake --build build --target AlgoTests --config Debug
./build/Debug/AlgoTests.exe --gtest_filter='PercentileRankNormalize.*:ComplexityScorer*'

Schema migration (verifies existing DBs get the column added without losing data):

cmake --build build --target TelescodeAlgo --config Debug
sqlite3 fixture.db "CREATE TABLE file(file_id TEXT PRIMARY KEY, file_name TEXT NOT NULL, language TEXT NOT NULL, raw_loc INTEGER NOT NULL, logical_loc INTEGER NOT NULL DEFAULT 0, is_generated INTEGER NOT NULL DEFAULT 0, max_cyclomatic_complexity INTEGER NOT NULL DEFAULT 0, avg_cyclomatic_complexity REAL NOT NULL DEFAULT 0.0, max_block_depth INTEGER NOT NULL DEFAULT 0, avg_block_depth REAL NOT NULL DEFAULT 0.0, max_function_loc INTEGER NOT NULL DEFAULT 0, avg_function_loc REAL NOT NULL DEFAULT 0.0); INSERT INTO file(file_id,file_name,language,raw_loc) VALUES('a.py','a.py','python',42);"
./build/Debug/TelescodeAlgo.exe fixture.db
sqlite3 fixture.db "SELECT file_id, complexity_score FROM file WHERE file_id='a.py';"   # expect: a.py|0.0
rm fixture.db

Config seeding (verifies all 10 new keys get seeded with correct defaults on first run):

cmake --build build --target TelescodeScanner TelescodeAlgo --config Debug
./build/Debug/TelescodeScanner.exe repos/sherlock cfg_test.db
./build/Debug/TelescodeAlgo.exe cfg_test.db
sqlite3 cfg_test.db "SELECT config_key, config_value FROM reading_sequence_config WHERE config_key LIKE 'complexity_%' ORDER BY config_key;"
rm cfg_test.db

End-to-end (real repo, sanity-checks the resulting distribution):

./build/Debug/TelescodeScanner.exe repos/sherlock e2e_test.db
./build/Debug/TelescodeAlgo.exe e2e_test.db
sqlite3 e2e_test.db "SELECT file_id, complexity_score FROM file WHERE is_generated=0 ORDER BY complexity_score DESC LIMIT 10;"
rm e2e_test.db

Expect the largest/most-complex file in the repo (sherlock_project/sherlock.py, 714 loc, CC 48) at the top of the list with a score near the high end (~0.95), scores spread across the range rather than clustered.

Out of scope

Color mapping / heatmap visualization (separate issue)
UI for weight adjustment (separate issue — config keys defined here are what it will read/write)
Per-function complexity score (file-level only)

CREATE TABLE IF NOT EXISTS alone won't add the column to existing
DBs, so initDb now checks via PRAGMA table_info and issues an
ALTER TABLE ADD COLUMN for DBs created before this column existed.
Ten new weights/blends for the upcoming ComplexityScorer, following
the same load/upsert pattern already used for alpha/beta/damping so
they're user-adjustable via reading_sequence_config without a recompile.
@seojeongm seojeongm self-assigned this Jul 10, 2026
@seojeongm seojeongm added the feature New feature or request label Jul 10, 2026
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds configurable percentile-based file complexity scoring, persists scores in SQLite with schema migration support, integrates scoring into the algorithm runner, and adds normalization, scoring, database, and generated-file handling tests.

Changes

Complexity scoring

Layer / File(s) Summary
Scoring contracts and configuration
src/algo/AlgoConfig.h, src/algo/Scoring.h, src/algo/Scoring.cpp
Adds complexity weights, blend factors, metric structures, percentile normalization, and in-memory score computation.
Database schema and migration
src/db/db.cpp
Adds file.complexity_score and migrates existing databases that lack the column.
Score computation and persistence
src/algo/Scoring.cpp
Reads file metrics and graph degrees, filters generated files according to configuration, computes scores, and updates SQLite transactionally.
Pipeline wiring and validation
src/algo/AlgoDbWriter.cpp, src/algo/AlgoRunner.cpp, tests/algo/*, CMakeLists.txt
Loads and persists complexity configuration, invokes scoring during runs, and registers tests covering normalization, weighting, database writes, and generated files.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

  • c5ln/Telescode#22 — Introduced the configuration and runner pipeline extended here with complexity scoring.
  • c5ln/Telescode#30 — Updated the file metrics and schema consumed by ComplexityScorer.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 31.25% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: complexity score combination.
Description check ✅ Passed The description is detailed and mostly complete, but it omits the explicit 'Closes #' issue link and checklist items from the template.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/31-complexity-score-combination

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/algo/Scoring.cpp`:
- Around line 188-213: Update the SQLite SELECT loop in the scoring function to
capture each sqlite3_step result, process SQLITE_ROW rows, and after the loop
verify the final status is SQLITE_DONE. On any other status, handle and
propagate the read error before calling compute() or writing scores, preventing
partial scoring. Use the surrounding statement-loading function and its existing
error-handling conventions as the integration point.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 57ecbdc7-c3f0-411c-9032-ba61e1013988

📥 Commits

Reviewing files that changed from the base of the PR and between 3f545fc and 78c6b46.

📒 Files selected for processing (9)
  • CMakeLists.txt
  • src/algo/AlgoConfig.h
  • src/algo/AlgoDbWriter.cpp
  • src/algo/AlgoRunner.cpp
  • src/algo/Scoring.cpp
  • src/algo/Scoring.h
  • src/db/db.cpp
  • tests/algo/test_complexity_scorer.cpp
  • tests/algo/test_percentile_rank.cpp

Comment thread src/algo/Scoring.cpp
Comment on lines +188 to +213
while (sqlite3_step(stmt) == SQLITE_ROW) {
const char* fid = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
if (!fid) continue;

ComplexityFileMetrics m;
m.file_id = fid;
m.max_cyclomatic_complexity = sqlite3_column_int(stmt, 1);
m.avg_cyclomatic_complexity = sqlite3_column_double(stmt, 2);
m.max_block_depth = sqlite3_column_int(stmt, 3);
m.avg_block_depth = sqlite3_column_double(stmt, 4);
m.logical_loc = sqlite3_column_int(stmt, 5);

// Isolated files with no CALLS/INHERITS/IMPORTS edges still get a node
// (GraphBuilder pre-populates every file_id), so a missing lookup here
// means the file wasn't in the file table when the graph was built --
// fall back to 0/0 rather than dropping the file from scoring.
auto it = file_graph.id_to_node.find(m.file_id);
if (it != file_graph.id_to_node.end()) {
NodeId nid = it->second;
m.inbound = static_cast<int>(file_graph.radj[nid].size());
m.outbound = static_cast<int>(file_graph.adj[nid].size());
}

inputs.push_back(std::move(m));
}
sqlite3_finalize(stmt);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

SELECT loop swallows step errors, risking partial scoring.

The loop exits on any non-SQLITE_ROW return, so a mid-iteration error (e.g. SQLITE_BUSY, SQLITE_ERROR) is indistinguishable from SQLITE_DONE. Execution then proceeds to compute() and writes scores computed over a truncated population — silently corrupting the heatmap. Capture and check the final step code.

🛡️ Proposed fix to propagate read errors
     std::vector<ComplexityFileMetrics> inputs;
-    while (sqlite3_step(stmt) == SQLITE_ROW) {
+    int stepRc;
+    while ((stepRc = sqlite3_step(stmt)) == SQLITE_ROW) {
         const char* fid = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
         if (!fid) continue;
@@
         inputs.push_back(std::move(m));
     }
     sqlite3_finalize(stmt);
+    if (stepRc != SQLITE_DONE) return stepRc;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
while (sqlite3_step(stmt) == SQLITE_ROW) {
const char* fid = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
if (!fid) continue;
ComplexityFileMetrics m;
m.file_id = fid;
m.max_cyclomatic_complexity = sqlite3_column_int(stmt, 1);
m.avg_cyclomatic_complexity = sqlite3_column_double(stmt, 2);
m.max_block_depth = sqlite3_column_int(stmt, 3);
m.avg_block_depth = sqlite3_column_double(stmt, 4);
m.logical_loc = sqlite3_column_int(stmt, 5);
// Isolated files with no CALLS/INHERITS/IMPORTS edges still get a node
// (GraphBuilder pre-populates every file_id), so a missing lookup here
// means the file wasn't in the file table when the graph was built --
// fall back to 0/0 rather than dropping the file from scoring.
auto it = file_graph.id_to_node.find(m.file_id);
if (it != file_graph.id_to_node.end()) {
NodeId nid = it->second;
m.inbound = static_cast<int>(file_graph.radj[nid].size());
m.outbound = static_cast<int>(file_graph.adj[nid].size());
}
inputs.push_back(std::move(m));
}
sqlite3_finalize(stmt);
int stepRc;
while ((stepRc = sqlite3_step(stmt)) == SQLITE_ROW) {
const char* fid = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
if (!fid) continue;
ComplexityFileMetrics m;
m.file_id = fid;
m.max_cyclomatic_complexity = sqlite3_column_int(stmt, 1);
m.avg_cyclomatic_complexity = sqlite3_column_double(stmt, 2);
m.max_block_depth = sqlite3_column_int(stmt, 3);
m.avg_block_depth = sqlite3_column_double(stmt, 4);
m.logical_loc = sqlite3_column_int(stmt, 5);
// Isolated files with no CALLS/INHERITS/IMPORTS edges still get a node
// (GraphBuilder pre-populates every file_id), so a missing lookup here
// means the file wasn't in the file table when the graph was built --
// fall back to 0/0 rather than dropping the file from scoring.
auto it = file_graph.id_to_node.find(m.file_id);
if (it != file_graph.id_to_node.end()) {
NodeId nid = it->second;
m.inbound = static_cast<int>(file_graph.radj[nid].size());
m.outbound = static_cast<int>(file_graph.adj[nid].size());
}
inputs.push_back(std::move(m));
}
sqlite3_finalize(stmt);
if (stepRc != SQLITE_DONE) return stepRc;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/algo/Scoring.cpp` around lines 188 - 213, Update the SQLite SELECT loop
in the scoring function to capture each sqlite3_step result, process SQLITE_ROW
rows, and after the loop verify the final status is SQLITE_DONE. On any other
status, handle and propagate the read error before calling compute() or writing
scores, preventing partial scoring. Use the surrounding statement-loading
function and its existing error-handling conventions as the integration point.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant