kiri-mcp-server 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -11
- package/config/scoring-profiles.yml +78 -0
- package/config/stop-words.yml +307 -0
- package/dist/config/scoring-profiles.yml +78 -0
- package/dist/config/stop-words.yml +307 -0
- package/dist/package.json +2 -2
- package/dist/src/indexer/cli.d.ts +1 -0
- package/dist/src/indexer/cli.d.ts.map +1 -1
- package/dist/src/indexer/cli.js +22 -2
- package/dist/src/indexer/cli.js.map +1 -1
- package/dist/src/indexer/cochange.d.ts +97 -0
- package/dist/src/indexer/cochange.d.ts.map +1 -0
- package/dist/src/indexer/cochange.js +315 -0
- package/dist/src/indexer/cochange.js.map +1 -0
- package/dist/src/indexer/graph-metrics.d.ts +68 -0
- package/dist/src/indexer/graph-metrics.d.ts.map +1 -0
- package/dist/src/indexer/graph-metrics.js +239 -0
- package/dist/src/indexer/graph-metrics.js.map +1 -0
- package/dist/src/indexer/schema.d.ts +15 -0
- package/dist/src/indexer/schema.d.ts.map +1 -1
- package/dist/src/indexer/schema.js +86 -0
- package/dist/src/indexer/schema.js.map +1 -1
- package/dist/src/server/handlers/snippets-get.d.ts +10 -0
- package/dist/src/server/handlers/snippets-get.d.ts.map +1 -1
- package/dist/src/server/handlers/snippets-get.js +40 -3
- package/dist/src/server/handlers/snippets-get.js.map +1 -1
- package/dist/src/server/handlers.d.ts +1 -1
- package/dist/src/server/handlers.d.ts.map +1 -1
- package/dist/src/server/handlers.js +187 -51
- package/dist/src/server/handlers.js.map +1 -1
- package/dist/src/server/idf-provider.d.ts +110 -0
- package/dist/src/server/idf-provider.d.ts.map +1 -0
- package/dist/src/server/idf-provider.js +233 -0
- package/dist/src/server/idf-provider.js.map +1 -0
- package/dist/src/server/rpc.d.ts.map +1 -1
- package/dist/src/server/rpc.js +21 -1
- package/dist/src/server/rpc.js.map +1 -1
- package/dist/src/server/scoring.d.ts +10 -0
- package/dist/src/server/scoring.d.ts.map +1 -1
- package/dist/src/server/scoring.js +73 -0
- package/dist/src/server/scoring.js.map +1 -1
- package/dist/src/server/services/index.d.ts +2 -0
- package/dist/src/server/services/index.d.ts.map +1 -1
- package/dist/src/server/services/index.js +3 -0
- package/dist/src/server/services/index.js.map +1 -1
- package/dist/src/server/stop-words.d.ts +106 -0
- package/dist/src/server/stop-words.d.ts.map +1 -0
- package/dist/src/server/stop-words.js +312 -0
- package/dist/src/server/stop-words.js.map +1 -0
- package/dist/src/shared/duckdb.d.ts +8 -2
- package/dist/src/shared/duckdb.d.ts.map +1 -1
- package/dist/src/shared/duckdb.js +37 -62
- package/dist/src/shared/duckdb.js.map +1 -1
- package/package.json +2 -2
|
@@ -8,8 +8,10 @@ import { expandAbbreviations } from "./abbreviations.js";
|
|
|
8
8
|
import { getBoostProfile, } from "./boost-profiles.js";
|
|
9
9
|
import { loadPathPenalties, mergePathPenaltyEntries } from "./config-loader.js";
|
|
10
10
|
import { loadServerConfig } from "./config.js";
|
|
11
|
+
import { createIdfProvider } from "./idf-provider.js";
|
|
11
12
|
import { coerceProfileName, loadScoringProfile } from "./scoring.js";
|
|
12
13
|
import { createServerServices } from "./services/index.js";
|
|
14
|
+
import { loadStopWords } from "./stop-words.js";
|
|
13
15
|
// Re-export extracted handlers for backward compatibility
|
|
14
16
|
export { snippetsGet, } from "./handlers/snippets-get.js";
|
|
15
17
|
// Configuration file patterns (v0.8.0+: consolidated to avoid duplication)
|
|
@@ -533,6 +535,8 @@ const CLAMP_SNIPPETS_ENABLED = serverConfig.features.clampSnippets;
|
|
|
533
535
|
const FALLBACK_SNIPPET_WINDOW = serverConfig.features.snippetWindow;
|
|
534
536
|
const MAX_RERANK_LIMIT = 50;
|
|
535
537
|
const MAX_ARTIFACT_HINTS = 8;
|
|
538
|
+
/** Minimum confidence floor for co-change scoring to prevent zero-boost from low Jaccard scores */
|
|
539
|
+
const MIN_COCHANGE_CONFIDENCE_FLOOR = 0.2;
|
|
536
540
|
const DOMAIN_PATH_HINT_LIMIT = MAX_ARTIFACT_HINTS;
|
|
537
541
|
const SAFE_PATH_PATTERN = /^[a-zA-Z0-9_.\-/]+$/;
|
|
538
542
|
const HINT_PRIORITY_TEXT_MULTIPLIER = serverConfig.hints.priority.textMultiplier;
|
|
@@ -590,15 +594,16 @@ const WHY_TAG_PRIORITY = {
|
|
|
590
594
|
substring: 4, // Substring hint expansion
|
|
591
595
|
"path-phrase": 5, // Path contains multi-word phrase
|
|
592
596
|
structural: 6, // Semantic similarity
|
|
593
|
-
|
|
594
|
-
"path-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
597
|
+
cochange: 7, // Co-change history (files that change together)
|
|
598
|
+
"path-segment": 8, // Path component matches
|
|
599
|
+
"path-keyword": 9, // Path keyword match
|
|
600
|
+
dep: 10, // Dependency relationship
|
|
601
|
+
near: 11, // Proximity to editing file
|
|
602
|
+
boost: 12, // File type boost
|
|
603
|
+
recent: 13, // Recently changed
|
|
604
|
+
symbol: 14, // Symbol match
|
|
605
|
+
penalty: 15, // Penalty explanations (keep for transparency)
|
|
606
|
+
keyword: 16, // Generic keyword (deprecated, kept for compatibility)
|
|
602
607
|
};
|
|
603
608
|
// Reserve at least one slot for important structural tags
|
|
604
609
|
const RESERVED_WHY_SLOTS = {
|
|
@@ -663,39 +668,18 @@ function selectWhyTags(reasons) {
|
|
|
663
668
|
}
|
|
664
669
|
return Array.from(selected);
|
|
665
670
|
}
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
"into",
|
|
679
|
-
"about",
|
|
680
|
-
"there",
|
|
681
|
-
"their",
|
|
682
|
-
"your",
|
|
683
|
-
"fix",
|
|
684
|
-
"test",
|
|
685
|
-
"tests",
|
|
686
|
-
"issue",
|
|
687
|
-
"error",
|
|
688
|
-
"bug",
|
|
689
|
-
"fail",
|
|
690
|
-
"failing",
|
|
691
|
-
"make",
|
|
692
|
-
"when",
|
|
693
|
-
"where",
|
|
694
|
-
"should",
|
|
695
|
-
"could",
|
|
696
|
-
"need",
|
|
697
|
-
"goal",
|
|
698
|
-
]);
|
|
671
|
+
/**
|
|
672
|
+
* ストップワードサービスの遅延初期化
|
|
673
|
+
* シングルトンキャッシュを使用し、config/stop-words.yml から読み込み
|
|
674
|
+
* @see Issue #48: Improve context_bundle stop word coverage and configurability
|
|
675
|
+
*/
|
|
676
|
+
let _stopWordsService = null;
|
|
677
|
+
function getStopWordsService() {
|
|
678
|
+
if (!_stopWordsService) {
|
|
679
|
+
_stopWordsService = loadStopWords();
|
|
680
|
+
}
|
|
681
|
+
return _stopWordsService;
|
|
682
|
+
}
|
|
699
683
|
function prioritizeHintCandidates(rankedCandidates, hintPaths, limit) {
|
|
700
684
|
if (rankedCandidates.length === 0) {
|
|
701
685
|
return [];
|
|
@@ -822,7 +806,7 @@ function extractCompoundTerms(text) {
|
|
|
822
806
|
const matches = Array.from(text.matchAll(compoundPattern)).map((m) => m[1]);
|
|
823
807
|
return matches
|
|
824
808
|
.map((term) => term.toLowerCase())
|
|
825
|
-
.filter((term) => term.length >= 3 && !
|
|
809
|
+
.filter((term) => term.length >= 3 && !getStopWordsService().has(term));
|
|
826
810
|
}
|
|
827
811
|
/**
|
|
828
812
|
* パスライクな用語を抽出
|
|
@@ -837,7 +821,7 @@ function extractPathSegments(text) {
|
|
|
837
821
|
for (const path of matches) {
|
|
838
822
|
const parts = path.toLowerCase().split("/");
|
|
839
823
|
for (const part of parts) {
|
|
840
|
-
if (part.length >= 3 && !
|
|
824
|
+
if (part.length >= 3 && !getStopWordsService().has(part) && !segments.includes(part)) {
|
|
841
825
|
segments.push(part);
|
|
842
826
|
}
|
|
843
827
|
}
|
|
@@ -849,7 +833,7 @@ function extractPathSegments(text) {
|
|
|
849
833
|
* 共有トークン化ユーティリティを使用
|
|
850
834
|
*/
|
|
851
835
|
function extractRegularWords(text, strategy) {
|
|
852
|
-
const words = tokenizeText(text, strategy).filter((word) => word.length >= 3 && !
|
|
836
|
+
const words = tokenizeText(text, strategy).filter((word) => word.length >= 3 && !getStopWordsService().has(word));
|
|
853
837
|
return words;
|
|
854
838
|
}
|
|
855
839
|
/**
|
|
@@ -879,7 +863,7 @@ function extractKeywords(text) {
|
|
|
879
863
|
// ハイフンとアンダースコアの両方で分割
|
|
880
864
|
const parts = term
|
|
881
865
|
.split(/[-_]/)
|
|
882
|
-
.filter((part) => part.length >= 3 && !
|
|
866
|
+
.filter((part) => part.length >= 3 && !getStopWordsService().has(part));
|
|
883
867
|
result.keywords.push(...parts);
|
|
884
868
|
}
|
|
885
869
|
}
|
|
@@ -904,7 +888,7 @@ function addKeywordDerivedPathSegments(result) {
|
|
|
904
888
|
}
|
|
905
889
|
const additional = [];
|
|
906
890
|
for (const keyword of result.keywords) {
|
|
907
|
-
if (keyword.length < 3 ||
|
|
891
|
+
if (keyword.length < 3 || getStopWordsService().has(keyword)) {
|
|
908
892
|
continue;
|
|
909
893
|
}
|
|
910
894
|
if (result.pathSegments.includes(keyword) || additional.includes(keyword)) {
|
|
@@ -1348,6 +1332,133 @@ function applyStructuralScores(candidates, queryEmbedding, structuralWeight) {
|
|
|
1348
1332
|
candidate.reasons.add(`structural:${similarity.toFixed(2)}`);
|
|
1349
1333
|
}
|
|
1350
1334
|
}
|
|
1335
|
+
/**
|
|
1336
|
+
* Graph Layer: Apply graph-based scoring boosts (Phase 3.2)
|
|
1337
|
+
*
|
|
1338
|
+
* Uses precomputed metrics from graph_metrics table:
|
|
1339
|
+
* - inbound_count: Number of files that import this file (PageRank-like importance)
|
|
1340
|
+
* - importance_score: Normalized PageRank score [0, 1]
|
|
1341
|
+
*
|
|
1342
|
+
* Boosts are additive and scaled by profile weights.
|
|
1343
|
+
*/
|
|
1344
|
+
async function applyGraphLayerScores(db, repoId, candidates, weights) {
|
|
1345
|
+
// Skip if both weights are zero (disabled)
|
|
1346
|
+
if (weights.graphInbound <= 0 && weights.graphImportance <= 0) {
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
if (candidates.length === 0) {
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
// Fetch graph metrics for all candidate paths
|
|
1353
|
+
const paths = candidates.map((c) => c.path);
|
|
1354
|
+
const placeholders = paths.map(() => "?").join(", ");
|
|
1355
|
+
const metrics = await db.all(`
|
|
1356
|
+
SELECT path, inbound_count, importance_score
|
|
1357
|
+
FROM graph_metrics
|
|
1358
|
+
WHERE repo_id = ? AND path IN (${placeholders})
|
|
1359
|
+
`, [repoId, ...paths]);
|
|
1360
|
+
// Build lookup map
|
|
1361
|
+
const metricsMap = new Map();
|
|
1362
|
+
for (const m of metrics) {
|
|
1363
|
+
metricsMap.set(m.path, {
|
|
1364
|
+
inbound: m.inbound_count,
|
|
1365
|
+
importance: m.importance_score,
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
// Compute max inbound for normalization (log scale)
|
|
1369
|
+
let maxInbound = 1;
|
|
1370
|
+
for (const m of metrics) {
|
|
1371
|
+
if (m.inbound_count > maxInbound) {
|
|
1372
|
+
maxInbound = m.inbound_count;
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
// Apply boosts
|
|
1376
|
+
for (const candidate of candidates) {
|
|
1377
|
+
const graphMetrics = metricsMap.get(candidate.path);
|
|
1378
|
+
if (!graphMetrics) {
|
|
1379
|
+
continue;
|
|
1380
|
+
}
|
|
1381
|
+
// Inbound dependency boost (log-scaled to dampen very high values)
|
|
1382
|
+
if (weights.graphInbound > 0 && graphMetrics.inbound > 0) {
|
|
1383
|
+
// Log-scale normalization: log(1 + count) / log(1 + max)
|
|
1384
|
+
const normalizedInbound = Math.log(1 + graphMetrics.inbound) / Math.log(1 + maxInbound);
|
|
1385
|
+
const inboundBoost = weights.graphInbound * normalizedInbound;
|
|
1386
|
+
candidate.score += inboundBoost;
|
|
1387
|
+
candidate.reasons.add(`graph:inbound:${graphMetrics.inbound}`);
|
|
1388
|
+
}
|
|
1389
|
+
// Importance score boost (already normalized to [0, 1])
|
|
1390
|
+
if (weights.graphImportance > 0 && graphMetrics.importance > 0) {
|
|
1391
|
+
const importanceBoost = weights.graphImportance * graphMetrics.importance;
|
|
1392
|
+
candidate.score += importanceBoost;
|
|
1393
|
+
candidate.reasons.add(`graph:importance:${graphMetrics.importance.toFixed(2)}`);
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
/**
|
|
1398
|
+
* Apply co-change scores based on git history.
|
|
1399
|
+
* Files that frequently change together with editing_path get boosted.
|
|
1400
|
+
*
|
|
1401
|
+
* Phase 4: Co-change graph integration.
|
|
1402
|
+
*
|
|
1403
|
+
* @param db - DuckDB client
|
|
1404
|
+
* @param repoId - Repository ID
|
|
1405
|
+
* @param candidates - Candidate files to score
|
|
1406
|
+
* @param weights - Scoring weights (uses cochange weight)
|
|
1407
|
+
* @param editingPath - Currently edited file path (optional)
|
|
1408
|
+
*/
|
|
1409
|
+
async function applyCochangeScores(db, repoId, candidates, weights, editingPath) {
|
|
1410
|
+
// Skip if cochange weight is zero (disabled by default)
|
|
1411
|
+
if (weights.cochange <= 0) {
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
// Skip if no editing_path provided (co-change needs a reference file)
|
|
1415
|
+
if (!editingPath || candidates.length === 0) {
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
// Query co-change edges involving editing_path
|
|
1419
|
+
// Both directions: editing_path can be file1 or file2 (canonical ordering)
|
|
1420
|
+
const cochangeEdges = await db.all(`
|
|
1421
|
+
SELECT
|
|
1422
|
+
CASE WHEN file1 = ? THEN file2 ELSE file1 END as neighbor,
|
|
1423
|
+
cochange_count,
|
|
1424
|
+
confidence
|
|
1425
|
+
FROM cochange
|
|
1426
|
+
WHERE repo_id = ? AND (file1 = ? OR file2 = ?)
|
|
1427
|
+
`, [editingPath, repoId, editingPath, editingPath]);
|
|
1428
|
+
if (cochangeEdges.length === 0) {
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
// Build lookup map: neighbor path -> (count, confidence)
|
|
1432
|
+
const cochangeMap = new Map();
|
|
1433
|
+
for (const edge of cochangeEdges) {
|
|
1434
|
+
cochangeMap.set(edge.neighbor, {
|
|
1435
|
+
count: edge.cochange_count,
|
|
1436
|
+
confidence: edge.confidence ?? 0,
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
// Compute max count for normalization
|
|
1440
|
+
let maxCount = 1;
|
|
1441
|
+
for (const edge of cochangeEdges) {
|
|
1442
|
+
if (edge.cochange_count > maxCount) {
|
|
1443
|
+
maxCount = edge.cochange_count;
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
// Apply cochange boost to candidates
|
|
1447
|
+
for (const candidate of candidates) {
|
|
1448
|
+
const cochange = cochangeMap.get(candidate.path);
|
|
1449
|
+
if (!cochange || cochange.count <= 0) {
|
|
1450
|
+
continue;
|
|
1451
|
+
}
|
|
1452
|
+
// Normalize cochange count using log scale (similar to inbound boost)
|
|
1453
|
+
const normalizedCount = Math.log(1 + cochange.count) / Math.log(1 + maxCount);
|
|
1454
|
+
// Weight the boost by confidence (Jaccard similarity) for quality
|
|
1455
|
+
// Final boost = weight * normalized_count * confidence
|
|
1456
|
+
const confidenceFactor = Math.max(cochange.confidence, MIN_COCHANGE_CONFIDENCE_FLOOR);
|
|
1457
|
+
const cochangeBoost = weights.cochange * normalizedCount * confidenceFactor;
|
|
1458
|
+
candidate.score += cochangeBoost;
|
|
1459
|
+
candidate.reasons.add(`cochange:${cochange.count}:${(cochange.confidence * 100).toFixed(0)}%`);
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1351
1462
|
async function fetchEmbeddingMap(db, repoId, paths) {
|
|
1352
1463
|
const map = new Map();
|
|
1353
1464
|
if (paths.length === 0) {
|
|
@@ -2829,12 +2940,26 @@ async function contextBundleImpl(context, params) {
|
|
|
2829
2940
|
const pathSegments = artifacts.editing_path
|
|
2830
2941
|
.split(/[/_.-]/)
|
|
2831
2942
|
.map((segment) => segment.toLowerCase())
|
|
2832
|
-
.filter((segment) => segment.length >= 3 && !
|
|
2943
|
+
.filter((segment) => segment.length >= 3 && !getStopWordsService().has(segment));
|
|
2833
2944
|
extractedTerms.pathSegments.push(...pathSegments.slice(0, MAX_KEYWORDS));
|
|
2834
2945
|
}
|
|
2835
2946
|
const candidates = new Map();
|
|
2836
2947
|
const stringMatchSeeds = new Set();
|
|
2837
2948
|
const fileCache = new Map();
|
|
2949
|
+
// Phase 2: IDF重み付けプロバイダーの初期化
|
|
2950
|
+
// キーワードの文書頻度に基づいて重みを計算し、高頻度語を自動的に減衰
|
|
2951
|
+
const idfProvider = createIdfProvider(db, repoId);
|
|
2952
|
+
const idfWeights = new Map();
|
|
2953
|
+
// 抽出されたキーワードのIDF重みを事前計算(非同期バッチ処理)
|
|
2954
|
+
if (extractedTerms.keywords.length > 0) {
|
|
2955
|
+
const computedWeights = await idfProvider.computeIdfBatch(extractedTerms.keywords);
|
|
2956
|
+
for (const [term, weight] of computedWeights) {
|
|
2957
|
+
idfWeights.set(term, weight);
|
|
2958
|
+
}
|
|
2959
|
+
if (process.env.KIRI_TRACE_IDF === "1") {
|
|
2960
|
+
console.info("[idf-weights]", JSON.stringify(Object.fromEntries(Array.from(idfWeights.entries()).map(([k, v]) => [k, v.toFixed(3)]))));
|
|
2961
|
+
}
|
|
2962
|
+
}
|
|
2838
2963
|
// ✅ Cache boost profile config to avoid redundant lookups in hot path
|
|
2839
2964
|
const boostProfile = params.boost_profile ??
|
|
2840
2965
|
(hasHintMetadataFilters ? "balanced" : hasStrictMetadataFilters ? "docs" : "default");
|
|
@@ -2961,10 +3086,17 @@ async function contextBundleImpl(context, params) {
|
|
|
2961
3086
|
continue; // Should not happen, but defensive check
|
|
2962
3087
|
}
|
|
2963
3088
|
const candidate = ensureCandidate(candidates, row.path);
|
|
2964
|
-
//
|
|
3089
|
+
// 各マッチしたキーワードに対してスコアリング(Phase 2: IDF重み付け)
|
|
2965
3090
|
for (const keyword of matchedKeywords) {
|
|
2966
|
-
|
|
2967
|
-
|
|
3091
|
+
// IDF重みを適用(事前計算済み、なければデフォルト1.0)
|
|
3092
|
+
// 減衰適用: 0.6 + 0.4 * idfWeight でファイル種別マルチプライヤとのバランスを維持
|
|
3093
|
+
// - 高頻度語: IDF=0 → 0.6 (40%減)
|
|
3094
|
+
// - 低頻度語: IDF=1 → 1.0 (減衰なし)
|
|
3095
|
+
const rawIdfWeight = idfWeights.get(keyword.toLowerCase()) ?? 1.0;
|
|
3096
|
+
const dampedIdfWeight = 0.6 + 0.4 * rawIdfWeight;
|
|
3097
|
+
const weightedScore = weights.textMatch * dampedIdfWeight;
|
|
3098
|
+
candidate.score += weightedScore;
|
|
3099
|
+
candidate.reasons.add(`text:${keyword}:idf=${rawIdfWeight.toFixed(2)}`);
|
|
2968
3100
|
candidate.keywordHits.add(keyword);
|
|
2969
3101
|
}
|
|
2970
3102
|
// Apply boost profile once per file
|
|
@@ -3338,6 +3470,10 @@ async function contextBundleImpl(context, params) {
|
|
|
3338
3470
|
}
|
|
3339
3471
|
}
|
|
3340
3472
|
applyStructuralScores(materializedCandidates, queryEmbedding, weights.structural);
|
|
3473
|
+
// Phase 3.2: Apply graph layer scoring (inbound dependencies, PageRank importance)
|
|
3474
|
+
await applyGraphLayerScores(db, repoId, materializedCandidates, weights);
|
|
3475
|
+
// Phase 4: Apply co-change scores (files that change together with editing_path)
|
|
3476
|
+
await applyCochangeScores(db, repoId, materializedCandidates, weights, artifacts.editing_path);
|
|
3341
3477
|
// ✅ CRITICAL SAFETY: Apply multipliers AFTER all additive scoring (v0.7.0)
|
|
3342
3478
|
// Only apply to positive scores to prevent negative score inversion
|
|
3343
3479
|
for (const candidate of materializedCandidates) {
|