Skip to content

Commit d93fdad

Browse files
committed
feat(ci-server): personalize search/locate ranking toward session journey
search/locate ranking was 100% static (BM25/RRF/coreness) with zero awareness of what the current session has actually been exploring — Aider-style personalized-PageRank was pure gap. SessionLog now records the tool-call index at which each file/symbol was last touched, not just membership. search/locate additively boost results whose file is import/call-adjacent to a recently-touched anchor (score += personalization_weight * decay), decayed by tool-call distance rather than wall-clock, then re-sort — never overriding a strong text/semantic match, and never touching the shared is_hub/coreness columns other sessions' results depend on. A cold session (nothing explored yet) or personalization_weight=0.0 is a byte-for-byte no-op. A top-level `personalized: bool` flag reports whether boosting actually happened, so it's never silent. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JC2bYmsArFCvhSReTVAeAZ
1 parent cdff755 commit d93fdad

5 files changed

Lines changed: 481 additions & 13 deletions

File tree

crates/ci-core/src/config.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,15 @@ pub struct SearchConfig {
105105
pub text_chunk_context_lines: usize,
106106
pub text_max_chunk_lines: usize,
107107
pub rrf_k: usize,
108+
/// Additive weight applied to the session-journey proximity boost (see
109+
/// `CodeIntelligenceServer::apply_personalization_boost`) before
110+
/// re-ranking `search`/`locate` results — a result whose file is
111+
/// import/call-adjacent to something this session recently explored
112+
/// gets `score += personalization_weight * boost` (`boost` in `(0, 1]`).
113+
/// Additive-only by construction: it can nudge ordering among
114+
/// close-scoring results but a low default keeps it from overriding a
115+
/// strong text/semantic match. `0.0` disables personalization entirely.
116+
pub personalization_weight: f64,
108117
}
109118

110119
impl Default for SearchConfig {
@@ -113,6 +122,7 @@ impl Default for SearchConfig {
113122
text_chunk_context_lines: 10,
114123
text_max_chunk_lines: 50,
115124
rrf_k: 20,
125+
personalization_weight: 0.15,
116126
}
117127
}
118128
}

crates/ci-server/src/tools.rs

Lines changed: 117 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,21 +77,26 @@ fn days_to_ymd(mut days: u64) -> (u64, u64, u64) {
7777
(year, month, days + 1)
7878
}
7979

80-
/// In-memory session tracking — tool call count and the set of symbols/files
81-
/// touched, for the `session_context` tool. Reset only when the server restarts.
80+
/// In-memory session tracking — tool call count and the symbols/files
81+
/// touched, for the `session_context` tool. Reset only when the server
82+
/// restarts. Values are the `tool_calls` count at the most recent touch (not
83+
/// a boolean "seen"): `apply_personalization_boost` uses that to decay a
84+
/// result's proximity boost by how long ago (in tool-calls, not wall-clock)
85+
/// the connecting file/symbol was last explored — a re-touch refreshes it,
86+
/// same "attention" semantics as re-reading something brings it back to mind.
8287
struct SessionLog {
8388
tool_calls: u64,
84-
explored_symbols: std::collections::HashSet<String>,
85-
explored_files: std::collections::HashSet<String>,
89+
explored_symbols: std::collections::HashMap<String, u64>,
90+
explored_files: std::collections::HashMap<String, u64>,
8691
session_started_at: String,
8792
}
8893

8994
impl Default for SessionLog {
9095
fn default() -> Self {
9196
Self {
9297
tool_calls: 0,
93-
explored_symbols: std::collections::HashSet::new(),
94-
explored_files: std::collections::HashSet::new(),
98+
explored_symbols: std::collections::HashMap::new(),
99+
explored_files: std::collections::HashMap::new(),
95100
session_started_at: utc_now_iso8601(),
96101
}
97102
}
@@ -2471,6 +2476,112 @@ mod tests {
24712476
let _ = std::fs::remove_dir_all(&dir);
24722477
}
24732478

2479+
#[test]
2480+
fn locate_boosts_result_near_recently_explored_file() {
2481+
let dir = std::env::temp_dir().join(format!("ci_locate_personalize_{}", std::process::id()));
2482+
let _ = std::fs::remove_dir_all(&dir);
2483+
std::fs::create_dir_all(&dir).unwrap();
2484+
let server = CodeIntelligenceServer::new(dir.clone(), dir.join("index.db")).unwrap();
2485+
2486+
{
2487+
let conn = server.db();
2488+
conn.execute(
2489+
"INSERT INTO symbols (name, qualified_name, kind, language, path, line_start, line_end,
2490+
signature, docstring, name_tokens, caller_count, is_hub, is_entry_point)
2491+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
2492+
rusqlite::params![
2493+
"helper_fn", "mod::helper_fn", "function", "rust", "b.rs",
2494+
1i64, 5i64, "fn helper_fn()", "", "helper fn",
2495+
0i64, 0i64, 0i64
2496+
],
2497+
).unwrap();
2498+
// a.rs imports b.rs — tracking a.rs should boost a search hit in b.rs.
2499+
conn.execute(
2500+
"INSERT INTO import_edges (from_path, to_path, module_name) VALUES ('a.rs', 'b.rs', 'b')",
2501+
[],
2502+
).unwrap();
2503+
}
2504+
2505+
let params = || LocateParams {
2506+
query: "helper_fn".into(),
2507+
kind: None,
2508+
depth: Some("search_only".into()),
2509+
limit: None,
2510+
};
2511+
2512+
let baseline = server.locate(params());
2513+
let bv: serde_json::Value = serde_json::from_str(&baseline).unwrap();
2514+
assert_eq!(
2515+
bv["personalized"], false,
2516+
"a session that hasn't explored anything must not personalize"
2517+
);
2518+
let baseline_score = bv["results"][0]["score"].as_f64().unwrap();
2519+
2520+
server.track_file("a.rs");
2521+
2522+
let boosted = server.locate(params());
2523+
let boostv: serde_json::Value = serde_json::from_str(&boosted).unwrap();
2524+
assert_eq!(boostv["personalized"], true);
2525+
let boosted_score = boostv["results"][0]["score"].as_f64().unwrap();
2526+
2527+
// track_file ran between two `locate` calls (each a tool call, so
2528+
// tool_calls is now 2); a.rs was touched at tool_calls=1 — distance 1,
2529+
// decay 1/(1+1)=0.5, default personalization_weight=0.15.
2530+
let expected_delta = 0.15 * 0.5;
2531+
assert!(
2532+
(boosted_score - baseline_score - expected_delta).abs() < 1e-9,
2533+
"expected +{expected_delta}, got baseline={baseline_score} boosted={boosted_score}"
2534+
);
2535+
2536+
let _ = std::fs::remove_dir_all(&dir);
2537+
}
2538+
2539+
#[test]
2540+
fn locate_personalization_weight_zero_disables_boost() {
2541+
let dir = std::env::temp_dir().join(format!("ci_locate_personalize_off_{}", std::process::id()));
2542+
let _ = std::fs::remove_dir_all(&dir);
2543+
std::fs::create_dir_all(&dir).unwrap();
2544+
std::fs::write(
2545+
dir.join("config.json"),
2546+
r#"{"search": {"personalization_weight": 0.0}}"#,
2547+
)
2548+
.unwrap();
2549+
let server = CodeIntelligenceServer::new(dir.clone(), dir.join("index.db")).unwrap();
2550+
2551+
{
2552+
let conn = server.db();
2553+
conn.execute(
2554+
"INSERT INTO symbols (name, qualified_name, kind, language, path, line_start, line_end,
2555+
signature, docstring, name_tokens, caller_count, is_hub, is_entry_point)
2556+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
2557+
rusqlite::params![
2558+
"helper_fn", "mod::helper_fn", "function", "rust", "b.rs",
2559+
1i64, 5i64, "fn helper_fn()", "", "helper fn",
2560+
0i64, 0i64, 0i64
2561+
],
2562+
).unwrap();
2563+
conn.execute(
2564+
"INSERT INTO import_edges (from_path, to_path, module_name) VALUES ('a.rs', 'b.rs', 'b')",
2565+
[],
2566+
).unwrap();
2567+
}
2568+
2569+
server.track_file("a.rs");
2570+
let output = server.locate(LocateParams {
2571+
query: "helper_fn".into(),
2572+
kind: None,
2573+
depth: Some("search_only".into()),
2574+
limit: None,
2575+
});
2576+
let v: serde_json::Value = serde_json::from_str(&output).unwrap();
2577+
assert_eq!(
2578+
v["personalized"], false,
2579+
"personalization_weight=0.0 must fully disable boosting"
2580+
);
2581+
2582+
let _ = std::fs::remove_dir_all(&dir);
2583+
}
2584+
24742585
/// Regression for Task 15: `session_context` had no config knob bounding
24752586
/// `explored_symbols`/`explored_files` — a long session dumped an
24762587
/// unbounded list into every call.

0 commit comments

Comments
 (0)