Skip to main content

open_kioku_core/
lib.rs

1use chrono::{DateTime, Utc};
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5use std::fmt;
6use std::path::{Path, PathBuf};
7
8pub mod identity;
9
10macro_rules! id_type {
11    ($name:ident) => {
12        #[derive(
13            Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
14        )]
15        pub struct $name(pub String);
16
17        impl $name {
18            pub fn new(value: impl Into<String>) -> Self {
19                Self(value.into())
20            }
21        }
22
23        impl fmt::Display for $name {
24            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25                f.write_str(&self.0)
26            }
27        }
28    };
29}
30
31id_type!(RepositoryId);
32id_type!(FileId);
33id_type!(FileVersionId);
34id_type!(SymbolId);
35id_type!(NodeId);
36id_type!(EdgeId);
37id_type!(PatchId);
38id_type!(EvidenceId);
39id_type!(MemoryFactId);
40id_type!(ContextHandleId);
41id_type!(GitCommitId);
42id_type!(HistoryRecordId);
43
44pub const HISTORY_SCHEMA_VERSION: u32 = 1;
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
47#[serde(rename_all = "snake_case")]
48pub enum Confidence {
49    Low,
50    Medium,
51    High,
52    Exact,
53}
54
55impl Confidence {
56    pub fn score(self) -> f32 {
57        match self {
58            Self::Low => 0.35,
59            Self::Medium => 0.6,
60            Self::High => 0.85,
61            Self::Exact => 1.0,
62        }
63    }
64
65    pub fn from_score(score: f32) -> Self {
66        if score >= 0.95 {
67            Self::Exact
68        } else if score >= 0.75 {
69            Self::High
70        } else if score >= 0.55 {
71            Self::Medium
72        } else {
73            Self::Low
74        }
75    }
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
79pub struct ConfidenceBreakdown {
80    pub overall_enum: Confidence,
81    pub overall_score: f32,
82    pub components: Vec<ScoreComponent>,
83    pub blockers: Vec<String>,
84    pub caveats: Vec<String>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
88pub struct NegativeEvidence {
89    pub query: String,
90    pub scope: String,
91    pub inspected_sources: Vec<String>,
92    pub reason: String,
93    pub confidence: f32,
94    pub suggested_next_probe: Option<String>,
95}
96
97impl Default for ConfidenceBreakdown {
98    fn default() -> Self {
99        Self {
100            overall_enum: Confidence::Low,
101            overall_score: 0.0,
102            components: Vec::new(),
103            blockers: Vec::new(),
104            caveats: Vec::new(),
105        }
106    }
107}
108
109#[derive(Debug, Clone, Copy, Default)]
110pub struct ConfidenceSignalInput {
111    pub primary_file_count: usize,
112    pub evidence_count: usize,
113    pub exact_reference_count: usize,
114    pub validation_count: usize,
115    pub validation_with_command_count: usize,
116    pub negative_evidence_count: usize,
117    pub allowed_file_count: usize,
118    pub runtime_signal_count: usize,
119}
120
121impl ConfidenceBreakdown {
122    pub fn from_signals(input: ConfidenceSignalInput) -> Self {
123        let mut blockers = Vec::new();
124        let mut caveats = Vec::new();
125
126        if input.primary_file_count == 0 {
127            blockers.push("no primary context matched the task".into());
128        }
129        if input.negative_evidence_count > 0 {
130            blockers.push(format!(
131                "{} negative evidence signal(s) lowered confidence",
132                input.negative_evidence_count
133            ));
134        }
135        if input.exact_reference_count == 0 {
136            caveats.push("exact symbol/reference evidence is absent".into());
137        }
138        if input.validation_count == 0 {
139            caveats.push("no validation target was selected".into());
140        } else if input.validation_with_command_count == 0 {
141            caveats.push("validation targets require manual commands".into());
142        }
143        if input.runtime_signal_count == 0 {
144            caveats.push("runtime corroboration is absent".into());
145        }
146        if input.allowed_file_count == 0 {
147            caveats.push("change boundary has no allowed files".into());
148        } else if input.allowed_file_count > 8 {
149            caveats.push("change boundary is broad".into());
150        }
151
152        let evidence_target = input.primary_file_count.max(1) * 2;
153        let evidence_density = if input.primary_file_count == 0 {
154            0.0
155        } else {
156            (input.evidence_count as f32 / evidence_target.max(4) as f32).min(1.0)
157        };
158        if evidence_density < 0.5 {
159            caveats.push("evidence density is thin".into());
160        }
161
162        let exact_reference = if input.exact_reference_count > 0 {
163            1.0
164        } else {
165            0.25
166        };
167        let validation_availability = if input.validation_count > 0 { 1.0 } else { 0.2 };
168        let negative_evidence = if input.negative_evidence_count == 0 {
169            1.0
170        } else if input.negative_evidence_count <= 2 {
171            0.3
172        } else {
173            0.1
174        };
175        let boundary_tightness = if input.primary_file_count == 0 {
176            0.0
177        } else if input.allowed_file_count == 0 {
178            0.3
179        } else if input.allowed_file_count <= 3 {
180            1.0
181        } else if input.allowed_file_count <= 8
182            && input.allowed_file_count <= input.primary_file_count.max(1) * 2
183        {
184            0.85
185        } else {
186            0.45
187        };
188        let runtime_corroboration = if input.runtime_signal_count > 0 {
189            1.0
190        } else {
191            0.25
192        };
193        let test_coverage = if input.validation_count == 0 {
194            0.2
195        } else if input.validation_with_command_count > 0 {
196            1.0
197        } else {
198            0.6
199        };
200
201        let mut components = vec![
202            confidence_component(
203                "evidence_density",
204                evidence_density,
205                0.20,
206                "amount of independent indexed evidence near the selected context",
207            ),
208            confidence_component(
209                "exact_references",
210                exact_reference,
211                0.20,
212                "explicit exact symbol references or SCIP signals",
213            ),
214            confidence_component(
215                "validation_availability",
216                validation_availability,
217                0.15,
218                "presence of validation targets for the likely change",
219            ),
220            confidence_component(
221                "negative_evidence",
222                negative_evidence,
223                0.15,
224                "absence of low-confidence, missing-anchor, or no-match evidence",
225            ),
226            confidence_component(
227                "boundary_tightness",
228                boundary_tightness,
229                0.15,
230                "how narrowly allowed edit files bound the proposed change",
231            ),
232            confidence_component(
233                "runtime_corroboration",
234                runtime_corroboration,
235                0.05,
236                "runtime traces, incidents, or error signals that support the context",
237            ),
238            confidence_component(
239                "test_coverage",
240                test_coverage,
241                0.10,
242                "selected tests with runnable commands",
243            ),
244        ];
245        components.sort_by(|a, b| a.signal.cmp(&b.signal));
246        let mut overall_score = score_component_total(&components).clamp(0.0, 1.0);
247        if input.primary_file_count == 0 {
248            overall_score = overall_score.min(0.35);
249        }
250        if input.exact_reference_count == 0
251            && input.validation_count == 0
252            && input.runtime_signal_count == 0
253        {
254            overall_score = overall_score.min(0.55);
255        }
256        if input.negative_evidence_count > 0 {
257            overall_score = overall_score.min(0.60);
258        }
259
260        blockers.sort();
261        blockers.dedup();
262        caveats.sort();
263        caveats.dedup();
264        if !caveats.is_empty() {
265            overall_score = overall_score.min(0.94);
266        }
267
268        Self {
269            overall_enum: Confidence::from_score(overall_score),
270            overall_score,
271            components,
272            blockers,
273            caveats,
274        }
275    }
276}
277
278fn confidence_component(
279    signal: &'static str,
280    value: f32,
281    weight: f32,
282    rationale: &'static str,
283) -> ScoreComponent {
284    ScoreComponent::new(
285        signal,
286        value,
287        value,
288        weight,
289        value * weight,
290        Vec::new(),
291        rationale,
292    )
293}
294
295#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
296pub struct LineRange {
297    pub start: u32,
298    pub end: u32,
299}
300
301impl LineRange {
302    pub fn single(line: u32) -> Self {
303        Self {
304            start: line,
305            end: line,
306        }
307    }
308}
309
310#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
311pub struct FileRange {
312    pub path: PathBuf,
313    pub line_range: Option<LineRange>,
314}
315
316#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
317#[serde(rename_all = "snake_case")]
318pub enum EvidenceSourceType {
319    TreeSitter,
320    Scip,
321    Lsp,
322    Regex,
323    Lexical,
324    Semantic,
325    Runtime,
326    GitHistory,
327    StaticAnalysis,
328    ExternalIntegration,
329    Heuristic,
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
333pub struct Evidence {
334    pub id: EvidenceId,
335    pub source: String,
336    pub source_type: EvidenceSourceType,
337    pub file_range: Option<FileRange>,
338    pub symbol_id: Option<SymbolId>,
339    pub confidence: Confidence,
340    pub message: String,
341    pub indexed_at: DateTime<Utc>,
342    #[serde(default, skip_serializing_if = "Option::is_none")]
343    pub confidence_score: Option<f32>,
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    pub confidence_reason: Option<String>,
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    pub freshness: Option<String>,
348}
349
350impl Default for Evidence {
351    fn default() -> Self {
352        Self {
353            id: EvidenceId::new(""),
354            source: String::new(),
355            source_type: EvidenceSourceType::Lexical,
356            file_range: None,
357            symbol_id: None,
358            confidence: Confidence::Low,
359            message: String::new(),
360            indexed_at: Utc::now(),
361            confidence_score: None,
362            confidence_reason: None,
363            freshness: None,
364        }
365    }
366}
367
368#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
369pub struct ScoreComponent {
370    pub signal: String,
371    pub raw_value: f32,
372    pub normalized_value: f32,
373    pub weight: f32,
374    pub contribution: f32,
375    pub evidence_ids: Vec<String>,
376    pub rationale: String,
377}
378
379impl ScoreComponent {
380    pub fn new(
381        signal: impl Into<String>,
382        raw_value: f32,
383        normalized_value: f32,
384        weight: f32,
385        contribution: f32,
386        evidence_ids: Vec<String>,
387        rationale: impl Into<String>,
388    ) -> Self {
389        Self {
390            signal: signal.into(),
391            raw_value,
392            normalized_value,
393            weight,
394            contribution,
395            evidence_ids,
396            rationale: rationale.into(),
397        }
398    }
399
400    pub fn single(
401        signal: impl Into<String>,
402        score: f32,
403        evidence_ids: Vec<String>,
404        rationale: impl Into<String>,
405    ) -> Self {
406        Self::new(
407            signal,
408            score,
409            score.clamp(0.0, 1.0),
410            1.0,
411            score,
412            evidence_ids,
413            rationale,
414        )
415    }
416
417    pub fn adjustment(
418        signal: impl Into<String>,
419        contribution: f32,
420        evidence_ids: Vec<String>,
421        rationale: impl Into<String>,
422    ) -> Self {
423        Self::new(
424            signal,
425            contribution,
426            contribution.clamp(-1.0, 1.0),
427            1.0,
428            contribution,
429            evidence_ids,
430            rationale,
431        )
432    }
433}
434
435pub fn score_component_total(components: &[ScoreComponent]) -> f32 {
436    components
437        .iter()
438        .map(|component| component.contribution)
439        .sum()
440}
441
442pub fn reconcile_score_breakdown(
443    score: f32,
444    components: &mut Vec<ScoreComponent>,
445    fallback_signal: &str,
446    evidence_ids: Vec<String>,
447    rationale: &str,
448) {
449    if components.is_empty() {
450        components.push(ScoreComponent::single(
451            fallback_signal,
452            score,
453            evidence_ids,
454            rationale,
455        ));
456        return;
457    }
458
459    let delta = score - score_component_total(components);
460    if delta.abs() > 0.001 {
461        components.push(ScoreComponent::adjustment(
462            "score_reconciliation",
463            delta,
464            evidence_ids,
465            format!("adjusted component total to match surfaced score: {rationale}"),
466        ));
467    }
468}
469
470#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
471pub struct Repository {
472    pub id: RepositoryId,
473    pub name: String,
474    pub root: PathBuf,
475    pub branch: Option<String>,
476    pub commit: Option<String>,
477    pub indexed_at: Option<DateTime<Utc>>,
478}
479
480#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
481pub struct Commit {
482    pub sha: String,
483    pub message: Option<String>,
484    pub authored_at: Option<DateTime<Utc>>,
485}
486
487#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
488pub struct Branch {
489    pub name: String,
490    pub head: Option<String>,
491}
492
493#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
494#[serde(rename_all = "snake_case")]
495pub enum Language {
496    Rust,
497    Java,
498    TypeScript,
499    JavaScript,
500    Python,
501    Go,
502    Yaml,
503    Json,
504    Toml,
505    Sql,
506    Markdown,
507    Text,
508    Unknown,
509}
510
511#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
512pub struct File {
513    pub id: FileId,
514    pub repository_id: RepositoryId,
515    pub path: PathBuf,
516    pub language: Language,
517    pub size_bytes: u64,
518    pub content_hash: String,
519    pub is_generated: bool,
520    pub is_vendor: bool,
521}
522
523#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
524pub struct FileVersion {
525    pub id: FileVersionId,
526    pub file_id: FileId,
527    pub commit: Option<String>,
528    pub content_hash: String,
529    pub indexed_at: DateTime<Utc>,
530}
531
532#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
533#[serde(rename_all = "snake_case")]
534pub enum SymbolKind {
535    Module,
536    Package,
537    Class,
538    Trait,
539    Interface,
540    Function,
541    Method,
542    Field,
543    Variable,
544    Constant,
545    Endpoint,
546    DatabaseTable,
547    Test,
548    Unknown,
549}
550
551#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
552pub struct Symbol {
553    pub id: SymbolId,
554    pub name: String,
555    pub qualified_name: String,
556    pub kind: SymbolKind,
557    pub file_id: FileId,
558    pub range: Option<LineRange>,
559    pub language: Language,
560    pub confidence: Confidence,
561    pub provenance: EvidenceSourceType,
562}
563
564#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
565pub struct SymbolOccurrence {
566    pub symbol_id: SymbolId,
567    pub file_id: FileId,
568    pub range: Option<LineRange>,
569    pub is_definition: bool,
570    pub confidence: Confidence,
571    pub provenance: EvidenceSourceType,
572}
573
574pub type Reference = SymbolOccurrence;
575pub type Definition = SymbolOccurrence;
576
577#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
578pub struct Import {
579    pub file_id: FileId,
580    pub imported: String,
581    pub range: Option<LineRange>,
582    pub confidence: Confidence,
583}
584
585#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
586#[serde(rename_all = "snake_case")]
587pub enum ResolutionStatus {
588    Resolved,
589    Ambiguous { candidates: usize },
590    ExternalPackage,
591    Builtin,
592    Unresolved,
593}
594
595#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
596pub struct ImportResolution {
597    pub import: Import,
598    pub status: ResolutionStatus,
599    pub target_file: Option<FileId>,
600    pub target_symbol: Option<SymbolId>,
601    pub confidence: Confidence,
602    pub strategy: String,
603    pub caveats: Vec<String>,
604}
605
606#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
607pub struct AnalysisFact {
608    pub id: String,
609    pub file_id: FileId,
610    pub symbol_id: Option<SymbolId>,
611    pub target: String,
612    pub target_kind: GraphNodeType,
613    pub edge_type: GraphEdgeType,
614    pub range: Option<LineRange>,
615    pub confidence: Confidence,
616    pub source: String,
617    pub source_type: EvidenceSourceType,
618    pub message: String,
619}
620
621#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
622pub struct CodeChunk {
623    pub id: String,
624    pub file_id: FileId,
625    pub range: LineRange,
626    pub language: Language,
627    pub text: String,
628    pub symbol_id: Option<SymbolId>,
629}
630
631#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
632pub struct Diagnostic {
633    pub severity: String,
634    pub message: String,
635    pub file_range: Option<FileRange>,
636}
637
638#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
639pub struct TestTarget {
640    pub id: String,
641    pub name: String,
642    pub file_id: FileId,
643    pub range: Option<LineRange>,
644    pub command: Option<String>,
645    pub confidence: Confidence,
646    pub reason: String,
647    #[serde(default)]
648    pub evidence_refs: Vec<String>,
649    #[serde(default)]
650    pub score_breakdown: Vec<ScoreComponent>,
651}
652
653impl TestTarget {
654    pub fn reconcile_score_breakdown(&mut self) {
655        if self.evidence_refs.is_empty() {
656            self.evidence_refs.push(format!("test:{}", self.id));
657        }
658        reconcile_score_breakdown(
659            self.confidence.score(),
660            &mut self.score_breakdown,
661            "test_confidence",
662            self.evidence_refs.clone(),
663            &self.reason,
664        );
665    }
666}
667
668#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
669pub struct BuildTarget {
670    pub id: String,
671    pub name: String,
672    pub command: String,
673    pub files: Vec<FileId>,
674}
675
676#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
677pub struct RuntimeSignal {
678    pub id: String,
679    pub kind: String,
680    pub message: String,
681    pub file_range: Option<FileRange>,
682    pub occurred_at: Option<DateTime<Utc>>,
683    pub confidence: Confidence,
684}
685
686#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
687pub struct Owner {
688    pub name: String,
689    pub email: Option<String>,
690}
691
692#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
693#[serde(rename_all = "snake_case")]
694pub enum GitChangeKind {
695    Added,
696    Modified,
697    Deleted,
698    Renamed,
699    Copied,
700    TypeChanged,
701    Unknown,
702}
703
704#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
705#[serde(rename_all = "snake_case")]
706pub enum ReviewerRole {
707    Reviewer,
708    Approver,
709    Author,
710    Committer,
711    Owner,
712}
713
714#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
715pub struct GitCommitRecord {
716    pub id: GitCommitId,
717    #[serde(default)]
718    pub parent_ids: Vec<GitCommitId>,
719    pub author: Owner,
720    pub committer: Option<Owner>,
721    pub authored_at: DateTime<Utc>,
722    pub committed_at: DateTime<Utc>,
723    pub summary: String,
724    pub message: String,
725    pub file_count: usize,
726}
727
728#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
729pub struct GitFileTouch {
730    pub id: HistoryRecordId,
731    pub commit_id: GitCommitId,
732    pub path: PathBuf,
733    pub previous_path: Option<PathBuf>,
734    pub change_kind: GitChangeKind,
735    pub additions: Option<u32>,
736    pub deletions: Option<u32>,
737    pub touched_at: DateTime<Utc>,
738}
739
740#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
741pub struct GitSymbolTouch {
742    pub id: HistoryRecordId,
743    pub commit_id: GitCommitId,
744    pub symbol_id: Option<SymbolId>,
745    pub qualified_name: String,
746    pub file_path: PathBuf,
747    pub change_kind: GitChangeKind,
748    #[serde(default)]
749    pub line_ranges: Vec<LineRange>,
750    #[serde(default = "default_history_confidence")]
751    pub confidence: Confidence,
752    #[serde(default)]
753    pub uncertainty: Vec<String>,
754    pub touched_at: DateTime<Utc>,
755}
756
757fn default_history_confidence() -> Confidence {
758    Confidence::Low
759}
760
761#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
762pub struct ProvenanceTouch {
763    pub commit: GitCommitRecord,
764    pub path: PathBuf,
765    pub previous_path: Option<PathBuf>,
766    pub symbol_id: Option<SymbolId>,
767    pub qualified_name: Option<String>,
768    pub change_kind: GitChangeKind,
769    pub line_ranges: Vec<LineRange>,
770    pub confidence: Confidence,
771    pub uncertainty: Vec<String>,
772}
773
774#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
775pub struct FileProvenance {
776    pub path: PathBuf,
777    pub first_seen: Option<ProvenanceTouch>,
778    pub last_touched: Option<ProvenanceTouch>,
779    pub recent_touches: Vec<ProvenanceTouch>,
780    pub confidence: Confidence,
781    pub truncated: bool,
782    pub uncertainty: Vec<String>,
783}
784
785#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
786pub struct SymbolProvenance {
787    pub symbol_id: SymbolId,
788    pub qualified_name: String,
789    pub file_path: PathBuf,
790    pub range: Option<LineRange>,
791    pub first_seen: Option<ProvenanceTouch>,
792    pub last_touched: Option<ProvenanceTouch>,
793    pub recent_touches: Vec<ProvenanceTouch>,
794    pub confidence: Confidence,
795    pub truncated: bool,
796    pub uncertainty: Vec<String>,
797}
798
799#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
800pub struct GitCochangeEdge {
801    pub id: HistoryRecordId,
802    pub path: PathBuf,
803    pub cochanged_path: PathBuf,
804    pub commit_count: usize,
805    pub recency_weight: f32,
806    pub last_changed_at: Option<DateTime<Utc>>,
807    #[serde(default)]
808    pub sample_commits: Vec<GitCommitId>,
809    pub test_corun: bool,
810}
811
812#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
813pub struct ReviewerEvidence {
814    pub id: HistoryRecordId,
815    pub commit_id: Option<GitCommitId>,
816    pub path: Option<PathBuf>,
817    pub reviewer: Owner,
818    pub role: ReviewerRole,
819    pub observed_at: DateTime<Utc>,
820    pub source: String,
821    pub confidence: Confidence,
822}
823
824#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
825pub struct HistorySnapshot {
826    pub schema_version: u32,
827    #[serde(default)]
828    pub commits: Vec<GitCommitRecord>,
829    #[serde(default)]
830    pub file_touches: Vec<GitFileTouch>,
831    #[serde(default)]
832    pub symbol_touches: Vec<GitSymbolTouch>,
833    #[serde(default)]
834    pub cochange_edges: Vec<GitCochangeEdge>,
835    #[serde(default)]
836    pub reviewer_evidence: Vec<ReviewerEvidence>,
837}
838
839impl HistorySnapshot {
840    pub fn empty() -> Self {
841        Self {
842            schema_version: HISTORY_SCHEMA_VERSION,
843            commits: Vec::new(),
844            file_touches: Vec::new(),
845            symbol_touches: Vec::new(),
846            cochange_edges: Vec::new(),
847            reviewer_evidence: Vec::new(),
848        }
849    }
850}
851
852#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
853pub struct HistorySummary {
854    pub path: PathBuf,
855    pub recent_commits: Vec<GitCommitRecord>,
856    pub file_touches: Vec<GitFileTouch>,
857    pub symbol_touches: Vec<GitSymbolTouch>,
858    pub cochange_neighbors: Vec<GitCochangeEdge>,
859    pub reviewer_evidence: Vec<ReviewerEvidence>,
860    pub truncated: bool,
861    #[serde(default)]
862    pub uncertainty: Vec<String>,
863}
864
865impl HistorySummary {
866    pub fn empty(path: impl Into<PathBuf>) -> Self {
867        Self {
868            path: path.into(),
869            recent_commits: Vec::new(),
870            file_touches: Vec::new(),
871            symbol_touches: Vec::new(),
872            cochange_neighbors: Vec::new(),
873            reviewer_evidence: Vec::new(),
874            truncated: false,
875            uncertainty: vec!["no persisted history evidence is available for this path".into()],
876        }
877    }
878}
879
880#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
881pub struct ArchitectureComponent {
882    pub id: String,
883    pub name: String,
884    pub paths: Vec<String>,
885    pub evidence: Vec<Evidence>,
886}
887
888#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
889pub struct PolicyComponentMatch {
890    pub component_id: String,
891    pub matched_glob: String,
892}
893
894#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
895pub struct ResolvedArchitectureNode {
896    pub file_path: PathBuf,
897    pub symbol_id: Option<SymbolId>,
898    pub components: Vec<PolicyComponentMatch>,
899}
900
901#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
902pub struct UnmappedPolicyTarget {
903    pub file_path: PathBuf,
904    pub symbol_id: Option<SymbolId>,
905}
906
907#[derive(
908    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
909)]
910#[serde(rename_all = "snake_case")]
911pub enum EnforcedEdgeType {
912    Imports,
913    References,
914    Calls,
915}
916
917impl EnforcedEdgeType {
918    pub fn graph_edge_type(self) -> GraphEdgeType {
919        match self {
920            Self::Imports => GraphEdgeType::Imports,
921            Self::References => GraphEdgeType::References,
922            Self::Calls => GraphEdgeType::Calls,
923        }
924    }
925}
926
927#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
928pub struct PolicyMatchEvidence {
929    pub edge_id: String,
930    pub edge_type: EnforcedEdgeType,
931    pub source_node: String,
932    pub target_node: String,
933    pub source_path: PathBuf,
934    pub target_path: PathBuf,
935    pub confidence: Confidence,
936    pub message: String,
937}
938
939#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
940pub struct PolicyViolation {
941    pub rule_id: String,
942    pub severity: String,
943    pub source_component: String,
944    pub target_component: String,
945    pub source_path: PathBuf,
946    pub target_path: PathBuf,
947    pub edge_type: EnforcedEdgeType,
948    pub evidence: PolicyMatchEvidence,
949    pub message: String,
950}
951
952#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
953pub struct UnknownPolicyEdge {
954    pub reason: String,
955    pub evidence: PolicyMatchEvidence,
956}
957
958#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
959pub struct PolicyCheckReport {
960    pub configured: bool,
961    pub evaluated_edge_count: usize,
962    pub allowed_edges: usize,
963    pub violation_count: usize,
964    pub unknown_edge_count: usize,
965    pub unknown_sample_count: usize,
966    pub unknown_edges_truncated: bool,
967    pub violations: Vec<PolicyViolation>,
968    pub unknown_edges: Vec<UnknownPolicyEdge>,
969    pub uncertainty: Vec<String>,
970}
971
972#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
973pub struct IndexManifest {
974    pub repository: Repository,
975    pub file_count: usize,
976    pub symbol_count: usize,
977    pub chunk_count: usize,
978    pub indexed_at: DateTime<Utc>,
979    pub schema_version: u32,
980    #[serde(default)]
981    pub index_mode: IndexMode,
982    #[serde(default)]
983    pub phase_reports: Vec<IndexPhaseReport>,
984    #[serde(default)]
985    pub quality: IndexQuality,
986}
987
988#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
989#[serde(rename_all = "snake_case")]
990pub enum IndexMode {
991    #[default]
992    Full,
993    Balanced,
994    Fast,
995    CrossProject,
996}
997
998impl fmt::Display for IndexMode {
999    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1000        let value = match self {
1001            Self::Full => "full",
1002            Self::Balanced => "balanced",
1003            Self::Fast => "fast",
1004            Self::CrossProject => "cross_project",
1005        };
1006        f.write_str(value)
1007    }
1008}
1009
1010#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
1011pub struct IndexPhaseReport {
1012    pub phase: String,
1013    pub elapsed_ms: u64,
1014    pub scanned_files: usize,
1015    pub indexed_files: usize,
1016    pub nodes_added: usize,
1017    pub edges_added: usize,
1018    pub skipped: usize,
1019    pub warnings: Vec<String>,
1020}
1021
1022#[derive(
1023    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
1024)]
1025#[serde(rename_all = "snake_case")]
1026pub enum SkipReason {
1027    Ignored,
1028    Denied,
1029    Hidden,
1030    UnsupportedLanguage,
1031    Binary,
1032    TooLarge,
1033    Generated,
1034    Vendor,
1035    FastMode,
1036    SecretPolicy,
1037    SymlinkPolicy,
1038    Error,
1039}
1040
1041#[derive(
1042    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
1043)]
1044#[serde(rename_all = "snake_case")]
1045pub enum SkipSource {
1046    SecurityPolicy,
1047    HiddenPolicy,
1048    ConfigExclude,
1049    GitIgnore,
1050    OkIgnore,
1051    Detector,
1052    FastMode,
1053    SizeLimit,
1054    SymlinkPolicy,
1055    LanguageSupport,
1056    Filesystem,
1057}
1058
1059#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1060pub struct SkippedPath {
1061    pub path: PathBuf,
1062    pub reason: SkipReason,
1063    pub source: SkipSource,
1064    pub safe_to_show: bool,
1065}
1066
1067#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
1068pub struct IndexQuality {
1069    #[serde(default)]
1070    pub index_mode: IndexMode,
1071    #[serde(default)]
1072    pub phase_reports: Vec<IndexPhaseReport>,
1073    pub scip_enabled: bool,
1074    pub scip_mode: String,
1075    pub scip_indexes_imported: usize,
1076    pub scip_symbols: usize,
1077    pub scip_occurrences: usize,
1078    pub scip_exact_references: usize,
1079    pub test_count: usize,
1080    pub import_count: usize,
1081    #[serde(default)]
1082    pub build_systems: Vec<String>,
1083    #[serde(default)]
1084    pub codeql_databases: usize,
1085    #[serde(default)]
1086    pub coverage_reports: usize,
1087    #[serde(default)]
1088    pub junit_reports: usize,
1089    #[serde(default)]
1090    pub static_analysis_facts: usize,
1091    #[serde(default)]
1092    pub runtime_analysis_facts: usize,
1093    #[serde(default)]
1094    pub git_history_facts: usize,
1095    #[serde(default)]
1096    pub architecture_facts: usize,
1097    #[serde(default)]
1098    pub semantic_provider_notes: Vec<String>,
1099    #[serde(default)]
1100    pub skip_counts: BTreeMap<SkipReason, usize>,
1101    #[serde(default)]
1102    pub skipped_paths: Vec<SkippedPath>,
1103    pub quality_notes: Vec<String>,
1104}
1105
1106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1107pub struct EvidenceGraphSchema {
1108    pub version: String,
1109    pub node_types: Vec<NodeTypeSpec>,
1110    pub edge_types: Vec<EdgeTypeSpec>,
1111    pub property_specs: Vec<PropertySpec>,
1112    pub feature_flags: Vec<String>,
1113    #[serde(default)]
1114    pub evidence_source_types: Vec<String>,
1115    #[serde(default)]
1116    pub query_features: Vec<String>,
1117    #[serde(default)]
1118    pub optional_evidence: Vec<OptionalEvidenceSpec>,
1119    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1120    pub caveats: Vec<String>,
1121    #[serde(default, skip_serializing_if = "Option::is_none")]
1122    pub indexed_at: Option<String>,
1123}
1124
1125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1126pub struct NodeTypeSpec {
1127    pub name: String,
1128    pub stable: bool,
1129    pub description: String,
1130    pub required_fields: Vec<String>,
1131    pub optional_fields: Vec<String>,
1132    #[serde(skip_serializing_if = "Option::is_none")]
1133    pub count: Option<usize>,
1134    #[serde(skip_serializing_if = "Option::is_none")]
1135    pub evidence_available: Option<bool>,
1136    #[serde(skip_serializing_if = "Option::is_none")]
1137    pub freshness: Option<String>,
1138}
1139
1140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1141pub struct EdgeTypeSpec {
1142    pub name: String,
1143    pub stable: bool,
1144    pub description: String,
1145    pub source_types: Vec<String>,
1146    pub target_types: Vec<String>,
1147    pub required_evidence: Vec<String>,
1148    #[serde(skip_serializing_if = "Option::is_none")]
1149    pub count: Option<usize>,
1150    #[serde(skip_serializing_if = "Option::is_none")]
1151    pub evidence_available: Option<bool>,
1152    #[serde(skip_serializing_if = "Option::is_none")]
1153    pub freshness: Option<String>,
1154}
1155
1156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1157pub struct PropertySpec {
1158    pub name: String,
1159    pub type_name: String,
1160    pub description: String,
1161}
1162
1163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1164pub struct OptionalEvidenceSpec {
1165    pub name: String,
1166    pub available: bool,
1167    pub status: String,
1168    pub evidence_count: usize,
1169    pub description: String,
1170    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1171    pub caveats: Vec<String>,
1172}
1173
1174#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
1175#[serde(rename_all = "snake_case")]
1176pub enum GraphNodeType {
1177    File,
1178    Directory,
1179    Module,
1180    Package,
1181    Class,
1182    Trait,
1183    Interface,
1184    Function,
1185    Method,
1186    Field,
1187    Endpoint,
1188    DatabaseTable,
1189    Collection,
1190    Queue,
1191    Topic,
1192    ConfigKey,
1193    Test,
1194    BuildTarget,
1195    RuntimeError,
1196    Ticket,
1197    PullRequest,
1198    Resource,
1199    ArchitectureComponent,
1200}
1201
1202#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
1203#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
1204pub enum GraphEdgeType {
1205    Contains,
1206    Defines,
1207    References,
1208    Calls,
1209    Implements,
1210    Extends,
1211    Imports,
1212    DependsOn,
1213    ExposesEndpoint,
1214    CallsEndpoint,
1215    ReadsConfig,
1216    WritesConfig,
1217    ReadsTable,
1218    WritesTable,
1219    PublishesEvent,
1220    ConsumesEvent,
1221    Tests,
1222    TestCovers,
1223    Validates,
1224    OwnedBy,
1225    ChangedBy,
1226    FailedIn,
1227    BelongsTo,
1228    MentionedIn,
1229    RelatedToTicket,
1230    SimilarTo,
1231    SemanticallyRelated,
1232}
1233
1234#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1235pub struct GraphNode {
1236    pub id: NodeId,
1237    pub node_type: GraphNodeType,
1238    pub label: String,
1239    pub file_id: Option<FileId>,
1240    pub symbol_id: Option<SymbolId>,
1241    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1242    pub properties: BTreeMap<String, serde_json::Value>,
1243    #[serde(default, skip_serializing_if = "Option::is_none")]
1244    pub schema_version: Option<String>,
1245    #[serde(default, skip_serializing_if = "Option::is_none")]
1246    pub source_pass: Option<String>,
1247    #[serde(default, skip_serializing_if = "Option::is_none")]
1248    pub index_mode: Option<String>,
1249    #[serde(default, skip_serializing_if = "Option::is_none")]
1250    pub extractor_version: Option<String>,
1251    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1252    pub ambiguity: Vec<String>,
1253    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1254    pub quality_notes: Vec<String>,
1255}
1256
1257impl Default for GraphNode {
1258    fn default() -> Self {
1259        Self {
1260            id: NodeId::new(""),
1261            node_type: GraphNodeType::File,
1262            label: String::new(),
1263            file_id: None,
1264            symbol_id: None,
1265            properties: BTreeMap::new(),
1266            schema_version: None,
1267            source_pass: None,
1268            index_mode: None,
1269            extractor_version: None,
1270            ambiguity: vec![],
1271            quality_notes: vec![],
1272        }
1273    }
1274}
1275
1276#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1277pub struct GraphEdge {
1278    pub id: EdgeId,
1279    pub from: NodeId,
1280    pub to: NodeId,
1281    pub edge_type: GraphEdgeType,
1282    pub evidence: Evidence,
1283    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1284    pub properties: BTreeMap<String, serde_json::Value>,
1285    #[serde(default, skip_serializing_if = "Option::is_none")]
1286    pub schema_version: Option<String>,
1287    #[serde(default, skip_serializing_if = "Option::is_none")]
1288    pub source_pass: Option<String>,
1289    #[serde(default, skip_serializing_if = "Option::is_none")]
1290    pub index_mode: Option<String>,
1291    #[serde(default, skip_serializing_if = "Option::is_none")]
1292    pub extractor_version: Option<String>,
1293    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1294    pub ambiguity: Vec<String>,
1295    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1296    pub quality_notes: Vec<String>,
1297}
1298
1299impl Default for GraphEdge {
1300    fn default() -> Self {
1301        Self {
1302            id: EdgeId::new(""),
1303            from: NodeId::new(""),
1304            to: NodeId::new(""),
1305            edge_type: GraphEdgeType::References,
1306            evidence: Evidence::default(),
1307            properties: BTreeMap::new(),
1308            schema_version: None,
1309            source_pass: None,
1310            index_mode: None,
1311            extractor_version: None,
1312            ambiguity: vec![],
1313            quality_notes: vec![],
1314        }
1315    }
1316}
1317
1318#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1319pub struct SearchResult {
1320    pub path: PathBuf,
1321    pub line_range: Option<LineRange>,
1322    pub snippet: String,
1323    pub symbol: Option<Symbol>,
1324    pub score: f32,
1325    pub match_reason: String,
1326    pub evidence: Vec<String>,
1327    #[serde(default)]
1328    pub evidence_refs: Vec<String>,
1329    pub confidence: f32,
1330    #[serde(default)]
1331    pub score_breakdown: Vec<ScoreComponent>,
1332}
1333
1334impl SearchResult {
1335    pub fn derived_evidence_ids(&self) -> Vec<String> {
1336        if !self.evidence_refs.is_empty() {
1337            return self.evidence_refs.clone();
1338        }
1339        search_result_evidence_ids(&self.path, &self.line_range, self.evidence.len())
1340    }
1341
1342    pub fn reconcile_score_breakdown(&mut self) {
1343        if self.evidence_refs.is_empty() {
1344            self.evidence_refs =
1345                search_result_evidence_ids(&self.path, &self.line_range, self.evidence.len());
1346        }
1347        reconcile_score_breakdown(
1348            self.score,
1349            &mut self.score_breakdown,
1350            "search_score",
1351            self.evidence_refs.clone(),
1352            &self.match_reason,
1353        );
1354    }
1355
1356    pub fn add_score_component(&mut self, component: ScoreComponent) {
1357        self.score_breakdown.push(component);
1358    }
1359}
1360
1361pub fn search_result_evidence_ids(
1362    path: &Path,
1363    line_range: &Option<LineRange>,
1364    evidence_len: usize,
1365) -> Vec<String> {
1366    let range = line_range
1367        .as_ref()
1368        .map(|range| format!("{}-{}", range.start, range.end))
1369        .unwrap_or_else(|| "unknown".into());
1370    let count = evidence_len.max(1);
1371    (0..count)
1372        .map(|index| format!("search:{}:{range}:{index}", path.display()))
1373        .collect()
1374}
1375
1376#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1377pub struct EntityLink {
1378    pub kind: String,
1379    pub value: String,
1380    pub file_range: Option<FileRange>,
1381    pub confidence: Confidence,
1382}
1383
1384#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1385pub struct MemoryFact {
1386    pub id: MemoryFactId,
1387    pub text: String,
1388    pub source: String,
1389    pub confidence: Confidence,
1390    pub entities: Vec<EntityLink>,
1391    pub created_at: DateTime<Utc>,
1392}
1393
1394#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1395pub struct MemorySearchResult {
1396    pub fact: MemoryFact,
1397    pub score: f32,
1398    pub match_reason: String,
1399    pub evidence: Vec<String>,
1400}
1401
1402#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1403pub struct ContextHandle {
1404    pub id: ContextHandleId,
1405    pub kind: String,
1406    pub summary: String,
1407    pub file_range: Option<FileRange>,
1408    pub entities: Vec<EntityLink>,
1409    pub original_tokens_estimate: usize,
1410    pub compressed_tokens_estimate: usize,
1411}
1412
1413#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1414pub struct CompressedContextPack {
1415    pub task: String,
1416    pub summary: String,
1417    pub handles: Vec<ContextHandle>,
1418    pub original_tokens_estimate: usize,
1419    pub compressed_tokens_estimate: usize,
1420    pub compression_ratio: f32,
1421    pub evidence: Vec<Evidence>,
1422}
1423
1424#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1425pub struct RiskReport {
1426    pub level: String,
1427    pub score: f32,
1428    pub reasons: Vec<String>,
1429}
1430
1431#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
1432pub struct BoundaryFileRule {
1433    pub path: PathBuf,
1434    pub reason: String,
1435    #[serde(default)]
1436    pub evidence_refs: Vec<String>,
1437    #[serde(default)]
1438    pub symbols: Vec<String>,
1439}
1440
1441#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
1442pub struct BoundaryForbiddenRule {
1443    pub pattern: String,
1444    pub reason: String,
1445    #[serde(default)]
1446    pub evidence_refs: Vec<String>,
1447}
1448
1449#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
1450pub struct BoundaryExpansionRequirement {
1451    pub reason: String,
1452    #[serde(default)]
1453    pub required_evidence_refs: Vec<String>,
1454}
1455
1456#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
1457pub struct BoundarySignalHooks {
1458    #[serde(default)]
1459    pub architecture_components: Vec<String>,
1460    #[serde(default)]
1461    pub ownership_sources: Vec<String>,
1462    #[serde(default)]
1463    pub cochange_sources: Vec<String>,
1464}
1465
1466#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
1467pub struct ChangeBoundary {
1468    pub allowed_files: Vec<PathBuf>,
1469    pub caution_files: Vec<PathBuf>,
1470    pub forbidden_files: Vec<PathBuf>,
1471    #[serde(default)]
1472    pub evidence_refs: Vec<String>,
1473    #[serde(default)]
1474    pub allowed_symbols: Vec<String>,
1475    #[serde(default)]
1476    pub allowed_rules: Vec<BoundaryFileRule>,
1477    #[serde(default)]
1478    pub caution_rules: Vec<BoundaryFileRule>,
1479    #[serde(default)]
1480    pub forbidden_rules: Vec<BoundaryForbiddenRule>,
1481    #[serde(default)]
1482    pub expansion_requirements: Vec<BoundaryExpansionRequirement>,
1483    #[serde(default)]
1484    pub signal_hooks: BoundarySignalHooks,
1485}
1486
1487#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1488pub struct ValidationPlan {
1489    pub commands: Vec<String>,
1490    pub tests: Vec<TestTarget>,
1491    pub requires_approval: bool,
1492    pub evidence: Vec<Evidence>,
1493}
1494
1495#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1496pub struct ImpactReport {
1497    pub target: String,
1498    pub direct_impacts: Vec<SearchResult>,
1499    pub indirect_impacts: Vec<SearchResult>,
1500    pub risk_report: RiskReport,
1501    pub evidence: Vec<Evidence>,
1502    #[serde(default)]
1503    pub score_breakdown: Vec<ScoreComponent>,
1504}
1505
1506impl ImpactReport {
1507    pub fn reconcile_score_breakdown(&mut self) {
1508        reconcile_score_breakdown(
1509            self.risk_report.score,
1510            &mut self.score_breakdown,
1511            "impact_risk",
1512            self.evidence
1513                .iter()
1514                .map(|evidence| evidence.id.0.clone())
1515                .collect(),
1516            "impact risk score",
1517        );
1518        for result in &mut self.direct_impacts {
1519            result.reconcile_score_breakdown();
1520        }
1521        for result in &mut self.indirect_impacts {
1522            result.reconcile_score_breakdown();
1523        }
1524    }
1525}
1526
1527#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1528pub struct ContextPack {
1529    pub task: String,
1530    pub intent: String,
1531    pub primary_files: Vec<SearchResult>,
1532    pub primary_symbols: Vec<Symbol>,
1533    pub supporting_files: Vec<SearchResult>,
1534    pub dependency_edges: Vec<GraphEdge>,
1535    pub runtime_signals: Vec<RuntimeSignal>,
1536    pub test_candidates: Vec<TestTarget>,
1537    pub risk_report: RiskReport,
1538    pub recommended_change_boundary: ChangeBoundary,
1539    pub validation_plan: ValidationPlan,
1540    pub evidence: Vec<Evidence>,
1541    #[serde(default)]
1542    pub negative_evidence: Vec<NegativeEvidence>,
1543    pub confidence_summary: String,
1544    #[serde(default)]
1545    pub confidence_breakdown: ConfidenceBreakdown,
1546}
1547
1548#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1549pub struct ToolCallRecommendation {
1550    pub tool: String,
1551    pub purpose: String,
1552    pub arguments: serde_json::Value,
1553}
1554
1555#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1556pub struct PlanReport {
1557    pub task: String,
1558    pub summary: String,
1559    pub primary_context: Vec<SearchResult>,
1560    pub relevant_symbols: Vec<Symbol>,
1561    pub impact: ImpactReport,
1562    pub validation: Vec<TestTarget>,
1563    pub risk: RiskReport,
1564    pub recommended_change_boundary: ChangeBoundary,
1565    pub recommended_next_steps: Vec<String>,
1566    pub tool_calls: Vec<ToolCallRecommendation>,
1567    pub memory_facts: Vec<MemorySearchResult>,
1568    #[serde(default)]
1569    pub runtime_signals: Vec<RuntimeSignal>,
1570    pub evidence: Vec<Evidence>,
1571    #[serde(default)]
1572    pub evidence_by_section: BTreeMap<String, Vec<String>>,
1573    #[serde(default)]
1574    pub negative_evidence: Vec<NegativeEvidence>,
1575    pub confidence_summary: String,
1576    #[serde(default)]
1577    pub confidence_breakdown: ConfidenceBreakdown,
1578    #[serde(default)]
1579    pub score_breakdown: Vec<ScoreComponent>,
1580}
1581
1582impl PlanReport {
1583    pub fn reconcile_score_breakdown(&mut self) {
1584        reconcile_score_breakdown(
1585            self.risk.score,
1586            &mut self.score_breakdown,
1587            "plan_risk",
1588            self.evidence
1589                .iter()
1590                .map(|evidence| evidence.id.0.clone())
1591                .collect(),
1592            "plan risk score",
1593        );
1594        for result in &mut self.primary_context {
1595            result.reconcile_score_breakdown();
1596        }
1597        self.impact.reconcile_score_breakdown();
1598        for test in &mut self.validation {
1599            test.reconcile_score_breakdown();
1600        }
1601    }
1602}
1603
1604#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1605pub struct PatchPlan {
1606    pub id: PatchId,
1607    pub task: String,
1608    pub allowed_files: Vec<PathBuf>,
1609    pub caution_files: Vec<PathBuf>,
1610    pub forbidden_files: Vec<PathBuf>,
1611    pub change_steps: Vec<String>,
1612    pub risks: Vec<String>,
1613    pub assumptions: Vec<String>,
1614    pub tests: Vec<TestTarget>,
1615    pub rollback_notes: Vec<String>,
1616    pub unified_diff: Option<String>,
1617    pub requires_approval: bool,
1618    pub evidence: Vec<Evidence>,
1619}
1620
1621#[cfg(test)]
1622mod tests {
1623    use super::{
1624        reconcile_score_breakdown, score_component_total, Confidence, ConfidenceBreakdown,
1625        ConfidenceSignalInput, EdgeId, Evidence, EvidenceSourceType, FileRange, GitChangeKind,
1626        GitCommitId, GitCommitRecord, GitFileTouch, GitSymbolTouch, GraphEdge, GraphEdgeType,
1627        GraphNode, GraphNodeType, HistoryRecordId, HistorySnapshot, HistorySummary, LineRange,
1628        NodeId, Owner, ScoreComponent, SymbolId, HISTORY_SCHEMA_VERSION,
1629    };
1630    use chrono::{TimeZone, Utc};
1631    use std::collections::BTreeMap;
1632
1633    #[test]
1634    fn reconciliation_adds_delta_to_match_surfaced_score() {
1635        let mut components = vec![ScoreComponent::single(
1636            "base",
1637            0.4,
1638            vec!["ev:base".into()],
1639            "base signal",
1640        )];
1641
1642        reconcile_score_breakdown(
1643            0.65,
1644            &mut components,
1645            "fallback",
1646            vec!["ev:adjust".into()],
1647            "test score",
1648        );
1649
1650        assert_eq!(components.len(), 2);
1651        assert!((score_component_total(&components) - 0.65).abs() < 0.001);
1652        assert_eq!(components[1].signal, "score_reconciliation");
1653    }
1654
1655    #[test]
1656    fn reconciliation_creates_fallback_for_empty_components() {
1657        let mut components = Vec::new();
1658
1659        reconcile_score_breakdown(
1660            0.85,
1661            &mut components,
1662            "confidence",
1663            vec!["test:id".into()],
1664            "test confidence",
1665        );
1666
1667        assert_eq!(components.len(), 1);
1668        assert_eq!(components[0].signal, "confidence");
1669        assert!((score_component_total(&components) - 0.85).abs() < 0.001);
1670    }
1671
1672    #[test]
1673    fn confidence_breakdown_is_stable_for_same_signals() {
1674        let input = ConfidenceSignalInput {
1675            primary_file_count: 2,
1676            evidence_count: 8,
1677            exact_reference_count: 2,
1678            validation_count: 2,
1679            validation_with_command_count: 1,
1680            negative_evidence_count: 0,
1681            allowed_file_count: 2,
1682            runtime_signal_count: 1,
1683        };
1684
1685        let first = ConfidenceBreakdown::from_signals(input);
1686        let second = ConfidenceBreakdown::from_signals(input);
1687
1688        assert_eq!(first.overall_enum, second.overall_enum);
1689        assert_eq!(first.overall_score, second.overall_score);
1690        assert_eq!(first.components, second.components);
1691        assert!(first.caveats.is_empty());
1692        assert!(first.blockers.is_empty());
1693    }
1694
1695    #[test]
1696    fn confidence_drops_without_exact_tests_or_runtime() {
1697        let grounded = ConfidenceBreakdown::from_signals(ConfidenceSignalInput {
1698            primary_file_count: 1,
1699            evidence_count: 6,
1700            exact_reference_count: 1,
1701            validation_count: 1,
1702            validation_with_command_count: 1,
1703            negative_evidence_count: 0,
1704            allowed_file_count: 1,
1705            runtime_signal_count: 1,
1706        });
1707        let thin = ConfidenceBreakdown::from_signals(ConfidenceSignalInput {
1708            primary_file_count: 1,
1709            evidence_count: 6,
1710            exact_reference_count: 0,
1711            validation_count: 0,
1712            validation_with_command_count: 0,
1713            negative_evidence_count: 0,
1714            allowed_file_count: 1,
1715            runtime_signal_count: 0,
1716        });
1717
1718        assert!(thin.overall_score < grounded.overall_score);
1719        assert_eq!(thin.overall_enum, Confidence::Medium);
1720        assert!(thin
1721            .caveats
1722            .iter()
1723            .any(|caveat| caveat.contains("exact symbol/reference")));
1724        assert!(thin
1725            .caveats
1726            .iter()
1727            .any(|caveat| caveat.contains("no validation")));
1728        assert!(thin
1729            .caveats
1730            .iter()
1731            .any(|caveat| caveat.contains("runtime corroboration")));
1732    }
1733
1734    #[test]
1735    fn negative_evidence_prevents_false_high_confidence() {
1736        let breakdown = ConfidenceBreakdown::from_signals(ConfidenceSignalInput {
1737            primary_file_count: 3,
1738            evidence_count: 12,
1739            exact_reference_count: 3,
1740            validation_count: 3,
1741            validation_with_command_count: 3,
1742            negative_evidence_count: 1,
1743            allowed_file_count: 3,
1744            runtime_signal_count: 1,
1745        });
1746
1747        assert!(breakdown.overall_score <= 0.60);
1748        assert_ne!(breakdown.overall_enum, Confidence::High);
1749        assert!(!breakdown.blockers.is_empty());
1750    }
1751
1752    #[test]
1753    fn history_snapshot_round_trips_with_versioned_records() {
1754        let committed_at = Utc.with_ymd_and_hms(2026, 6, 1, 12, 0, 0).unwrap();
1755        let commit = GitCommitRecord {
1756            id: GitCommitId::new("abc123"),
1757            parent_ids: vec![GitCommitId::new("parent123")],
1758            author: Owner {
1759                name: "Ada".into(),
1760                email: Some("ada@example.com".into()),
1761            },
1762            committer: None,
1763            authored_at: committed_at,
1764            committed_at,
1765            summary: "Add typed history".into(),
1766            message: "Add typed history\n\nPersist first-class records.".into(),
1767            file_count: 1,
1768        };
1769        let touch = GitFileTouch {
1770            id: HistoryRecordId::new("touch-1"),
1771            commit_id: commit.id.clone(),
1772            path: "src/history.rs".into(),
1773            previous_path: None,
1774            change_kind: GitChangeKind::Added,
1775            additions: Some(42),
1776            deletions: Some(0),
1777            touched_at: committed_at,
1778        };
1779        let snapshot = HistorySnapshot {
1780            schema_version: HISTORY_SCHEMA_VERSION,
1781            commits: vec![commit],
1782            file_touches: vec![touch],
1783            symbol_touches: Vec::new(),
1784            cochange_edges: Vec::new(),
1785            reviewer_evidence: Vec::new(),
1786        };
1787
1788        let json = serde_json::to_string(&snapshot).unwrap();
1789        let decoded: HistorySnapshot = serde_json::from_str(&json).unwrap();
1790
1791        assert_eq!(decoded, snapshot);
1792        assert_eq!(
1793            HistorySnapshot::empty().schema_version,
1794            HISTORY_SCHEMA_VERSION
1795        );
1796    }
1797
1798    #[test]
1799    fn empty_history_summary_exposes_uncertainty() {
1800        let summary = HistorySummary::empty("src/missing.rs");
1801
1802        assert!(summary.recent_commits.is_empty());
1803        assert!(!summary.uncertainty.is_empty());
1804        assert!(summary.uncertainty[0].contains("no persisted history evidence"));
1805    }
1806
1807    #[test]
1808    fn legacy_symbol_touch_json_remains_compatible() {
1809        let decoded: GitSymbolTouch = serde_json::from_value(serde_json::json!({
1810            "id": "touch",
1811            "commit_id": "abc123",
1812            "symbol_id": "symbol",
1813            "qualified_name": "crate::symbol",
1814            "file_path": "src/lib.rs",
1815            "change_kind": "modified",
1816            "touched_at": "2026-06-01T12:00:00Z"
1817        }))
1818        .unwrap();
1819
1820        assert_eq!(decoded.symbol_id, Some(SymbolId::new("symbol")));
1821        assert!(decoded.line_ranges.is_empty());
1822        assert_eq!(decoded.confidence, Confidence::Low);
1823        assert!(decoded.uncertainty.is_empty());
1824    }
1825
1826    #[test]
1827    fn legacy_graph_json_deserializes_with_default_metadata() {
1828        let decoded_node: GraphNode = serde_json::from_value(serde_json::json!({
1829            "id": "node:file",
1830            "node_type": "file",
1831            "label": "src/lib.rs",
1832            "file_id": "file:src/lib.rs",
1833            "symbol_id": null
1834        }))
1835        .unwrap();
1836        assert!(decoded_node.properties.is_empty());
1837        assert!(decoded_node.schema_version.is_none());
1838        assert!(decoded_node.ambiguity.is_empty());
1839        assert!(decoded_node.quality_notes.is_empty());
1840
1841        let decoded_edge: GraphEdge = serde_json::from_value(serde_json::json!({
1842            "id": "edge:defines",
1843            "from": "node:file",
1844            "to": "node:symbol",
1845            "edge_type": "DEFINES",
1846            "evidence": {
1847                "id": "evidence:legacy",
1848                "source": "tree-sitter",
1849                "source_type": "tree_sitter",
1850                "file_range": {
1851                    "path": "src/lib.rs",
1852                    "line_range": { "start": 1, "end": 3 }
1853                },
1854                "symbol_id": "symbol:main",
1855                "confidence": "high",
1856                "message": "legacy graph evidence",
1857                "indexed_at": "2026-06-01T12:00:00Z"
1858            }
1859        }))
1860        .unwrap();
1861        assert!(decoded_edge.properties.is_empty());
1862        assert!(decoded_edge.schema_version.is_none());
1863        assert!(decoded_edge.quality_notes.is_empty());
1864        assert!(decoded_edge.evidence.confidence_score.is_none());
1865        assert!(decoded_edge.evidence.confidence_reason.is_none());
1866        assert!(decoded_edge.evidence.freshness.is_none());
1867    }
1868
1869    #[test]
1870    fn enriched_graph_json_round_trips_metadata() {
1871        let indexed_at = Utc.with_ymd_and_hms(2026, 6, 1, 12, 0, 0).unwrap();
1872        let node = GraphNode {
1873            id: NodeId::new("node:file"),
1874            node_type: GraphNodeType::File,
1875            label: "src/lib.rs".into(),
1876            file_id: None,
1877            symbol_id: Some(SymbolId::new("symbol:main")),
1878            properties: BTreeMap::from([(
1879                "qualified_name".into(),
1880                serde_json::Value::String("crate::main".into()),
1881            )]),
1882            schema_version: Some("graph-v1".into()),
1883            source_pass: Some("tree_sitter".into()),
1884            index_mode: Some("scip".into()),
1885            extractor_version: Some("open-kioku-test".into()),
1886            ambiguity: vec!["overloaded symbol name".into()],
1887            quality_notes: vec!["exact definition".into()],
1888        };
1889        let edge = GraphEdge {
1890            id: EdgeId::new("edge:defines"),
1891            from: NodeId::new("node:file"),
1892            to: NodeId::new("node:symbol"),
1893            edge_type: GraphEdgeType::Defines,
1894            evidence: Evidence {
1895                id: super::EvidenceId::new("evidence:rich"),
1896                source: "scip".into(),
1897                source_type: EvidenceSourceType::Scip,
1898                file_range: Some(FileRange {
1899                    path: "src/lib.rs".into(),
1900                    line_range: Some(LineRange { start: 1, end: 1 }),
1901                }),
1902                symbol_id: Some(SymbolId::new("symbol:main")),
1903                confidence: Confidence::Exact,
1904                message: "exact reference".into(),
1905                indexed_at,
1906                confidence_score: Some(0.99),
1907                confidence_reason: Some("SCIP exact occurrence".into()),
1908                freshness: Some("fresh".into()),
1909            },
1910            properties: BTreeMap::from([("call_kind".into(), serde_json::json!("direct"))]),
1911            schema_version: Some("graph-v1".into()),
1912            source_pass: Some("scip".into()),
1913            index_mode: Some("full".into()),
1914            extractor_version: Some("scip-cli".into()),
1915            ambiguity: vec!["dynamic dispatch not expanded".into()],
1916            quality_notes: vec!["exact edge".into()],
1917        };
1918
1919        let decoded_node: GraphNode =
1920            serde_json::from_str(&serde_json::to_string(&node).unwrap()).unwrap();
1921        let decoded_edge: GraphEdge =
1922            serde_json::from_str(&serde_json::to_string(&edge).unwrap()).unwrap();
1923
1924        assert_eq!(decoded_node.properties, node.properties);
1925        assert_eq!(decoded_node.schema_version, Some("graph-v1".into()));
1926        assert_eq!(decoded_node.quality_notes, vec!["exact definition"]);
1927        assert_eq!(decoded_edge.properties, edge.properties);
1928        assert_eq!(decoded_edge.evidence.confidence_score, Some(0.99));
1929        assert_eq!(
1930            decoded_edge.evidence.confidence_reason.as_deref(),
1931            Some("SCIP exact occurrence")
1932        );
1933        assert_eq!(decoded_edge.evidence.freshness.as_deref(), Some("fresh"));
1934    }
1935}