Skip to main content

open_kioku_storage/
lib.rs

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
719/// Combined store trait for types that implement both metadata and graph storage.
720pub trait OkStore: MetadataStore + GraphStore {}
721impl<T: MetadataStore + GraphStore> OkStore for T {}