1use open_kioku_core::{
2 AnalysisFact, ChurnSummary, CodeChunk, EvidenceSourceType, File, FileId, FileProvenance,
3 GitCochangeEdge, GitCommitRecord, GraphEdge, GraphEdgeType, GraphNode, GraphNodeType,
4 HistorySignalQuery, HistorySignalSummary, HistorySnapshot, HistorySummary, ImpactReport,
5 Import, IndexManifest, ScoreComponent, SearchResult, SimilarChangeQuery, SimilarChangeReport,
6 Symbol, SymbolId, SymbolOccurrence, SymbolProvenance, TestTarget,
7};
8use open_kioku_errors::{OkError, Result};
9use std::collections::BTreeSet;
10use std::path::Path;
11
12pub trait MetadataStore: Send + Sync {
13 fn initialize(&self) -> Result<()>;
14 fn put_manifest(&self, manifest: &IndexManifest) -> Result<()>;
15 fn manifest(&self) -> Result<Option<IndexManifest>>;
16 fn replace_index(&self, data: IndexData<'_>) -> Result<()>;
17 fn replace_files_index(&self, _update: PartialIndexUpdate<'_>) -> Result<()> {
18 Err(OkError::Unsupported(
19 "partial index replacement is not implemented by this metadata store".into(),
20 ))
21 }
22 fn list_files(&self, limit: usize, offset: usize) -> Result<Vec<File>>;
23 fn get_file_by_path(&self, path: &Path) -> Result<Option<File>>;
24 fn list_symbols(&self, query: Option<&str>, limit: usize, offset: usize)
25 -> Result<Vec<Symbol>>;
26 fn symbol_by_id(&self, id: &SymbolId) -> Result<Option<Symbol>>;
27 fn chunks_for_file(&self, file_id: &FileId) -> Result<Vec<CodeChunk>>;
28 fn all_chunks(&self) -> Result<Vec<CodeChunk>>;
29 fn tests(&self) -> Result<Vec<TestTarget>>;
30 fn imports(&self) -> Result<Vec<Import>>;
31 fn analysis_facts(
32 &self,
33 _source_type: Option<EvidenceSourceType>,
34 _limit: usize,
35 ) -> Result<Vec<AnalysisFact>> {
36 Ok(Vec::new())
37 }
38 fn references_for_symbol(&self, id: &SymbolId, limit: usize) -> Result<Vec<SymbolOccurrence>>;
39 fn occurrences_for_file(&self, file_id: &FileId) -> Result<Vec<SymbolOccurrence>>;
40 fn symbols_for_file(&self, _file_id: &FileId) -> Result<Vec<Symbol>> {
41 Ok(Vec::new())
42 }
43 fn find_chunks_containing(&self, query: &str, limit: usize) -> Result<Vec<CodeChunk>> {
44 let chunks = self.all_chunks()?;
45 let mut results = Vec::new();
46 for chunk in chunks {
47 if chunk.text.contains(query) {
48 results.push(chunk);
49 if results.len() >= limit {
50 break;
51 }
52 }
53 }
54 Ok(results)
55 }
56 fn find_files_by_path_pattern(&self, pattern: &str) -> Result<Vec<File>> {
57 let files = self.list_files(usize::MAX, 0)?;
58 let lower_pattern = pattern.to_ascii_lowercase();
59 Ok(files
60 .into_iter()
61 .filter(|f| {
62 f.path
63 .to_string_lossy()
64 .to_ascii_lowercase()
65 .contains(&lower_pattern)
66 })
67 .collect())
68 }
69 fn tests_for_files(&self, file_ids: &[FileId]) -> Result<Vec<TestTarget>> {
70 let tests = self.tests()?;
71 let set = file_ids.iter().collect::<std::collections::HashSet<_>>();
72 Ok(tests
73 .into_iter()
74 .filter(|t| set.contains(&t.file_id))
75 .collect())
76 }
77}
78
79pub struct IndexData<'a> {
80 pub manifest: &'a IndexManifest,
81 pub files: &'a [File],
82 pub symbols: &'a [Symbol],
83 pub chunks: &'a [CodeChunk],
84 pub tests: &'a [TestTarget],
85 pub imports: &'a [Import],
86 pub occurrences: &'a [SymbolOccurrence],
87 pub analysis_facts: &'a [AnalysisFact],
88}
89
90pub struct PartialIndexUpdate<'a> {
91 pub manifest: &'a IndexManifest,
92 pub changed_files: &'a [File],
93 pub deleted_file_ids: &'a [FileId],
94 pub symbols: &'a [Symbol],
95 pub chunks: &'a [CodeChunk],
96 pub tests: &'a [TestTarget],
97 pub imports: &'a [Import],
98 pub occurrences: &'a [SymbolOccurrence],
99 pub analysis_facts: &'a [AnalysisFact],
100 pub graph_nodes: &'a [GraphNode],
101 pub graph_edges: &'a [GraphEdge],
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum IndexChangeKind {
106 Unchanged,
107 Modified,
108 Added,
109 Deleted,
110 Renamed,
111 ModeSkipped,
112 ParserVersionStale,
113 SchemaVersionStale,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct IndexChange {
118 pub old_path: Option<std::path::PathBuf>,
119 pub new_path: Option<std::path::PathBuf>,
120 pub file_id: Option<FileId>,
121 pub kind: IndexChangeKind,
122}
123
124pub fn classify_file_changes(
125 previous_manifest: Option<&IndexManifest>,
126 next_manifest: &IndexManifest,
127 previous_files: &[File],
128 next_files: &[File],
129) -> Vec<IndexChange> {
130 classify_file_changes_with_parser_version(
131 previous_manifest,
132 next_manifest,
133 previous_files,
134 next_files,
135 None,
136 None,
137 )
138}
139
140pub fn classify_file_changes_with_parser_version(
141 previous_manifest: Option<&IndexManifest>,
142 next_manifest: &IndexManifest,
143 previous_files: &[File],
144 next_files: &[File],
145 previous_parser_version: Option<&str>,
146 next_parser_version: Option<&str>,
147) -> Vec<IndexChange> {
148 if previous_manifest
149 .is_some_and(|manifest| manifest.schema_version != next_manifest.schema_version)
150 {
151 return next_files
152 .iter()
153 .map(|file| IndexChange {
154 old_path: Some(file.path.clone()),
155 new_path: Some(file.path.clone()),
156 file_id: Some(file.id.clone()),
157 kind: IndexChangeKind::SchemaVersionStale,
158 })
159 .collect();
160 }
161 if previous_parser_version
162 .zip(next_parser_version)
163 .is_some_and(|(previous, next)| previous != next)
164 {
165 return next_files
166 .iter()
167 .map(|file| IndexChange {
168 old_path: Some(file.path.clone()),
169 new_path: Some(file.path.clone()),
170 file_id: Some(file.id.clone()),
171 kind: IndexChangeKind::ParserVersionStale,
172 })
173 .collect();
174 }
175 if previous_manifest.is_some_and(|manifest| manifest.index_mode != next_manifest.index_mode) {
176 return next_files
177 .iter()
178 .map(|file| IndexChange {
179 old_path: Some(file.path.clone()),
180 new_path: Some(file.path.clone()),
181 file_id: Some(file.id.clone()),
182 kind: IndexChangeKind::ModeSkipped,
183 })
184 .collect();
185 }
186
187 let previous_by_id = previous_files
188 .iter()
189 .map(|file| (&file.id, file))
190 .collect::<std::collections::BTreeMap<_, _>>();
191 let next_by_id = next_files
192 .iter()
193 .map(|file| (&file.id, file))
194 .collect::<std::collections::BTreeMap<_, _>>();
195 let mut changes = Vec::new();
196 for file in next_files {
197 let kind = match previous_by_id.get(&file.id) {
198 None => IndexChangeKind::Added,
199 Some(previous) if previous.path != file.path => IndexChangeKind::Renamed,
200 Some(previous) if previous.content_hash != file.content_hash => {
201 IndexChangeKind::Modified
202 }
203 Some(_) => IndexChangeKind::Unchanged,
204 };
205 let old_path = previous_by_id.get(&file.id).map(|file| file.path.clone());
206 changes.push(IndexChange {
207 old_path,
208 new_path: Some(file.path.clone()),
209 file_id: Some(file.id.clone()),
210 kind,
211 });
212 }
213 for file in previous_files {
214 if !next_by_id.contains_key(&file.id) {
215 changes.push(IndexChange {
216 old_path: Some(file.path.clone()),
217 new_path: None,
218 file_id: Some(file.id.clone()),
219 kind: IndexChangeKind::Deleted,
220 });
221 }
222 }
223 changes.sort_by(|left, right| {
224 left.new_path
225 .as_ref()
226 .or(left.old_path.as_ref())
227 .cmp(&right.new_path.as_ref().or(right.old_path.as_ref()))
228 });
229 changes
230}
231
232pub fn partial_index_supported(previous: Option<&IndexManifest>, next: &IndexManifest) -> bool {
233 previous.is_some_and(|previous| {
234 previous.schema_version == next.schema_version && previous.index_mode == next.index_mode
235 })
236}
237
238pub fn partial_index_supported_for_versions(
239 previous: Option<&IndexManifest>,
240 next: &IndexManifest,
241 previous_parser_version: Option<&str>,
242 next_parser_version: Option<&str>,
243) -> bool {
244 partial_index_supported(previous, next)
245 && previous_parser_version
246 .zip(next_parser_version)
247 .map(|(previous, next)| previous == next)
248 .unwrap_or(true)
249}
250
251#[cfg(test)]
252mod tests {
253 use super::{
254 classify_file_changes, classify_file_changes_with_parser_version, IndexChangeKind,
255 };
256 use chrono::Utc;
257 use open_kioku_core::{
258 File, FileId, IndexManifest, IndexQuality, Language, Repository, RepositoryId,
259 };
260 use std::path::PathBuf;
261
262 #[test]
263 fn classifies_added_modified_deleted_and_renamed_files() {
264 let previous = vec![
265 file("stable", "src/stable.rs", "a"),
266 file("modified", "src/modified.rs", "a"),
267 file("renamed", "src/old.rs", "a"),
268 file("deleted", "src/deleted.rs", "a"),
269 ];
270 let next = vec![
271 file("stable", "src/stable.rs", "a"),
272 file("modified", "src/modified.rs", "b"),
273 file("renamed", "src/new.rs", "a"),
274 file("added", "src/added.rs", "a"),
275 ];
276
277 let changes = classify_file_changes(Some(&manifest(1)), &manifest(1), &previous, &next);
278
279 assert!(changes
280 .iter()
281 .any(|change| change.kind == IndexChangeKind::Unchanged
282 && change.new_path.as_deref() == Some(std::path::Path::new("src/stable.rs"))));
283 assert!(changes
284 .iter()
285 .any(|change| change.kind == IndexChangeKind::Modified
286 && change.new_path.as_deref() == Some(std::path::Path::new("src/modified.rs"))));
287 assert!(changes
288 .iter()
289 .any(|change| change.kind == IndexChangeKind::Renamed
290 && change.old_path.as_deref() == Some(std::path::Path::new("src/old.rs"))
291 && change.new_path.as_deref() == Some(std::path::Path::new("src/new.rs"))));
292 assert!(changes
293 .iter()
294 .any(|change| change.kind == IndexChangeKind::Added
295 && change.new_path.as_deref() == Some(std::path::Path::new("src/added.rs"))));
296 assert!(changes
297 .iter()
298 .any(|change| change.kind == IndexChangeKind::Deleted
299 && change.old_path.as_deref() == Some(std::path::Path::new("src/deleted.rs"))));
300 }
301
302 #[test]
303 fn schema_and_parser_version_changes_force_stale_classification() {
304 let previous = vec![file("f1", "src/lib.rs", "a")];
305 let next = vec![file("f1", "src/lib.rs", "b")];
306
307 let schema_changes =
308 classify_file_changes(Some(&manifest(1)), &manifest(2), &previous, &next);
309 assert_eq!(schema_changes[0].kind, IndexChangeKind::SchemaVersionStale);
310
311 let parser_changes = classify_file_changes_with_parser_version(
312 Some(&manifest(1)),
313 &manifest(1),
314 &previous,
315 &next,
316 Some("parser-a"),
317 Some("parser-b"),
318 );
319 assert_eq!(parser_changes[0].kind, IndexChangeKind::ParserVersionStale);
320 }
321
322 fn manifest(schema_version: u32) -> IndexManifest {
323 IndexManifest {
324 repository: Repository {
325 id: RepositoryId::new("repo"),
326 name: "repo".into(),
327 root: PathBuf::from("."),
328 branch: None,
329 commit: None,
330 indexed_at: Some(Utc::now()),
331 },
332 file_count: 0,
333 symbol_count: 0,
334 chunk_count: 0,
335 indexed_at: Utc::now(),
336 schema_version,
337 index_mode: Default::default(),
338 phase_reports: Vec::new(),
339 quality: IndexQuality::default(),
340 }
341 }
342
343 fn file(id: &str, path: &str, hash: &str) -> File {
344 File {
345 id: FileId::new(id),
346 repository_id: RepositoryId::new("repo"),
347 path: PathBuf::from(path),
348 language: Language::Rust,
349 size_bytes: 10,
350 content_hash: hash.into(),
351 is_generated: false,
352 is_vendor: false,
353 }
354 }
355}
356
357#[derive(Debug, Clone, Default, PartialEq, Eq)]
358pub struct GraphCounts {
359 pub nodes: usize,
360 pub edges: usize,
361}
362
363#[derive(Debug, Clone, Default, PartialEq, Eq)]
364pub struct GraphSchemaCounts {
365 pub node_types: std::collections::BTreeMap<String, usize>,
366 pub edge_types: std::collections::BTreeMap<String, usize>,
367}
368
369#[derive(Debug, Clone, Default)]
370pub struct TypeStats {
371 pub count: usize,
372 pub evidence_available: bool,
373 pub freshness: Option<u64>,
374}
375
376pub trait GraphStore: Send + Sync {
377 fn replace_graph(&self, nodes: &[GraphNode], edges: &[GraphEdge]) -> Result<()>;
378 fn node_by_id(&self, _id: &str) -> Result<Option<GraphNode>> {
379 Err(OkError::Unsupported(
380 "node_by_id is not implemented by this graph store".into(),
381 ))
382 }
383 fn neighbors(&self, node: &str, limit: usize) -> Result<(Vec<GraphNode>, Vec<GraphEdge>)>;
384 fn shortest_path(&self, from: &str, to: &str, max_depth: usize) -> Result<Vec<GraphEdge>>;
385
386 fn node_type_stats(&self) -> Result<std::collections::HashMap<String, TypeStats>> {
387 Ok(std::collections::HashMap::new())
388 }
389
390 fn edge_type_stats(&self) -> Result<std::collections::HashMap<String, TypeStats>> {
391 Ok(std::collections::HashMap::new())
392 }
393
394 fn nodes_by_type(
395 &self,
396 _node_type: GraphNodeType,
397 _limit: usize,
398 _offset: usize,
399 ) -> Result<Vec<GraphNode>> {
400 Err(OkError::Unsupported(
401 "nodes_by_type is not implemented by this graph store".into(),
402 ))
403 }
404
405 fn all_graph_nodes(&self) -> Result<Vec<GraphNode>> {
406 Err(OkError::Unsupported(
407 "all_graph_nodes is not implemented by this graph store".into(),
408 ))
409 }
410
411 fn edges_by_type(
412 &self,
413 _edge_type: GraphEdgeType,
414 _limit: usize,
415 _offset: usize,
416 ) -> Result<Vec<GraphEdge>> {
417 Err(OkError::Unsupported(
418 "edges_by_type is not implemented by this graph store".into(),
419 ))
420 }
421
422 fn graph_counts(&self) -> Result<GraphCounts> {
423 Err(OkError::Unsupported(
424 "graph_counts is not implemented by this graph store".into(),
425 ))
426 }
427
428 fn graph_schema_counts(&self) -> Result<GraphSchemaCounts> {
429 Err(OkError::Unsupported(
430 "graph_schema_counts is not implemented by this graph store".into(),
431 ))
432 }
433
434 fn graph_edges_between(&self, _from: &str, _to: &str, _limit: usize) -> Result<Vec<GraphEdge>> {
435 Err(OkError::Unsupported(
436 "graph_edges_between is not implemented by this graph store".into(),
437 ))
438 }
439}
440
441pub trait HistoryStore: Send + Sync {
442 fn put_history_snapshot(&self, snapshot: &HistorySnapshot) -> Result<()>;
443 fn history_for_file(&self, path: &Path, limit: usize) -> Result<HistorySummary>;
444 fn churn_for_file(&self, _path: &Path) -> Result<ChurnSummary> {
445 Err(OkError::Unsupported(
446 "file churn lookup is not implemented by this history store".into(),
447 ))
448 }
449 fn churn_for_module(&self, _module: &Path) -> Result<ChurnSummary> {
450 Err(OkError::Unsupported(
451 "module churn lookup is not implemented by this history store".into(),
452 ))
453 }
454 fn churn_for_symbol(&self, _symbol_id: &SymbolId) -> Result<ChurnSummary> {
455 Err(OkError::Unsupported(
456 "symbol churn lookup is not implemented by this history store".into(),
457 ))
458 }
459 fn provenance_for_path(&self, _path: &Path, _limit: usize) -> Result<FileProvenance> {
460 Err(OkError::Unsupported(
461 "file provenance lookup is not implemented by this history store".into(),
462 ))
463 }
464 fn provenance_for_symbol(
465 &self,
466 _symbol_id: &SymbolId,
467 _limit: usize,
468 ) -> Result<SymbolProvenance> {
469 Err(OkError::Unsupported(
470 "symbol provenance lookup is not implemented by this history store".into(),
471 ))
472 }
473 fn similar_changes(
474 &self,
475 _query: &SimilarChangeQuery,
476 _limit: usize,
477 ) -> Result<SimilarChangeReport> {
478 Err(OkError::Unsupported(
479 "similar historical change lookup is not implemented by this history store".into(),
480 ))
481 }
482 fn history_score_components(
483 &self,
484 query: &HistorySignalQuery,
485 limit: usize,
486 ) -> Result<HistorySignalSummary> {
487 Ok(history_signal_summary(self, query, limit))
488 }
489 fn cochange_neighbors(&self, path: &Path, limit: usize) -> Result<Vec<GitCochangeEdge>>;
490 fn recent_commits(&self, limit: usize) -> Result<Vec<GitCommitRecord>>;
491}
492
493fn history_signal_summary<T: HistoryStore + ?Sized>(
494 store: &T,
495 query: &HistorySignalQuery,
496 limit: usize,
497) -> HistorySignalSummary {
498 let limit = limit.clamp(1, 25);
499 let mut summary = HistorySignalSummary::empty(query.path.clone());
500 summary.uncertainty.clear();
501
502 match store.churn_for_file(&query.path) {
503 Ok(churn) => add_churn_signal(&mut summary, &churn),
504 Err(err) => summary
505 .uncertainty
506 .push(format!("history_churn unavailable: {err}")),
507 }
508
509 match store.history_for_file(&query.path, limit) {
510 Ok(history) => add_history_summary_signals(&mut summary, &history),
511 Err(err) => summary
512 .uncertainty
513 .push(format!("history summary unavailable: {err}")),
514 }
515
516 let similar_query = SimilarChangeQuery {
517 task: query.task.clone(),
518 paths: vec![query.path.clone()],
519 symbols: query.symbols.clone(),
520 };
521 match store.similar_changes(&similar_query, 5.min(limit)) {
522 Ok(report) => add_similar_change_signal(&mut summary, &report),
523 Err(err) => summary
524 .uncertainty
525 .push(format!("similar_change_overlap unavailable: {err}")),
526 }
527
528 summary.evidence_refs.sort();
529 summary.evidence_refs.dedup();
530 summary.reasons.sort();
531 summary.reasons.dedup();
532 summary.uncertainty.sort();
533 summary.uncertainty.dedup();
534 if summary.components.is_empty() && summary.uncertainty.is_empty() {
535 summary
536 .uncertainty
537 .push("no bounded history signals were available for this path".into());
538 }
539 summary
540}
541
542fn add_churn_signal(summary: &mut HistorySignalSummary, churn: &ChurnSummary) {
543 if churn.stats.touch_count == 0 {
544 summary.uncertainty.extend(churn.uncertainty.clone());
545 return;
546 }
547 let contribution = if churn.stats.hotspot_score >= 3.0 {
548 0.12
549 } else if churn.stats.hotspot_score >= 1.5 {
550 0.07
551 } else {
552 0.03
553 };
554 let evidence_id = format!("history-churn:{}", churn.key);
555 summary.evidence_refs.push(evidence_id.clone());
556 summary.reasons.push(format!(
557 "history churn: hotspot {:.2} from {} touch(es)",
558 churn.stats.hotspot_score, churn.stats.touch_count
559 ));
560 summary.components.push(ScoreComponent::adjustment(
561 "history_churn",
562 contribution,
563 vec![evidence_id],
564 "bounded churn/hotspot signal from persisted local git history",
565 ));
566 summary.uncertainty.extend(churn.uncertainty.clone());
567}
568
569fn add_history_summary_signals(summary: &mut HistorySignalSummary, history: &HistorySummary) {
570 let author_count = history
571 .recent_commits
572 .iter()
573 .map(|commit| owner_identity(&commit.author))
574 .filter(|identity| !identity.is_empty())
575 .collect::<BTreeSet<_>>()
576 .len();
577 let reviewer_count = history
578 .reviewer_evidence
579 .iter()
580 .map(|reviewer| owner_identity(&reviewer.reviewer))
581 .filter(|identity| !identity.is_empty())
582 .collect::<BTreeSet<_>>()
583 .len();
584 summary.distinct_author_count = author_count;
585 summary.reviewer_count = reviewer_count;
586
587 if author_count > 0 {
588 let contribution = if author_count >= 4 {
589 0.12
590 } else if author_count >= 2 {
591 0.07
592 } else if reviewer_count == 0 && !history.file_touches.is_empty() {
593 0.04
594 } else {
595 0.0
596 };
597 if contribution > 0.0 {
598 let evidence_ids = history
599 .recent_commits
600 .iter()
601 .take(5)
602 .map(|commit| format!("history-author:{}", commit.id.0))
603 .collect::<Vec<_>>();
604 summary.evidence_refs.extend(evidence_ids.clone());
605 summary.reasons.push(format!(
606 "ownership risk: {} distinct historical author(s), {} reviewer(s)",
607 author_count, reviewer_count
608 ));
609 summary.components.push(ScoreComponent::adjustment(
610 "ownership_risk",
611 contribution,
612 evidence_ids,
613 "bounded ownership risk from dispersed local author history",
614 ));
615 }
616 }
617
618 if reviewer_count > 0 {
619 let contribution = (0.04 + reviewer_count.min(3) as f32 * 0.02).min(0.10);
620 let evidence_ids = history
621 .reviewer_evidence
622 .iter()
623 .take(5)
624 .map(|review| format!("history-reviewer:{}", review.id.0))
625 .collect::<Vec<_>>();
626 summary.evidence_refs.extend(evidence_ids.clone());
627 summary.reasons.push(format!(
628 "reviewer affinity: {} historical reviewer signal(s)",
629 reviewer_count
630 ));
631 summary.components.push(ScoreComponent::adjustment(
632 "reviewer_affinity",
633 contribution,
634 evidence_ids,
635 "bounded reviewer affinity from local review and owner history",
636 ));
637 }
638
639 if !history.cochange_neighbors.is_empty() {
640 let contribution =
641 (history.cochange_neighbors.iter().take(3).count() as f32 * 0.04).min(0.12);
642 let evidence_ids = history
643 .cochange_neighbors
644 .iter()
645 .take(5)
646 .map(|edge| format!("history-cochange:{}", edge.id.0))
647 .collect::<Vec<_>>();
648 summary.evidence_refs.extend(evidence_ids.clone());
649 summary.reasons.push(format!(
650 "similar change overlap: {} persisted co-change neighbor(s)",
651 history.cochange_neighbors.len()
652 ));
653 summary.components.push(ScoreComponent::adjustment(
654 "similar_change_overlap",
655 contribution,
656 evidence_ids,
657 "bounded co-change overlap from persisted local history",
658 ));
659 }
660
661 summary.uncertainty.extend(history.uncertainty.clone());
662 if history.truncated {
663 summary
664 .uncertainty
665 .push("history signal inputs were truncated".into());
666 }
667}
668
669fn add_similar_change_signal(summary: &mut HistorySignalSummary, report: &SimilarChangeReport) {
670 summary.similar_change_count = report.hits.len();
671 if let Some(top_hit) = report.hits.first() {
672 let contribution = (top_hit.score.clamp(0.0, 1.0) * 0.18).max(0.05);
673 let evidence_ids = report
674 .hits
675 .iter()
676 .take(5)
677 .map(|hit| format!("history-similar:{}", hit.change.commit.id.0))
678 .collect::<Vec<_>>();
679 summary.evidence_refs.extend(evidence_ids.clone());
680 summary.reasons.push(format!(
681 "similar change overlap: {} similar historical change(s), top `{}`",
682 report.hits.len(),
683 top_hit.change.commit.summary
684 ));
685 summary.components.push(ScoreComponent::adjustment(
686 "similar_change_overlap",
687 contribution.min(0.18),
688 evidence_ids,
689 "bounded similar-change overlap from persisted local history",
690 ));
691 }
692 summary.uncertainty.extend(report.uncertainty.clone());
693 if report.truncated {
694 summary
695 .uncertainty
696 .push("similar change signal inputs were truncated".into());
697 }
698}
699
700fn owner_identity(owner: &open_kioku_core::Owner) -> String {
701 owner
702 .email
703 .as_deref()
704 .filter(|email| !email.trim().is_empty())
705 .unwrap_or(&owner.name)
706 .trim()
707 .to_ascii_lowercase()
708}
709
710pub trait SearchIndex: Send + Sync {
711 fn rebuild(&mut self, chunks: &[CodeChunk], files: &[File], symbols: &[Symbol]) -> Result<()>;
712 fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchResult>>;
713}
714
715pub trait ImpactStore: Send + Sync {
716 fn impact_for_file(&self, path: &Path) -> Result<ImpactReport>;
717}
718
719pub trait OkStore: MetadataStore + GraphStore {}
721impl<T: MetadataStore + GraphStore> OkStore for T {}