Skip to content

Commit f66cd7d

Browse files
committed
feat(review): weight feedback reinforcement by trust
1 parent 2030d5c commit f66cd7d

19 files changed

Lines changed: 639 additions & 98 deletions

File tree

TODO.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ This roadmap is derived from deep research into Greptile's public docs, blog, MC
2626
2. [x] Infer "addressed by later commit" by diffing follow-up pushes against the original commented lines.
2727
3. [x] Feed addressed/not-addressed outcomes into the reinforcement store alongside thumbs.
2828
4. [x] Separate false-positive rejections from "valid but won't fix" dismissals in stored feedback.
29-
5. [ ] Weight reinforcement by reviewer role or trust level when GitHub identity is available.
29+
5. [x] Weight reinforcement by reviewer role or trust level when GitHub identity is available.
3030
6. [x] Add rule-level reinforcement decay so old team preferences do not dominate forever.
3131
7. [x] Add path-scoped reinforcement buckets so teams can prefer different standards in `tests/`, `scripts/`, and production code.
3232
8. [x] Persist explanation text from follow-up feedback replies and mine it into reusable review guidance.

src/commands/feedback_eval/input.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ mod tests {
9797
file_patterns: vec!["*.rs".to_string()],
9898
accepted: false,
9999
created_at: "2026-03-13T00:00:00Z".to_string(),
100+
weight: 1.0,
100101
embedding: vec![],
101102
}],
102103
embedding: Default::default(),

src/core/semantic.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,16 @@ impl SemanticFeedbackStore {
5151
&example.file_patterns,
5252
example.accepted,
5353
);
54-
if self.examples.iter().any(|existing| {
54+
if let Some(existing) = self.examples.iter_mut().find(|existing| {
5555
feedback_example_fingerprint(
5656
&existing.content,
5757
&existing.category,
5858
&existing.file_patterns,
5959
existing.accepted,
6060
) == fingerprint
6161
}) {
62+
existing.weight += example.weight.max(0.0);
63+
existing.created_at = example.created_at;
6264
return;
6365
}
6466
self.examples.push(example);
@@ -696,11 +698,18 @@ mod tests {
696698
file_patterns: vec!["*.rs".to_string()],
697699
accepted: false,
698700
created_at: "2026-03-13T00:00:00Z".to_string(),
701+
weight: 1.0,
699702
embedding: local_hash_embedding("Style nit"),
700703
};
701704
store.add_example(example.clone());
702-
store.add_example(example);
705+
store.add_example(SemanticFeedbackExample {
706+
weight: 1.5,
707+
created_at: "2026-03-14T00:00:00Z".to_string(),
708+
..example
709+
});
703710
assert_eq!(store.examples.len(), 1);
711+
assert!((store.examples[0].weight - 2.5).abs() < f32::EPSILON);
712+
assert_eq!(store.examples[0].created_at, "2026-03-14T00:00:00Z");
704713
}
705714

706715
#[tokio::test]

src/core/semantic/types.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ pub struct SemanticFeedbackExample {
3939
pub file_patterns: Vec<String>,
4040
pub accepted: bool,
4141
pub created_at: String,
42+
#[serde(default = "default_feedback_weight")]
43+
pub weight: f32,
4244
pub embedding: Vec<f32>,
4345
}
4446

@@ -50,6 +52,10 @@ pub struct SemanticFeedbackStore {
5052
pub embedding: SemanticEmbeddingMetadata,
5153
}
5254

55+
fn default_feedback_weight() -> f32 {
56+
1.0
57+
}
58+
5359
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
5460
pub struct SemanticFileState {
5561
pub content_hash: String,

src/review/feedback.rs

Lines changed: 81 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,21 @@ pub use persistence::{load_feedback_store, load_feedback_store_from_path, save_f
2020
#[allow(unused_imports)]
2121
pub use record::{
2222
apply_comment_dismissal_signal, apply_comment_feedback_signal,
23-
apply_comment_feedback_signal_at, apply_comment_resolution_outcome_signal,
24-
apply_comment_resolution_outcome_signal_at, record_comment_dismissal_stats,
25-
record_comment_feedback_stats, record_comment_feedback_stats_at,
23+
apply_comment_feedback_signal_at, apply_comment_feedback_signal_with_weight,
24+
apply_comment_resolution_outcome_signal, apply_comment_resolution_outcome_signal_at,
25+
record_comment_dismissal_stats, record_comment_feedback_stats,
26+
record_comment_feedback_stats_at, record_comment_feedback_stats_with_weight,
2627
record_comment_resolution_stats, record_comment_resolution_stats_at, CommentResolutionOutcome,
2728
};
2829
#[allow(unused_imports)]
29-
pub use semantic::{record_semantic_feedback_example, record_semantic_feedback_examples};
30+
pub use semantic::{
31+
record_semantic_feedback_example, record_semantic_feedback_examples,
32+
record_semantic_feedback_examples_with_weight,
33+
};
3034
#[allow(unused_imports)]
3135
pub use store::{
32-
DecayedFeedbackStats, FeedbackExplanation, FeedbackPatternStats, FeedbackStore,
33-
FeedbackTypeStats,
36+
DecayedFeedbackStats, FeedbackActorMetadata, FeedbackExplanation, FeedbackPatternStats,
37+
FeedbackStore, FeedbackTypeStats,
3438
};
3539

3640
#[cfg(test)]
@@ -53,6 +57,7 @@ mod tests {
5357
assert!(store.by_rule.is_empty());
5458
assert!(store.by_rule_file_pattern.is_empty());
5559
assert!(store.explanations_by_comment.is_empty());
60+
assert!(store.feedback_actor_by_comment.is_empty());
5661
}
5762

5863
#[test]
@@ -83,14 +88,12 @@ mod tests {
8388
FeedbackPatternStats {
8489
accepted: 1,
8590
rejected: 2,
86-
dismissed: 0,
87-
addressed: 0,
88-
not_addressed: 0,
8991
decayed: Some(DecayedFeedbackStats {
9092
positive: 0.75,
9193
negative: 1.25,
9294
last_event_at: Some(123),
9395
}),
96+
..Default::default()
9497
},
9598
);
9699
store.by_comment_type.insert(
@@ -101,6 +104,7 @@ mod tests {
101104
dismissed: 3,
102105
addressed: 4,
103106
not_addressed: 5,
107+
..Default::default()
104108
},
105109
);
106110
store.explanations_by_comment.insert(
@@ -116,6 +120,15 @@ mod tests {
116120
updated_at: "2026-03-15T00:00:00Z".to_string(),
117121
},
118122
);
123+
store.feedback_actor_by_comment.insert(
124+
"review-1::comment-1".to_string(),
125+
FeedbackActorMetadata {
126+
github_login: Some("maintainer".to_string()),
127+
github_role: Some("write".to_string()),
128+
trust_weight: 1.5,
129+
updated_at: "2026-03-15T00:00:00Z".to_string(),
130+
},
131+
);
119132

120133
let json = serde_json::to_string(&store).unwrap();
121134
let deserialized: FeedbackStore = serde_json::from_str(&json).unwrap();
@@ -134,6 +147,12 @@ mod tests {
134147
Some(2.0)
135148
);
136149
assert_eq!(deserialized.explanations_by_comment.len(), 1);
150+
assert_eq!(
151+
deserialized.feedback_actor_by_comment["review-1::comment-1"]
152+
.github_role
153+
.as_deref(),
154+
Some("write")
155+
);
137156
}
138157

139158
#[test]
@@ -155,11 +174,7 @@ mod tests {
155174
fn pattern_stats_acceptance_rate_all_accepted() {
156175
let stats = FeedbackPatternStats {
157176
accepted: 10,
158-
rejected: 0,
159-
dismissed: 0,
160-
addressed: 0,
161-
not_addressed: 0,
162-
decayed: None,
177+
..Default::default()
163178
};
164179
assert_eq!(stats.acceptance_rate(), 1.0);
165180
assert_eq!(stats.total(), 10);
@@ -168,12 +183,8 @@ mod tests {
168183
#[test]
169184
fn pattern_stats_acceptance_rate_all_rejected() {
170185
let stats = FeedbackPatternStats {
171-
accepted: 0,
172186
rejected: 10,
173-
dismissed: 0,
174-
addressed: 0,
175-
not_addressed: 0,
176-
decayed: None,
187+
..Default::default()
177188
};
178189
assert_eq!(stats.acceptance_rate(), 0.0);
179190
}
@@ -183,23 +194,48 @@ mod tests {
183194
let stats = FeedbackPatternStats {
184195
accepted: 3,
185196
rejected: 7,
186-
dismissed: 0,
187-
addressed: 0,
188-
not_addressed: 0,
189-
decayed: None,
197+
..Default::default()
190198
};
191199
assert!((stats.acceptance_rate() - 0.3).abs() < f32::EPSILON);
192200
}
193201

202+
#[test]
203+
fn pattern_stats_weighted_acceptance_rate_prefers_weighted_signal() {
204+
let stats = FeedbackPatternStats {
205+
accepted: 1,
206+
rejected: 1,
207+
accepted_weight: 3.0,
208+
rejected_weight: 1.0,
209+
..Default::default()
210+
};
211+
212+
assert!((stats.weighted_acceptance_rate() - 0.75).abs() < f32::EPSILON);
213+
assert!((stats.weighted_total() - 4.0).abs() < f32::EPSILON);
214+
}
215+
216+
#[test]
217+
fn pattern_stats_weighted_acceptance_rate_preserves_legacy_counts_without_weights() {
218+
let stats = FeedbackPatternStats {
219+
accepted: 1,
220+
addressed: 1,
221+
rejected: 1,
222+
accepted_weight: 2.0,
223+
..Default::default()
224+
};
225+
226+
assert!((stats.weighted_positive_total() - 3.0).abs() < f32::EPSILON);
227+
assert!((stats.weighted_negative_total() - 1.0).abs() < f32::EPSILON);
228+
assert!((stats.weighted_acceptance_rate() - 0.75).abs() < f32::EPSILON);
229+
}
230+
194231
#[test]
195232
fn pattern_stats_acceptance_rate_includes_outcome_signals() {
196233
let stats = FeedbackPatternStats {
197234
accepted: 1,
198235
rejected: 1,
199-
dismissed: 0,
200236
addressed: 3,
201237
not_addressed: 1,
202-
decayed: None,
238+
..Default::default()
203239
};
204240

205241
assert!((stats.acceptance_rate() - (4.0 / 6.0)).abs() < f32::EPSILON);
@@ -416,6 +452,26 @@ mod tests {
416452
"Tenant isolation must stay explicit to avoid cross-account reads.",
417453
"2026-03-15T00:01:00Z",
418454
));
455+
assert!(store.record_feedback_actor(
456+
"review-1",
457+
"comment-1",
458+
FeedbackActorMetadata {
459+
github_login: Some("repo-owner".to_string()),
460+
github_role: Some("admin".to_string()),
461+
trust_weight: 2.0,
462+
updated_at: "2026-03-15T00:00:00Z".to_string(),
463+
}
464+
));
465+
assert!(store.record_feedback_actor(
466+
"review-2",
467+
"comment-2",
468+
FeedbackActorMetadata {
469+
github_login: Some("triager".to_string()),
470+
github_role: Some("triage".to_string()),
471+
trust_weight: 1.25,
472+
updated_at: "2026-03-15T00:01:00Z".to_string(),
473+
}
474+
));
419475

420476
let context = generate_feedback_context(&store);
421477
assert!(

src/review/feedback/context.rs

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ const MAX_EXPLANATION_GUIDANCE_ITEMS: usize = 4;
55

66
#[derive(Default)]
77
struct ExplanationGuidanceBucket {
8-
accepted: usize,
9-
rejected: usize,
8+
observations: usize,
9+
accepted: f32,
10+
rejected: f32,
1011
snippets: Vec<String>,
1112
}
1213

@@ -22,7 +23,7 @@ pub fn generate_feedback_context(store: &FeedbackStore) -> String {
2223
if stats.total() < min_observations {
2324
continue;
2425
}
25-
let rate = stats.acceptance_rate();
26+
let rate = stats.weighted_acceptance_rate();
2627
if rate >= 0.7 {
2728
patterns.push(format!(
2829
"- {} findings usually produce positive outcomes ({:.0}% positive reinforcement rate) — be thorough on {} issues",
@@ -40,7 +41,7 @@ pub fn generate_feedback_context(store: &FeedbackStore) -> String {
4041
if stats.total() < min_observations {
4142
continue;
4243
}
43-
let rate = stats.acceptance_rate();
44+
let rate = stats.weighted_acceptance_rate();
4445
if rate < 0.3 {
4546
patterns.push(format!(
4647
"- Comments on {} files rarely produce positive outcomes ({:.0}% positive reinforcement rate) — be more conservative",
@@ -52,16 +53,21 @@ pub fn generate_feedback_context(store: &FeedbackStore) -> String {
5253
let mut explanation_buckets =
5354
std::collections::HashMap::<String, ExplanationGuidanceBucket>::new();
5455
for explanation in store.explanations_by_comment.values() {
56+
let trust_weight = store
57+
.feedback_actor(&explanation.review_id, &explanation.comment_id)
58+
.map(|actor| actor.trust_weight)
59+
.unwrap_or(1.0);
5560
let bucket_key = explanation
5661
.rule_id
5762
.as_deref()
5863
.map(|rule_id| format!("rule::{rule_id}"))
5964
.unwrap_or_else(|| format!("category::{}", explanation.category));
6065
let bucket = explanation_buckets.entry(bucket_key).or_default();
66+
bucket.observations += 1;
6167
if explanation.action == "accept" {
62-
bucket.accepted += 1;
68+
bucket.accepted += trust_weight;
6369
} else {
64-
bucket.rejected += 1;
70+
bucket.rejected += trust_weight;
6571
}
6672

6773
if let Some(snippet) = explanation_snippet(&explanation.text) {
@@ -79,9 +85,9 @@ pub fn generate_feedback_context(store: &FeedbackStore) -> String {
7985
.into_iter()
8086
.filter_map(|(bucket_key, bucket)| {
8187
let total = bucket.accepted + bucket.rejected;
82-
if total < MIN_EXPLANATION_OBSERVATIONS
88+
if bucket.observations < MIN_EXPLANATION_OBSERVATIONS
8389
|| bucket.snippets.is_empty()
84-
|| bucket.accepted == bucket.rejected
90+
|| (bucket.accepted - bucket.rejected).abs() <= f32::EPSILON
8591
{
8692
return None;
8793
}
@@ -114,8 +120,12 @@ pub fn generate_feedback_context(store: &FeedbackStore) -> String {
114120
}
115121
})
116122
.collect::<Vec<_>>();
117-
explanation_guidance
118-
.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1)));
123+
explanation_guidance.sort_by(|left, right| {
124+
right
125+
.0
126+
.total_cmp(&left.0)
127+
.then_with(|| left.1.cmp(&right.1))
128+
});
119129
patterns.extend(
120130
explanation_guidance
121131
.into_iter()

0 commit comments

Comments
 (0)