From 1bed8e08fd8c47883e7030e3adc45c10eafc76e2 Mon Sep 17 00:00:00 2001 From: seojeongm Date: Fri, 10 Jul 2026 16:50:16 +0900 Subject: [PATCH 1/6] feat(db): add complexity_score column to file table with migration 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. --- src/db/db.cpp | 46 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/src/db/db.cpp b/src/db/db.cpp index d8cd9e5..3f25abd 100644 --- a/src/db/db.cpp +++ b/src/db/db.cpp @@ -2,6 +2,8 @@ #include #include +#include +#include static const char* kInitSQL = "PRAGMA journal_mode = WAL;" @@ -20,7 +22,8 @@ static const char* kInitSQL = " 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" + " avg_function_loc REAL NOT NULL DEFAULT 0.0," + " complexity_score REAL NOT NULL DEFAULT 0.0" ");" "CREATE TABLE IF NOT EXISTS class (" @@ -103,6 +106,40 @@ static const char* kInitSQL = " config_value TEXT NOT NULL" ");"; +// SQLite has no "ADD COLUMN IF NOT EXISTS" — check via PRAGMA table_info first. +static bool columnExists(sqlite3* db, const char* table, const char* column) +{ + std::string sql = std::string("PRAGMA table_info(") + table + ");"; + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK) + return false; + + bool found = false; + while (sqlite3_step(stmt) == SQLITE_ROW) { + const char* name = reinterpret_cast(sqlite3_column_text(stmt, 1)); + if (name && std::strcmp(name, column) == 0) { found = true; break; } + } + sqlite3_finalize(stmt); + return found; +} + +// Migrates DBs created before complexity_score existed. No-op once migrated, +// since the column is also created directly by kInitSQL for fresh DBs. +static int migrateComplexityScoreColumn(sqlite3* db) +{ + if (columnExists(db, "file", "complexity_score")) return SQLITE_OK; + + char* errMsg = nullptr; + int rc = sqlite3_exec(db, + "ALTER TABLE file ADD COLUMN complexity_score REAL NOT NULL DEFAULT 0.0;", + nullptr, nullptr, &errMsg); + if (rc != SQLITE_OK) { + std::fprintf(stderr, "initDb: complexity_score migration failed: %s\n", errMsg); + sqlite3_free(errMsg); + } + return rc; +} + int initDb(const char* dbPath, sqlite3** db) { if (!dbPath || !db) return SQLITE_MISUSE; @@ -127,5 +164,12 @@ int initDb(const char* dbPath, sqlite3** db) return rc; } + rc = migrateComplexityScoreColumn(*db); + if (rc != SQLITE_OK) { + sqlite3_close(*db); + *db = nullptr; + return rc; + } + return SQLITE_OK; } From 4595c6eb6ed234388a562887f2be7db9a76d51df Mon Sep 17 00:00:00 2001 From: seojeongm Date: Fri, 10 Jul 2026 16:51:32 +0900 Subject: [PATCH 2/6] feat(algo): add complexity scoring config keys to AlgoConfig 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. --- src/algo/AlgoConfig.h | 21 +++++++++++++++++++++ src/algo/AlgoDbWriter.cpp | 23 +++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/algo/AlgoConfig.h b/src/algo/AlgoConfig.h index e0e4b36..2ae86db 100644 --- a/src/algo/AlgoConfig.h +++ b/src/algo/AlgoConfig.h @@ -30,4 +30,25 @@ struct AlgoConfig { int bc_k_min = 50; uint64_t bc_seed = 42; + + // ── Complexity score (file-level heatmap) ────────────────────────────── + // complexity_score = w_cc*pct_cc + w_nesting*pct_nesting + w_loc*pct_loc + // + w_inbound*pct_inbound + w_outbound*pct_outbound + // Each pct_* is a percentile rank in [0,1] across all scored files. + double complexity_w_cc = 0.30; + double complexity_w_nesting = 0.20; + double complexity_w_loc = 0.15; + double complexity_w_inbound = 0.20; + double complexity_w_outbound = 0.15; + + // cc_score = cc_max_blend*max_cc + cc_avg_blend*avg_cc (pre-blend, before percentile-rank) + double complexity_cc_max_blend = 0.80; + double complexity_cc_avg_blend = 0.20; + + // nesting_score = nesting_max_blend*max_depth + nesting_avg_blend*avg_depth + double complexity_nesting_max_blend = 0.80; + double complexity_nesting_avg_blend = 0.20; + + // false(0): is_generated=1 files are excluded from the scoring population. + bool complexity_include_generated = false; }; diff --git a/src/algo/AlgoDbWriter.cpp b/src/algo/AlgoDbWriter.cpp index dab985a..38d0738 100644 --- a/src/algo/AlgoDbWriter.cpp +++ b/src/algo/AlgoDbWriter.cpp @@ -105,6 +105,18 @@ AlgoConfig AlgoDbWriter::loadConfig(const char* dbPath) cfg.bc_k_min = get_int (db, "bc_k_min", cfg.bc_k_min); cfg.bc_seed = get_uint64(db, "bc_seed", cfg.bc_seed); + cfg.complexity_w_cc = get_double(db, "complexity_w_cc", cfg.complexity_w_cc); + cfg.complexity_w_nesting = get_double(db, "complexity_w_nesting", cfg.complexity_w_nesting); + cfg.complexity_w_loc = get_double(db, "complexity_w_loc", cfg.complexity_w_loc); + cfg.complexity_w_inbound = get_double(db, "complexity_w_inbound", cfg.complexity_w_inbound); + cfg.complexity_w_outbound = get_double(db, "complexity_w_outbound", cfg.complexity_w_outbound); + cfg.complexity_cc_max_blend = get_double(db, "complexity_cc_max_blend", cfg.complexity_cc_max_blend); + cfg.complexity_cc_avg_blend = get_double(db, "complexity_cc_avg_blend", cfg.complexity_cc_avg_blend); + cfg.complexity_nesting_max_blend = get_double(db, "complexity_nesting_max_blend", cfg.complexity_nesting_max_blend); + cfg.complexity_nesting_avg_blend = get_double(db, "complexity_nesting_avg_blend", cfg.complexity_nesting_avg_blend); + cfg.complexity_include_generated = get_int(db, "complexity_include_generated", + cfg.complexity_include_generated ? 1 : 0) != 0; + sqlite3_close(db); return cfg; } @@ -204,6 +216,17 @@ int AlgoDbWriter::updateConfig(sqlite3* db, const AlgoConfig& cfg) i("bc_p2_fixed_k", cfg.bc_p2_fixed_k); i("bc_k_min", cfg.bc_k_min); u("bc_seed", cfg.bc_seed); + + d("complexity_w_cc", cfg.complexity_w_cc); + d("complexity_w_nesting", cfg.complexity_w_nesting); + d("complexity_w_loc", cfg.complexity_w_loc); + d("complexity_w_inbound", cfg.complexity_w_inbound); + d("complexity_w_outbound", cfg.complexity_w_outbound); + d("complexity_cc_max_blend", cfg.complexity_cc_max_blend); + d("complexity_cc_avg_blend", cfg.complexity_cc_avg_blend); + d("complexity_nesting_max_blend", cfg.complexity_nesting_max_blend); + d("complexity_nesting_avg_blend", cfg.complexity_nesting_avg_blend); + i("complexity_include_generated", cfg.complexity_include_generated ? 1 : 0); if (rc != SQLITE_OK) return rc; char ts[32]; From a3aa541ebc67ffb0837428aec479ee3250c5eab1 Mon Sep 17 00:00:00 2001 From: seojeongm Date: Fri, 10 Jul 2026 17:02:41 +0900 Subject: [PATCH 3/6] feat(algo): add percentile_rank_normalize helper with unit tests --- CMakeLists.txt | 1 + src/algo/Scoring.cpp | 23 ++++++++++++ src/algo/Scoring.h | 7 ++++ tests/algo/test_percentile_rank.cpp | 57 +++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+) create mode 100644 tests/algo/test_percentile_rank.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 86ca508..8cf13fa 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -216,6 +216,7 @@ add_executable(AlgoTests tests/algo/test_score_combiner.cpp tests/algo/test_betweenness_centrality.cpp tests/algo/test_algo_merge.cpp + tests/algo/test_percentile_rank.cpp ) target_include_directories(AlgoTests PRIVATE src) target_link_libraries(AlgoTests PRIVATE algo GTest::gtest_main) diff --git a/src/algo/Scoring.cpp b/src/algo/Scoring.cpp index 3cb1a91..9a8c8d4 100644 --- a/src/algo/Scoring.cpp +++ b/src/algo/Scoring.cpp @@ -75,6 +75,29 @@ static std::vector minmax_normalize(const std::vector& v) return out; } +// ── ComplexityScorer ──────────────────────────────────────────────────────── + +std::vector percentile_rank_normalize(const std::vector& values) +{ + const std::size_t N = values.size(); + if (N == 0) return {}; + if (N == 1) return {0.0}; + + std::vector sorted = values; + std::sort(sorted.begin(), sorted.end()); + + if (sorted.front() == sorted.back()) + return std::vector(N, 0.5); + + std::vector out(N); + for (std::size_t i = 0; i < N; ++i) { + auto it = std::lower_bound(sorted.begin(), sorted.end(), values[i]); + std::size_t strictlyLower = static_cast(it - sorted.begin()); + out[i] = static_cast(strictlyLower) / static_cast(N - 1); + } + return out; +} + std::vector ScoreCombiner::combine(const std::vector& pr, const std::vector& bc, double alpha, diff --git a/src/algo/Scoring.h b/src/algo/Scoring.h index 2cbea52..79d16c6 100644 --- a/src/algo/Scoring.h +++ b/src/algo/Scoring.h @@ -5,6 +5,13 @@ #include #include +// Percentile-rank normalization: position-based, so a single extreme outlier +// doesn't compress the rest of the population toward 0 the way min-max does. +// rank(i) = (# values strictly less than v[i]) / (N - 1); ties share a rank. +// N==0 -> {}; N==1 -> {0.0}; zero-variance input -> all 0.5. +// Exposed (not file-local static like minmax_normalize) so it's unit-testable. +std::vector percentile_rank_normalize(const std::vector& values); + class PageRank { public: static std::vector compute(const Graph& g, const AlgoConfig& cfg); diff --git a/tests/algo/test_percentile_rank.cpp b/tests/algo/test_percentile_rank.cpp new file mode 100644 index 0000000..b2d834f --- /dev/null +++ b/tests/algo/test_percentile_rank.cpp @@ -0,0 +1,57 @@ +#include + +#include "algo/Scoring.h" + +TEST(PercentileRankNormalize, EmptyInputReturnsEmpty) { + EXPECT_TRUE(percentile_rank_normalize({}).empty()); +} + +TEST(PercentileRankNormalize, SingleFileReturnsZero) { + auto out = percentile_rank_normalize({42.0}); + ASSERT_EQ(out.size(), 1u); + EXPECT_DOUBLE_EQ(out[0], 0.0); +} + +TEST(PercentileRankNormalize, AllSameValueReturnsHalf) { + auto out = percentile_rank_normalize({5.0, 5.0, 5.0, 5.0}); + ASSERT_EQ(out.size(), 4u); + for (double v : out) EXPECT_DOUBLE_EQ(v, 0.5); +} + +TEST(PercentileRankNormalize, ValuesInRangeZeroToOne) { + auto out = percentile_rank_normalize({10.0, 2.0, 7.0, 100.0, 3.0}); + for (double v : out) { + EXPECT_GE(v, 0.0); + EXPECT_LE(v, 1.0); + } +} + +TEST(PercentileRankNormalize, MinAndMaxHitZeroAndOne) { + auto out = percentile_rank_normalize({10.0, 2.0, 7.0, 100.0, 3.0}); + // index 1 (value 2.0) is the minimum -> 0.0; index 3 (value 100.0) is the max -> 1.0 + EXPECT_DOUBLE_EQ(out[1], 0.0); + EXPECT_DOUBLE_EQ(out[3], 1.0); +} + +TEST(PercentileRankNormalize, TiesShareTheSameRank) { + // sorted: 1, 5, 5, 5, 9 (N=5, N-1=4) + // strictly-lower counts: 1->0, 5->1 (all three), 9->4 + auto out = percentile_rank_normalize({5.0, 1.0, 5.0, 9.0, 5.0}); + EXPECT_DOUBLE_EQ(out[0], 0.25); + EXPECT_DOUBLE_EQ(out[2], 0.25); + EXPECT_DOUBLE_EQ(out[4], 0.25); + EXPECT_DOUBLE_EQ(out[1], 0.0); + EXPECT_DOUBLE_EQ(out[3], 1.0); +} + +TEST(PercentileRankNormalize, OneOutlierDoesNotCompressOthers) { + // Unlike min-max, a single huge outlier shouldn't push every other + // value's rank toward 0 -- ranks should stay spread out by position. + auto out = percentile_rank_normalize({10.0, 20.0, 30.0, 40.0, 5000.0}); + // sorted: 10,20,30,40,5000 -> N-1=4 + EXPECT_DOUBLE_EQ(out[0], 0.0); // 10 + EXPECT_DOUBLE_EQ(out[1], 0.25); // 20 + EXPECT_DOUBLE_EQ(out[2], 0.5); // 30 + EXPECT_DOUBLE_EQ(out[3], 0.75); // 40 + EXPECT_DOUBLE_EQ(out[4], 1.0); // 5000 +} From 596a7e68fb5480d2a589b83d9245d1d2563e79d7 Mon Sep 17 00:00:00 2001 From: seojeongm Date: Fri, 10 Jul 2026 17:09:09 +0900 Subject: [PATCH 4/6] feat(algo): add ComplexityScorer::compute (blend, percentile-rank, weighted sum) --- CMakeLists.txt | 1 + src/algo/Scoring.cpp | 72 ++++++++++++++++ src/algo/Scoring.h | 25 ++++++ tests/algo/test_complexity_scorer.cpp | 119 ++++++++++++++++++++++++++ 4 files changed, 217 insertions(+) create mode 100644 tests/algo/test_complexity_scorer.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 8cf13fa..4daf56c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -217,6 +217,7 @@ add_executable(AlgoTests tests/algo/test_betweenness_centrality.cpp tests/algo/test_algo_merge.cpp tests/algo/test_percentile_rank.cpp + tests/algo/test_complexity_scorer.cpp ) target_include_directories(AlgoTests PRIVATE src) target_link_libraries(AlgoTests PRIVATE algo GTest::gtest_main) diff --git a/src/algo/Scoring.cpp b/src/algo/Scoring.cpp index 9a8c8d4..525d693 100644 --- a/src/algo/Scoring.cpp +++ b/src/algo/Scoring.cpp @@ -2,6 +2,8 @@ #include #include +#include +#include #include #include #include @@ -98,6 +100,76 @@ std::vector percentile_rank_normalize(const std::vector& values) return out; } +// Normalizes a set of weights to sum to 1.0 if they don't already (+/- 0.001), +// logging a warning. Falls back to equal weights if the sum is ~0 (can't scale). +static void normalizeWeights(std::initializer_list weights, const char* label) +{ + double sum = 0.0; + for (double* w : weights) sum += *w; + if (std::fabs(sum - 1.0) <= 0.001) return; + + std::fprintf(stderr, + "ComplexityScorer: %s weights sum to %.6f (expected 1.0) -- auto-normalizing\n", + label, sum); + + if (std::fabs(sum) < 1e-9) { + const double eq = 1.0 / static_cast(weights.size()); + for (double* w : weights) *w = eq; + return; + } + for (double* w : weights) *w /= sum; +} + +std::vector ComplexityScorer::compute(const std::vector& inputs, + const AlgoConfig& cfg) +{ + const std::size_t N = inputs.size(); + if (N == 0) return {}; + + double w_cc = cfg.complexity_w_cc; + double w_nesting = cfg.complexity_w_nesting; + double w_loc = cfg.complexity_w_loc; + double w_inbound = cfg.complexity_w_inbound; + double w_outbound = cfg.complexity_w_outbound; + normalizeWeights({&w_cc, &w_nesting, &w_loc, &w_inbound, &w_outbound}, "top-level"); + + double cc_max_blend = cfg.complexity_cc_max_blend; + double cc_avg_blend = cfg.complexity_cc_avg_blend; + normalizeWeights({&cc_max_blend, &cc_avg_blend}, "cc pre-blend"); + + double nesting_max_blend = cfg.complexity_nesting_max_blend; + double nesting_avg_blend = cfg.complexity_nesting_avg_blend; + normalizeWeights({&nesting_max_blend, &nesting_avg_blend}, "nesting pre-blend"); + + std::vector cc_raw(N), nesting_raw(N), loc_raw(N), inbound_raw(N), outbound_raw(N); + for (std::size_t i = 0; i < N; ++i) { + const ComplexityFileMetrics& m = inputs[i]; + cc_raw[i] = cc_max_blend * m.max_cyclomatic_complexity + + cc_avg_blend * m.avg_cyclomatic_complexity; + nesting_raw[i] = nesting_max_blend * m.max_block_depth + + nesting_avg_blend * m.avg_block_depth; + loc_raw[i] = static_cast(m.logical_loc); + inbound_raw[i] = static_cast(m.inbound); + outbound_raw[i] = static_cast(m.outbound); + } + + std::vector pct_cc = percentile_rank_normalize(cc_raw); + std::vector pct_nesting = percentile_rank_normalize(nesting_raw); + std::vector pct_loc = percentile_rank_normalize(loc_raw); + std::vector pct_inbound = percentile_rank_normalize(inbound_raw); + std::vector pct_outbound = percentile_rank_normalize(outbound_raw); + + std::vector scores(N); + for (std::size_t i = 0; i < N; ++i) { + scores[i] = w_cc * pct_cc[i] + + w_nesting * pct_nesting[i] + + w_loc * pct_loc[i] + + w_inbound * pct_inbound[i] + + w_outbound * pct_outbound[i]; + } + return scores; +} + std::vector ScoreCombiner::combine(const std::vector& pr, const std::vector& bc, double alpha, diff --git a/src/algo/Scoring.h b/src/algo/Scoring.h index 79d16c6..597167a 100644 --- a/src/algo/Scoring.h +++ b/src/algo/Scoring.h @@ -3,6 +3,7 @@ #include "AlgoConfig.h" #include "Graph.h" #include +#include #include // Percentile-rank normalization: position-based, so a single extreme outlier @@ -25,6 +26,30 @@ class ScoreCombiner { double beta); }; +// Per-file inputs for ComplexityScorer, already joined with graph in/out-degree. +// (DB query + graph lookup happen in ComplexityScorer::computeAndWrite.) +struct ComplexityFileMetrics { + std::string file_id; + int max_cyclomatic_complexity = 0; + double avg_cyclomatic_complexity = 0.0; + int max_block_depth = 0; + double avg_block_depth = 0.0; + int logical_loc = 0; + int inbound = 0; + int outbound = 0; +}; + +class ComplexityScorer { +public: + // Pure computation: pre-blends CC/nesting (max/avg), percentile-ranks all + // five inputs (cc, nesting, logical_loc, inbound, outbound) independently, + // and combines them by configured weight. No DB access. Weight groups that + // don't sum to 1.0 (+/- 0.001) are auto-normalized with a stderr warning. + // Returns one score per file, same order as `inputs`. + static std::vector compute(const std::vector& inputs, + const AlgoConfig& cfg); +}; + // ── Betweenness Centrality ────────────────────────────────────────────────── // Pass 1(파일)/Pass 2(함수) 별로 그래프 규모에 따라 BC 계산 전략을 분기한다. diff --git a/tests/algo/test_complexity_scorer.cpp b/tests/algo/test_complexity_scorer.cpp new file mode 100644 index 0000000..58ca8f4 --- /dev/null +++ b/tests/algo/test_complexity_scorer.cpp @@ -0,0 +1,119 @@ +#include + +#include "algo/Scoring.h" + +namespace { + +std::vector threeFileFixture() { + // A: mid on everything; B: high; C: low -- chosen so default weights + // (0.30/0.20/0.15/0.20/0.15, sum exactly 1.0) and default blends + // (0.8 max / 0.2 avg) produce clean hand-computable percentile ranks. + ComplexityFileMetrics a; + a.file_id = "a.py"; + a.max_cyclomatic_complexity = 10; a.avg_cyclomatic_complexity = 5; + a.max_block_depth = 4; a.avg_block_depth = 2; + a.logical_loc = 100; a.inbound = 1; a.outbound = 0; + + ComplexityFileMetrics b; + b.file_id = "b.py"; + b.max_cyclomatic_complexity = 20; b.avg_cyclomatic_complexity = 15; + b.max_block_depth = 8; b.avg_block_depth = 6; + b.logical_loc = 200; b.inbound = 3; b.outbound = 2; + + ComplexityFileMetrics c; + c.file_id = "c.py"; + c.max_cyclomatic_complexity = 5; c.avg_cyclomatic_complexity = 3; + c.max_block_depth = 2; c.avg_block_depth = 1; + c.logical_loc = 50; c.inbound = 0; c.outbound = 5; + + return {a, b, c}; +} + +} // namespace + +TEST(ComplexityScorer, EmptyInputReturnsEmpty) { + AlgoConfig cfg; + EXPECT_TRUE(ComplexityScorer::compute({}, cfg).empty()); +} + +TEST(ComplexityScorer, WeightedPercentileRankCombinationMatchesHandComputed) { + AlgoConfig cfg; // defaults: w = 0.30/0.20/0.15/0.20/0.15, blends = 0.8/0.2 + auto scores = ComplexityScorer::compute(threeFileFixture(), cfg); + ASSERT_EQ(scores.size(), 3u); + + // cc_raw: a=0.8*10+0.2*5=9.0 b=0.8*20+0.2*15=19.0 c=0.8*5+0.2*3=4.6 + // nesting_raw: a=0.8*4+0.2*2=3.6 b=0.8*8+0.2*6=7.6 c=0.8*2+0.2*1=1.8 + // loc: a=100 b=200 c=50 | inbound: a=1 b=3 c=0 | outbound: a=0 b=2 c=5 + // Sorted order for cc/nesting/loc/inbound is always c < a < b -> pct: c=0, a=0.5, b=1.0 + // Sorted order for outbound is a < b < c -> pct: a=0, b=0.5, c=1.0 + // score_a = 0.30*.5 + 0.20*.5 + 0.15*.5 + 0.20*.5 + 0.15*0 = 0.425 + // score_b = 0.30*1 + 0.20*1 + 0.15*1 + 0.20*1 + 0.15*.5 = 0.925 + // score_c = 0.30*0 + 0.20*0 + 0.15*0 + 0.20*0 + 0.15*1 = 0.15 + EXPECT_NEAR(scores[0], 0.425, 1e-9); + EXPECT_NEAR(scores[1], 0.925, 1e-9); + EXPECT_NEAR(scores[2], 0.15, 1e-9); +} + +TEST(ComplexityScorer, TopLevelWeightsAutoNormalizeByRatio) { + // Uniform weights of 1.0 each (sum=5.0) should normalize to the same + // effective 0.2-each weighting as explicitly-normalized uniform weights. + AlgoConfig unnormalized; + unnormalized.complexity_w_cc = unnormalized.complexity_w_nesting = + unnormalized.complexity_w_loc = unnormalized.complexity_w_inbound = + unnormalized.complexity_w_outbound = 1.0; + + AlgoConfig normalized; + normalized.complexity_w_cc = normalized.complexity_w_nesting = + normalized.complexity_w_loc = normalized.complexity_w_inbound = + normalized.complexity_w_outbound = 0.2; + + auto inputs = threeFileFixture(); + auto s1 = ComplexityScorer::compute(inputs, unnormalized); + auto s2 = ComplexityScorer::compute(inputs, normalized); + + ASSERT_EQ(s1.size(), s2.size()); + for (std::size_t i = 0; i < s1.size(); ++i) + EXPECT_NEAR(s1[i], s2[i], 1e-9); +} + +TEST(ComplexityScorer, BlendWeightsAutoNormalizeByRatio) { + // cc blend of 2.0/2.0 (sum=4.0, ratio 1:1) should normalize the same as + // an explicit 0.5/0.5 blend. + AlgoConfig unnormalized; + unnormalized.complexity_cc_max_blend = 2.0; + unnormalized.complexity_cc_avg_blend = 2.0; + + AlgoConfig normalized; + normalized.complexity_cc_max_blend = 0.5; + normalized.complexity_cc_avg_blend = 0.5; + + auto inputs = threeFileFixture(); + auto s1 = ComplexityScorer::compute(inputs, unnormalized); + auto s2 = ComplexityScorer::compute(inputs, normalized); + + ASSERT_EQ(s1.size(), s2.size()); + for (std::size_t i = 0; i < s1.size(); ++i) + EXPECT_NEAR(s1[i], s2[i], 1e-9); +} + +TEST(ComplexityScorer, LogicalLocInboundOutboundFeedDirectlyWithoutBlending) { + // Isolate loc/inbound/outbound by zeroing every other weight -- the + // resulting score should equal a plain weighted percentile-rank of the + // raw (unblended) values. + AlgoConfig cfg; + cfg.complexity_w_cc = 0.0; + cfg.complexity_w_nesting = 0.0; + cfg.complexity_w_loc = 1.0; + cfg.complexity_w_inbound = 0.0; + cfg.complexity_w_outbound = 0.0; + + auto inputs = threeFileFixture(); + auto scores = ComplexityScorer::compute(inputs, cfg); + + std::vector loc_raw = {100.0, 200.0, 50.0}; + auto expected = percentile_rank_normalize(loc_raw); + + ASSERT_EQ(scores.size(), expected.size()); + for (std::size_t i = 0; i < scores.size(); ++i) + EXPECT_NEAR(scores[i], expected[i], 1e-9); +} From bb6f0033ef88dfc46ac3781515ad09d7f3596863 Mon Sep 17 00:00:00 2001 From: seojeongm Date: Fri, 10 Jul 2026 17:13:47 +0900 Subject: [PATCH 5/6] feat(algo): add ComplexityScorer::computeAndWrite (DB read/write + graph join) --- src/algo/Scoring.cpp | 71 +++++++++++++++++ src/algo/Scoring.h | 7 ++ tests/algo/test_complexity_scorer.cpp | 105 ++++++++++++++++++++++++++ 3 files changed, 183 insertions(+) diff --git a/src/algo/Scoring.cpp b/src/algo/Scoring.cpp index 525d693..2832004 100644 --- a/src/algo/Scoring.cpp +++ b/src/algo/Scoring.cpp @@ -1,5 +1,7 @@ #include "Scoring.h" +#include + #include #include #include @@ -170,6 +172,75 @@ std::vector ComplexityScorer::compute(const std::vector inputs; + while (sqlite3_step(stmt) == SQLITE_ROW) { + const char* fid = reinterpret_cast(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(file_graph.radj[nid].size()); + m.outbound = static_cast(file_graph.adj[nid].size()); + } + + inputs.push_back(std::move(m)); + } + sqlite3_finalize(stmt); + + std::vector scores = compute(inputs, cfg); + + rc = sqlite3_exec(db, "BEGIN;", nullptr, nullptr, nullptr); + if (rc != SQLITE_OK) return rc; + + sqlite3_stmt* upd = nullptr; + rc = sqlite3_prepare_v2(db, + "UPDATE file SET complexity_score = ? WHERE file_id = ?;", -1, &upd, nullptr); + if (rc != SQLITE_OK) { + sqlite3_exec(db, "ROLLBACK;", nullptr, nullptr, nullptr); + return rc; + } + + for (std::size_t i = 0; i < inputs.size(); ++i) { + sqlite3_bind_double(upd, 1, scores[i]); + sqlite3_bind_text(upd, 2, inputs[i].file_id.c_str(), -1, SQLITE_STATIC); + rc = sqlite3_step(upd); + sqlite3_reset(upd); + if (rc != SQLITE_DONE) { + sqlite3_finalize(upd); + sqlite3_exec(db, "ROLLBACK;", nullptr, nullptr, nullptr); + return rc; + } + } + sqlite3_finalize(upd); + + return sqlite3_exec(db, "COMMIT;", nullptr, nullptr, nullptr); +} + std::vector ScoreCombiner::combine(const std::vector& pr, const std::vector& bc, double alpha, diff --git a/src/algo/Scoring.h b/src/algo/Scoring.h index 597167a..26b8602 100644 --- a/src/algo/Scoring.h +++ b/src/algo/Scoring.h @@ -48,6 +48,13 @@ class ComplexityScorer { // Returns one score per file, same order as `inputs`. static std::vector compute(const std::vector& inputs, const AlgoConfig& cfg); + + // Reads file_id + the five rollup metrics from the `file` table (excluding + // is_generated=1 rows unless cfg.complexity_include_generated), joins each + // file_id to `file_graph` for inbound/outbound degree, computes scores via + // compute(), and writes complexity_score back to `file` in one transaction. + // Returns SQLITE_OK or the sqlite error code that failed. + static int computeAndWrite(sqlite3* db, const Graph& file_graph, const AlgoConfig& cfg); }; // ── Betweenness Centrality ────────────────────────────────────────────────── diff --git a/tests/algo/test_complexity_scorer.cpp b/tests/algo/test_complexity_scorer.cpp index 58ca8f4..11aaf13 100644 --- a/tests/algo/test_complexity_scorer.cpp +++ b/tests/algo/test_complexity_scorer.cpp @@ -1,6 +1,8 @@ #include #include "algo/Scoring.h" +#include "db/db.h" +#include namespace { @@ -117,3 +119,106 @@ TEST(ComplexityScorer, LogicalLocInboundOutboundFeedDirectlyWithoutBlending) { for (std::size_t i = 0; i < scores.size(); ++i) EXPECT_NEAR(scores[i], expected[i], 1e-9); } + +namespace { + +double readComplexityScore(sqlite3* db, const std::string& fileId) { + sqlite3_stmt* stmt = nullptr; + std::string sql = "SELECT complexity_score FROM file WHERE file_id = ?;"; + sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr); + sqlite3_bind_text(stmt, 1, fileId.c_str(), -1, SQLITE_STATIC); + double value = -1.0; + if (sqlite3_step(stmt) == SQLITE_ROW) value = sqlite3_column_double(stmt, 0); + sqlite3_finalize(stmt); + return value; +} + +} // namespace + +TEST(ComplexityScorerComputeAndWrite, WritesScoresMatchingDirectComputeAndExcludesGenerated) { + sqlite3* db = nullptr; + ASSERT_EQ(initDb(":memory:", &db), SQLITE_OK); + + char* err = nullptr; + int rc = sqlite3_exec(db, + "INSERT INTO file(file_id, file_name, language, raw_loc, logical_loc, is_generated," + " max_cyclomatic_complexity, avg_cyclomatic_complexity, max_block_depth, avg_block_depth) VALUES" + " ('a.py','a.py','python',100,100,0,10,5,4,2)," + " ('b.py','b.py','python',200,200,0,20,15,8,6)," + " ('gen.py','gen.py','python',9999,9999,1,999,999,999,999);", + nullptr, nullptr, &err); + ASSERT_EQ(rc, SQLITE_OK) << (err ? err : ""); + + Graph g; + NodeId a = g.get_or_add("a.py"); + NodeId b = g.get_or_add("b.py"); + g.get_or_add("gen.py"); + g.adj[a].push_back(b); // a -> b: a.outbound=1, b.inbound=1 + g.radj[b].push_back(a); + + AlgoConfig cfg; // complexity_include_generated = false by default + ASSERT_EQ(ComplexityScorer::computeAndWrite(db, g, cfg), SQLITE_OK); + + // gen.py wasn't in the scored population -- left at the column default. + EXPECT_DOUBLE_EQ(readComplexityScore(db, "gen.py"), 0.0); + + // a.py/b.py scores should equal calling compute() directly on the same + // (unblended) inputs -- i.e. the DB read + graph join didn't corrupt anything. + std::vector inputs(2); + inputs[0].file_id = "a.py"; + inputs[0].max_cyclomatic_complexity = 10; inputs[0].avg_cyclomatic_complexity = 5; + inputs[0].max_block_depth = 4; inputs[0].avg_block_depth = 2; + inputs[0].logical_loc = 100; inputs[0].inbound = 0; inputs[0].outbound = 1; + inputs[1].file_id = "b.py"; + inputs[1].max_cyclomatic_complexity = 20; inputs[1].avg_cyclomatic_complexity = 15; + inputs[1].max_block_depth = 8; inputs[1].avg_block_depth = 6; + inputs[1].logical_loc = 200; inputs[1].inbound = 1; inputs[1].outbound = 0; + auto expected = ComplexityScorer::compute(inputs, cfg); + + EXPECT_NEAR(readComplexityScore(db, "a.py"), expected[0], 1e-9); + EXPECT_NEAR(readComplexityScore(db, "b.py"), expected[1], 1e-9); + + sqlite3_close(db); +} + +TEST(ComplexityScorerComputeAndWrite, IncludeGeneratedFlagScoresGeneratedFilesToo) { + sqlite3* db = nullptr; + ASSERT_EQ(initDb(":memory:", &db), SQLITE_OK); + + char* err = nullptr; + int rc = sqlite3_exec(db, + "INSERT INTO file(file_id, file_name, language, raw_loc, is_generated) VALUES" + " ('a.py','a.py','python',100,0)," + " ('gen.py','gen.py','python',50,1);", + nullptr, nullptr, &err); + ASSERT_EQ(rc, SQLITE_OK) << (err ? err : ""); + + Graph g; + g.get_or_add("a.py"); + g.get_or_add("gen.py"); + + AlgoConfig cfg; + cfg.complexity_include_generated = true; + ASSERT_EQ(ComplexityScorer::computeAndWrite(db, g, cfg), SQLITE_OK); + + // Every unset metric column defaults to 0 and both files are isolated + // graph nodes, so a.py and gen.py have identical (all-zero) inputs -- + // the all-tied case percentile-ranks both to 0.5, and default weights + // sum to 1.0, so a real (non-default) score of exactly 0.5 proves + // gen.py was actually included in the scored population, not skipped. + EXPECT_DOUBLE_EQ(readComplexityScore(db, "gen.py"), 0.5); + EXPECT_DOUBLE_EQ(readComplexityScore(db, "a.py"), 0.5); + + sqlite3_close(db); +} + +TEST(ComplexityScorerComputeAndWrite, EmptyFileTableSucceedsWithoutError) { + sqlite3* db = nullptr; + ASSERT_EQ(initDb(":memory:", &db), SQLITE_OK); + + Graph g; + AlgoConfig cfg; + EXPECT_EQ(ComplexityScorer::computeAndWrite(db, g, cfg), SQLITE_OK); + + sqlite3_close(db); +} From 78c6b46ee75fdff472fc74c5f06e4504c4a43f4a Mon Sep 17 00:00:00 2001 From: seojeongm Date: Fri, 10 Jul 2026 17:15:18 +0900 Subject: [PATCH 6/6] feat(algo): wire ComplexityScorer into AlgoRunner::run --- src/algo/AlgoRunner.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/algo/AlgoRunner.cpp b/src/algo/AlgoRunner.cpp index 5029d46..fe272ab 100644 --- a/src/algo/AlgoRunner.cpp +++ b/src/algo/AlgoRunner.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -126,6 +127,16 @@ AlgoRunResult AlgoRunner::run(const char* dbPath, const AlgoConfig& cfg) auto gbr = GraphBuilder::build(db); + // Complexity scoring is independent of the PageRank/BC reading-sequence + // pipeline below (writes directly to the `file` table), but needs the + // same open db handle and file_graph -- best-effort, doesn't block the + // reading-sequence result on failure. + int complexityRc = ComplexityScorer::computeAndWrite(db, gbr.file_graph, cfg); + if (complexityRc != SQLITE_OK) { + std::fprintf(stderr, "AlgoRunner: complexity scoring failed (code %d): %s\n", + complexityRc, sqlite3_errmsg(db)); + } + // Build loc_hint for file-level pass (LOC descending tie-break) const Graph& fg = gbr.file_graph; std::vector file_loc_hint(fg.size(), 0);