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
8macro_rules! id_type {
9    ($name:ident) => {
10        #[derive(
11            Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
12        )]
13        pub struct $name(pub String);
14
15        impl $name {
16            pub fn new(value: impl Into<String>) -> Self {
17                Self(value.into())
18            }
19        }
20
21        impl fmt::Display for $name {
22            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
23                f.write_str(&self.0)
24            }
25        }
26    };
27}
28
29id_type!(RepositoryId);
30id_type!(FileId);
31id_type!(FileVersionId);
32id_type!(SymbolId);
33id_type!(NodeId);
34id_type!(EdgeId);
35id_type!(PatchId);
36id_type!(EvidenceId);
37id_type!(MemoryFactId);
38id_type!(ContextHandleId);
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
41#[serde(rename_all = "snake_case")]
42pub enum Confidence {
43    Low,
44    Medium,
45    High,
46    Exact,
47}
48
49impl Confidence {
50    pub fn score(self) -> f32 {
51        match self {
52            Self::Low => 0.35,
53            Self::Medium => 0.6,
54            Self::High => 0.85,
55            Self::Exact => 1.0,
56        }
57    }
58
59    pub fn from_score(score: f32) -> Self {
60        if score >= 0.95 {
61            Self::Exact
62        } else if score >= 0.75 {
63            Self::High
64        } else if score >= 0.55 {
65            Self::Medium
66        } else {
67            Self::Low
68        }
69    }
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
73pub struct ConfidenceBreakdown {
74    pub overall_enum: Confidence,
75    pub overall_score: f32,
76    pub components: Vec<ScoreComponent>,
77    pub blockers: Vec<String>,
78    pub caveats: Vec<String>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
82pub struct NegativeEvidence {
83    pub query: String,
84    pub scope: String,
85    pub inspected_sources: Vec<String>,
86    pub reason: String,
87    pub confidence: f32,
88    pub suggested_next_probe: Option<String>,
89}
90
91impl Default for ConfidenceBreakdown {
92    fn default() -> Self {
93        Self {
94            overall_enum: Confidence::Low,
95            overall_score: 0.0,
96            components: Vec::new(),
97            blockers: Vec::new(),
98            caveats: Vec::new(),
99        }
100    }
101}
102
103#[derive(Debug, Clone, Copy, Default)]
104pub struct ConfidenceSignalInput {
105    pub primary_file_count: usize,
106    pub evidence_count: usize,
107    pub exact_reference_count: usize,
108    pub validation_count: usize,
109    pub validation_with_command_count: usize,
110    pub negative_evidence_count: usize,
111    pub allowed_file_count: usize,
112    pub runtime_signal_count: usize,
113}
114
115impl ConfidenceBreakdown {
116    pub fn from_signals(input: ConfidenceSignalInput) -> Self {
117        let mut blockers = Vec::new();
118        let mut caveats = Vec::new();
119
120        if input.primary_file_count == 0 {
121            blockers.push("no primary context matched the task".into());
122        }
123        if input.negative_evidence_count > 0 {
124            blockers.push(format!(
125                "{} negative evidence signal(s) lowered confidence",
126                input.negative_evidence_count
127            ));
128        }
129        if input.exact_reference_count == 0 {
130            caveats.push("exact symbol/reference evidence is absent".into());
131        }
132        if input.validation_count == 0 {
133            caveats.push("no validation target was selected".into());
134        } else if input.validation_with_command_count == 0 {
135            caveats.push("validation targets require manual commands".into());
136        }
137        if input.runtime_signal_count == 0 {
138            caveats.push("runtime corroboration is absent".into());
139        }
140        if input.allowed_file_count == 0 {
141            caveats.push("change boundary has no allowed files".into());
142        } else if input.allowed_file_count > 8 {
143            caveats.push("change boundary is broad".into());
144        }
145
146        let evidence_target = input.primary_file_count.max(1) * 2;
147        let evidence_density = if input.primary_file_count == 0 {
148            0.0
149        } else {
150            (input.evidence_count as f32 / evidence_target.max(4) as f32).min(1.0)
151        };
152        if evidence_density < 0.5 {
153            caveats.push("evidence density is thin".into());
154        }
155
156        let exact_reference = if input.exact_reference_count > 0 {
157            1.0
158        } else {
159            0.25
160        };
161        let validation_availability = if input.validation_count > 0 { 1.0 } else { 0.2 };
162        let negative_evidence = if input.negative_evidence_count == 0 {
163            1.0
164        } else if input.negative_evidence_count <= 2 {
165            0.3
166        } else {
167            0.1
168        };
169        let boundary_tightness = if input.primary_file_count == 0 {
170            0.0
171        } else if input.allowed_file_count == 0 {
172            0.3
173        } else if input.allowed_file_count <= 3 {
174            1.0
175        } else if input.allowed_file_count <= 8
176            && input.allowed_file_count <= input.primary_file_count.max(1) * 2
177        {
178            0.85
179        } else {
180            0.45
181        };
182        let runtime_corroboration = if input.runtime_signal_count > 0 {
183            1.0
184        } else {
185            0.25
186        };
187        let test_coverage = if input.validation_count == 0 {
188            0.2
189        } else if input.validation_with_command_count > 0 {
190            1.0
191        } else {
192            0.6
193        };
194
195        let mut components = vec![
196            confidence_component(
197                "evidence_density",
198                evidence_density,
199                0.20,
200                "amount of independent indexed evidence near the selected context",
201            ),
202            confidence_component(
203                "exact_references",
204                exact_reference,
205                0.20,
206                "explicit exact symbol references or SCIP signals",
207            ),
208            confidence_component(
209                "validation_availability",
210                validation_availability,
211                0.15,
212                "presence of validation targets for the likely change",
213            ),
214            confidence_component(
215                "negative_evidence",
216                negative_evidence,
217                0.15,
218                "absence of low-confidence, missing-anchor, or no-match evidence",
219            ),
220            confidence_component(
221                "boundary_tightness",
222                boundary_tightness,
223                0.15,
224                "how narrowly allowed edit files bound the proposed change",
225            ),
226            confidence_component(
227                "runtime_corroboration",
228                runtime_corroboration,
229                0.05,
230                "runtime traces, incidents, or error signals that support the context",
231            ),
232            confidence_component(
233                "test_coverage",
234                test_coverage,
235                0.10,
236                "selected tests with runnable commands",
237            ),
238        ];
239        components.sort_by(|a, b| a.signal.cmp(&b.signal));
240        let mut overall_score = score_component_total(&components).clamp(0.0, 1.0);
241        if input.primary_file_count == 0 {
242            overall_score = overall_score.min(0.35);
243        }
244        if input.exact_reference_count == 0
245            && input.validation_count == 0
246            && input.runtime_signal_count == 0
247        {
248            overall_score = overall_score.min(0.55);
249        }
250        if input.negative_evidence_count > 0 {
251            overall_score = overall_score.min(0.60);
252        }
253
254        blockers.sort();
255        blockers.dedup();
256        caveats.sort();
257        caveats.dedup();
258        if !caveats.is_empty() {
259            overall_score = overall_score.min(0.94);
260        }
261
262        Self {
263            overall_enum: Confidence::from_score(overall_score),
264            overall_score,
265            components,
266            blockers,
267            caveats,
268        }
269    }
270}
271
272fn confidence_component(
273    signal: &'static str,
274    value: f32,
275    weight: f32,
276    rationale: &'static str,
277) -> ScoreComponent {
278    ScoreComponent::new(
279        signal,
280        value,
281        value,
282        weight,
283        value * weight,
284        Vec::new(),
285        rationale,
286    )
287}
288
289#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
290pub struct LineRange {
291    pub start: u32,
292    pub end: u32,
293}
294
295impl LineRange {
296    pub fn single(line: u32) -> Self {
297        Self {
298            start: line,
299            end: line,
300        }
301    }
302}
303
304#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
305pub struct FileRange {
306    pub path: PathBuf,
307    pub line_range: Option<LineRange>,
308}
309
310#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
311#[serde(rename_all = "snake_case")]
312pub enum EvidenceSourceType {
313    TreeSitter,
314    Scip,
315    Lsp,
316    Regex,
317    Lexical,
318    Semantic,
319    Runtime,
320    GitHistory,
321    StaticAnalysis,
322    ExternalIntegration,
323    Heuristic,
324}
325
326#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
327pub struct Evidence {
328    pub id: EvidenceId,
329    pub source: String,
330    pub source_type: EvidenceSourceType,
331    pub file_range: Option<FileRange>,
332    pub symbol_id: Option<SymbolId>,
333    pub confidence: Confidence,
334    pub message: String,
335    pub indexed_at: DateTime<Utc>,
336}
337
338#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
339pub struct ScoreComponent {
340    pub signal: String,
341    pub raw_value: f32,
342    pub normalized_value: f32,
343    pub weight: f32,
344    pub contribution: f32,
345    pub evidence_ids: Vec<String>,
346    pub rationale: String,
347}
348
349impl ScoreComponent {
350    pub fn new(
351        signal: impl Into<String>,
352        raw_value: f32,
353        normalized_value: f32,
354        weight: f32,
355        contribution: f32,
356        evidence_ids: Vec<String>,
357        rationale: impl Into<String>,
358    ) -> Self {
359        Self {
360            signal: signal.into(),
361            raw_value,
362            normalized_value,
363            weight,
364            contribution,
365            evidence_ids,
366            rationale: rationale.into(),
367        }
368    }
369
370    pub fn single(
371        signal: impl Into<String>,
372        score: f32,
373        evidence_ids: Vec<String>,
374        rationale: impl Into<String>,
375    ) -> Self {
376        Self::new(
377            signal,
378            score,
379            score.clamp(0.0, 1.0),
380            1.0,
381            score,
382            evidence_ids,
383            rationale,
384        )
385    }
386
387    pub fn adjustment(
388        signal: impl Into<String>,
389        contribution: f32,
390        evidence_ids: Vec<String>,
391        rationale: impl Into<String>,
392    ) -> Self {
393        Self::new(
394            signal,
395            contribution,
396            contribution.clamp(-1.0, 1.0),
397            1.0,
398            contribution,
399            evidence_ids,
400            rationale,
401        )
402    }
403}
404
405pub fn score_component_total(components: &[ScoreComponent]) -> f32 {
406    components
407        .iter()
408        .map(|component| component.contribution)
409        .sum()
410}
411
412pub fn reconcile_score_breakdown(
413    score: f32,
414    components: &mut Vec<ScoreComponent>,
415    fallback_signal: &str,
416    evidence_ids: Vec<String>,
417    rationale: &str,
418) {
419    if components.is_empty() {
420        components.push(ScoreComponent::single(
421            fallback_signal,
422            score,
423            evidence_ids,
424            rationale,
425        ));
426        return;
427    }
428
429    let delta = score - score_component_total(components);
430    if delta.abs() > 0.001 {
431        components.push(ScoreComponent::adjustment(
432            "score_reconciliation",
433            delta,
434            evidence_ids,
435            format!("adjusted component total to match surfaced score: {rationale}"),
436        ));
437    }
438}
439
440#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
441pub struct Repository {
442    pub id: RepositoryId,
443    pub name: String,
444    pub root: PathBuf,
445    pub branch: Option<String>,
446    pub commit: Option<String>,
447    pub indexed_at: Option<DateTime<Utc>>,
448}
449
450#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
451pub struct Commit {
452    pub sha: String,
453    pub message: Option<String>,
454    pub authored_at: Option<DateTime<Utc>>,
455}
456
457#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
458pub struct Branch {
459    pub name: String,
460    pub head: Option<String>,
461}
462
463#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
464#[serde(rename_all = "snake_case")]
465pub enum Language {
466    Rust,
467    Java,
468    TypeScript,
469    JavaScript,
470    Python,
471    Go,
472    Yaml,
473    Json,
474    Toml,
475    Sql,
476    Markdown,
477    Text,
478    Unknown,
479}
480
481#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
482pub struct File {
483    pub id: FileId,
484    pub repository_id: RepositoryId,
485    pub path: PathBuf,
486    pub language: Language,
487    pub size_bytes: u64,
488    pub content_hash: String,
489    pub is_generated: bool,
490    pub is_vendor: bool,
491}
492
493#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
494pub struct FileVersion {
495    pub id: FileVersionId,
496    pub file_id: FileId,
497    pub commit: Option<String>,
498    pub content_hash: String,
499    pub indexed_at: DateTime<Utc>,
500}
501
502#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
503#[serde(rename_all = "snake_case")]
504pub enum SymbolKind {
505    Module,
506    Package,
507    Class,
508    Trait,
509    Interface,
510    Function,
511    Method,
512    Field,
513    Variable,
514    Constant,
515    Endpoint,
516    DatabaseTable,
517    Test,
518    Unknown,
519}
520
521#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
522pub struct Symbol {
523    pub id: SymbolId,
524    pub name: String,
525    pub qualified_name: String,
526    pub kind: SymbolKind,
527    pub file_id: FileId,
528    pub range: Option<LineRange>,
529    pub language: Language,
530    pub confidence: Confidence,
531    pub provenance: EvidenceSourceType,
532}
533
534#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
535pub struct SymbolOccurrence {
536    pub symbol_id: SymbolId,
537    pub file_id: FileId,
538    pub range: Option<LineRange>,
539    pub is_definition: bool,
540    pub confidence: Confidence,
541    pub provenance: EvidenceSourceType,
542}
543
544pub type Reference = SymbolOccurrence;
545pub type Definition = SymbolOccurrence;
546
547#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
548pub struct Import {
549    pub file_id: FileId,
550    pub imported: String,
551    pub range: Option<LineRange>,
552    pub confidence: Confidence,
553}
554
555#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
556pub struct AnalysisFact {
557    pub id: String,
558    pub file_id: FileId,
559    pub symbol_id: Option<SymbolId>,
560    pub target: String,
561    pub target_kind: GraphNodeType,
562    pub edge_type: GraphEdgeType,
563    pub range: Option<LineRange>,
564    pub confidence: Confidence,
565    pub source: String,
566    pub source_type: EvidenceSourceType,
567    pub message: String,
568}
569
570#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
571pub struct CodeChunk {
572    pub id: String,
573    pub file_id: FileId,
574    pub range: LineRange,
575    pub language: Language,
576    pub text: String,
577    pub symbol_id: Option<SymbolId>,
578}
579
580#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
581pub struct Diagnostic {
582    pub severity: String,
583    pub message: String,
584    pub file_range: Option<FileRange>,
585}
586
587#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
588pub struct TestTarget {
589    pub id: String,
590    pub name: String,
591    pub file_id: FileId,
592    pub range: Option<LineRange>,
593    pub command: Option<String>,
594    pub confidence: Confidence,
595    pub reason: String,
596    #[serde(default)]
597    pub evidence_refs: Vec<String>,
598    #[serde(default)]
599    pub score_breakdown: Vec<ScoreComponent>,
600}
601
602impl TestTarget {
603    pub fn reconcile_score_breakdown(&mut self) {
604        if self.evidence_refs.is_empty() {
605            self.evidence_refs.push(format!("test:{}", self.id));
606        }
607        reconcile_score_breakdown(
608            self.confidence.score(),
609            &mut self.score_breakdown,
610            "test_confidence",
611            self.evidence_refs.clone(),
612            &self.reason,
613        );
614    }
615}
616
617#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
618pub struct BuildTarget {
619    pub id: String,
620    pub name: String,
621    pub command: String,
622    pub files: Vec<FileId>,
623}
624
625#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
626pub struct RuntimeSignal {
627    pub id: String,
628    pub kind: String,
629    pub message: String,
630    pub file_range: Option<FileRange>,
631    pub occurred_at: Option<DateTime<Utc>>,
632    pub confidence: Confidence,
633}
634
635#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
636pub struct Owner {
637    pub name: String,
638    pub email: Option<String>,
639}
640
641#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
642pub struct ArchitectureComponent {
643    pub id: String,
644    pub name: String,
645    pub paths: Vec<String>,
646    pub evidence: Vec<Evidence>,
647}
648
649#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
650pub struct IndexManifest {
651    pub repository: Repository,
652    pub file_count: usize,
653    pub symbol_count: usize,
654    pub chunk_count: usize,
655    pub indexed_at: DateTime<Utc>,
656    pub schema_version: u32,
657    #[serde(default)]
658    pub quality: IndexQuality,
659}
660
661#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
662pub struct IndexQuality {
663    pub scip_enabled: bool,
664    pub scip_mode: String,
665    pub scip_indexes_imported: usize,
666    pub scip_symbols: usize,
667    pub scip_occurrences: usize,
668    pub scip_exact_references: usize,
669    pub test_count: usize,
670    pub import_count: usize,
671    #[serde(default)]
672    pub build_systems: Vec<String>,
673    #[serde(default)]
674    pub codeql_databases: usize,
675    #[serde(default)]
676    pub coverage_reports: usize,
677    #[serde(default)]
678    pub junit_reports: usize,
679    #[serde(default)]
680    pub static_analysis_facts: usize,
681    #[serde(default)]
682    pub runtime_analysis_facts: usize,
683    #[serde(default)]
684    pub git_history_facts: usize,
685    #[serde(default)]
686    pub semantic_provider_notes: Vec<String>,
687    pub quality_notes: Vec<String>,
688}
689
690#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
691#[serde(rename_all = "snake_case")]
692pub enum GraphNodeType {
693    File,
694    Directory,
695    Module,
696    Package,
697    Class,
698    Trait,
699    Interface,
700    Function,
701    Method,
702    Field,
703    Endpoint,
704    DatabaseTable,
705    Collection,
706    Queue,
707    Topic,
708    ConfigKey,
709    Test,
710    BuildTarget,
711    RuntimeError,
712    Ticket,
713    PullRequest,
714    ArchitectureComponent,
715}
716
717#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
718#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
719pub enum GraphEdgeType {
720    Contains,
721    Defines,
722    References,
723    Calls,
724    Implements,
725    Extends,
726    Imports,
727    DependsOn,
728    ExposesEndpoint,
729    CallsEndpoint,
730    ReadsConfig,
731    WritesConfig,
732    ReadsTable,
733    WritesTable,
734    PublishesEvent,
735    ConsumesEvent,
736    Tests,
737    OwnedBy,
738    ChangedBy,
739    FailedIn,
740    MentionedIn,
741    RelatedToTicket,
742}
743
744#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
745pub struct GraphNode {
746    pub id: NodeId,
747    pub node_type: GraphNodeType,
748    pub label: String,
749    pub file_id: Option<FileId>,
750    pub symbol_id: Option<SymbolId>,
751}
752
753#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
754pub struct GraphEdge {
755    pub id: EdgeId,
756    pub from: NodeId,
757    pub to: NodeId,
758    pub edge_type: GraphEdgeType,
759    pub evidence: Evidence,
760}
761
762#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
763pub struct SearchResult {
764    pub path: PathBuf,
765    pub line_range: Option<LineRange>,
766    pub snippet: String,
767    pub symbol: Option<Symbol>,
768    pub score: f32,
769    pub match_reason: String,
770    pub evidence: Vec<String>,
771    #[serde(default)]
772    pub evidence_refs: Vec<String>,
773    pub confidence: f32,
774    #[serde(default)]
775    pub score_breakdown: Vec<ScoreComponent>,
776}
777
778impl SearchResult {
779    pub fn derived_evidence_ids(&self) -> Vec<String> {
780        if !self.evidence_refs.is_empty() {
781            return self.evidence_refs.clone();
782        }
783        search_result_evidence_ids(&self.path, &self.line_range, self.evidence.len())
784    }
785
786    pub fn reconcile_score_breakdown(&mut self) {
787        if self.evidence_refs.is_empty() {
788            self.evidence_refs =
789                search_result_evidence_ids(&self.path, &self.line_range, self.evidence.len());
790        }
791        reconcile_score_breakdown(
792            self.score,
793            &mut self.score_breakdown,
794            "search_score",
795            self.evidence_refs.clone(),
796            &self.match_reason,
797        );
798    }
799
800    pub fn add_score_component(&mut self, component: ScoreComponent) {
801        self.score_breakdown.push(component);
802    }
803}
804
805pub fn search_result_evidence_ids(
806    path: &Path,
807    line_range: &Option<LineRange>,
808    evidence_len: usize,
809) -> Vec<String> {
810    let range = line_range
811        .as_ref()
812        .map(|range| format!("{}-{}", range.start, range.end))
813        .unwrap_or_else(|| "unknown".into());
814    let count = evidence_len.max(1);
815    (0..count)
816        .map(|index| format!("search:{}:{range}:{index}", path.display()))
817        .collect()
818}
819
820#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
821pub struct EntityLink {
822    pub kind: String,
823    pub value: String,
824    pub file_range: Option<FileRange>,
825    pub confidence: Confidence,
826}
827
828#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
829pub struct MemoryFact {
830    pub id: MemoryFactId,
831    pub text: String,
832    pub source: String,
833    pub confidence: Confidence,
834    pub entities: Vec<EntityLink>,
835    pub created_at: DateTime<Utc>,
836}
837
838#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
839pub struct MemorySearchResult {
840    pub fact: MemoryFact,
841    pub score: f32,
842    pub match_reason: String,
843    pub evidence: Vec<String>,
844}
845
846#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
847pub struct ContextHandle {
848    pub id: ContextHandleId,
849    pub kind: String,
850    pub summary: String,
851    pub file_range: Option<FileRange>,
852    pub entities: Vec<EntityLink>,
853    pub original_tokens_estimate: usize,
854    pub compressed_tokens_estimate: usize,
855}
856
857#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
858pub struct CompressedContextPack {
859    pub task: String,
860    pub summary: String,
861    pub handles: Vec<ContextHandle>,
862    pub original_tokens_estimate: usize,
863    pub compressed_tokens_estimate: usize,
864    pub compression_ratio: f32,
865    pub evidence: Vec<Evidence>,
866}
867
868#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
869pub struct RiskReport {
870    pub level: String,
871    pub score: f32,
872    pub reasons: Vec<String>,
873}
874
875#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
876pub struct BoundaryFileRule {
877    pub path: PathBuf,
878    pub reason: String,
879    #[serde(default)]
880    pub evidence_refs: Vec<String>,
881    #[serde(default)]
882    pub symbols: Vec<String>,
883}
884
885#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
886pub struct BoundaryForbiddenRule {
887    pub pattern: String,
888    pub reason: String,
889    #[serde(default)]
890    pub evidence_refs: Vec<String>,
891}
892
893#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
894pub struct BoundaryExpansionRequirement {
895    pub reason: String,
896    #[serde(default)]
897    pub required_evidence_refs: Vec<String>,
898}
899
900#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
901pub struct BoundarySignalHooks {
902    #[serde(default)]
903    pub architecture_components: Vec<String>,
904    #[serde(default)]
905    pub ownership_sources: Vec<String>,
906    #[serde(default)]
907    pub cochange_sources: Vec<String>,
908}
909
910#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
911pub struct ChangeBoundary {
912    pub allowed_files: Vec<PathBuf>,
913    pub caution_files: Vec<PathBuf>,
914    pub forbidden_files: Vec<PathBuf>,
915    #[serde(default)]
916    pub evidence_refs: Vec<String>,
917    #[serde(default)]
918    pub allowed_symbols: Vec<String>,
919    #[serde(default)]
920    pub allowed_rules: Vec<BoundaryFileRule>,
921    #[serde(default)]
922    pub caution_rules: Vec<BoundaryFileRule>,
923    #[serde(default)]
924    pub forbidden_rules: Vec<BoundaryForbiddenRule>,
925    #[serde(default)]
926    pub expansion_requirements: Vec<BoundaryExpansionRequirement>,
927    #[serde(default)]
928    pub signal_hooks: BoundarySignalHooks,
929}
930
931#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
932pub struct ValidationPlan {
933    pub commands: Vec<String>,
934    pub tests: Vec<TestTarget>,
935    pub requires_approval: bool,
936    pub evidence: Vec<Evidence>,
937}
938
939#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
940pub struct ImpactReport {
941    pub target: String,
942    pub direct_impacts: Vec<SearchResult>,
943    pub indirect_impacts: Vec<SearchResult>,
944    pub risk_report: RiskReport,
945    pub evidence: Vec<Evidence>,
946    #[serde(default)]
947    pub score_breakdown: Vec<ScoreComponent>,
948}
949
950impl ImpactReport {
951    pub fn reconcile_score_breakdown(&mut self) {
952        reconcile_score_breakdown(
953            self.risk_report.score,
954            &mut self.score_breakdown,
955            "impact_risk",
956            self.evidence
957                .iter()
958                .map(|evidence| evidence.id.0.clone())
959                .collect(),
960            "impact risk score",
961        );
962        for result in &mut self.direct_impacts {
963            result.reconcile_score_breakdown();
964        }
965        for result in &mut self.indirect_impacts {
966            result.reconcile_score_breakdown();
967        }
968    }
969}
970
971#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
972pub struct ContextPack {
973    pub task: String,
974    pub intent: String,
975    pub primary_files: Vec<SearchResult>,
976    pub primary_symbols: Vec<Symbol>,
977    pub supporting_files: Vec<SearchResult>,
978    pub dependency_edges: Vec<GraphEdge>,
979    pub runtime_signals: Vec<RuntimeSignal>,
980    pub test_candidates: Vec<TestTarget>,
981    pub risk_report: RiskReport,
982    pub recommended_change_boundary: ChangeBoundary,
983    pub validation_plan: ValidationPlan,
984    pub evidence: Vec<Evidence>,
985    #[serde(default)]
986    pub negative_evidence: Vec<NegativeEvidence>,
987    pub confidence_summary: String,
988    #[serde(default)]
989    pub confidence_breakdown: ConfidenceBreakdown,
990}
991
992#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
993pub struct ToolCallRecommendation {
994    pub tool: String,
995    pub purpose: String,
996    pub arguments: serde_json::Value,
997}
998
999#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1000pub struct PlanReport {
1001    pub task: String,
1002    pub summary: String,
1003    pub primary_context: Vec<SearchResult>,
1004    pub relevant_symbols: Vec<Symbol>,
1005    pub impact: ImpactReport,
1006    pub validation: Vec<TestTarget>,
1007    pub risk: RiskReport,
1008    pub recommended_change_boundary: ChangeBoundary,
1009    pub recommended_next_steps: Vec<String>,
1010    pub tool_calls: Vec<ToolCallRecommendation>,
1011    pub memory_facts: Vec<MemorySearchResult>,
1012    #[serde(default)]
1013    pub runtime_signals: Vec<RuntimeSignal>,
1014    pub evidence: Vec<Evidence>,
1015    #[serde(default)]
1016    pub evidence_by_section: BTreeMap<String, Vec<String>>,
1017    #[serde(default)]
1018    pub negative_evidence: Vec<NegativeEvidence>,
1019    pub confidence_summary: String,
1020    #[serde(default)]
1021    pub confidence_breakdown: ConfidenceBreakdown,
1022    #[serde(default)]
1023    pub score_breakdown: Vec<ScoreComponent>,
1024}
1025
1026impl PlanReport {
1027    pub fn reconcile_score_breakdown(&mut self) {
1028        reconcile_score_breakdown(
1029            self.risk.score,
1030            &mut self.score_breakdown,
1031            "plan_risk",
1032            self.evidence
1033                .iter()
1034                .map(|evidence| evidence.id.0.clone())
1035                .collect(),
1036            "plan risk score",
1037        );
1038        for result in &mut self.primary_context {
1039            result.reconcile_score_breakdown();
1040        }
1041        self.impact.reconcile_score_breakdown();
1042        for test in &mut self.validation {
1043            test.reconcile_score_breakdown();
1044        }
1045    }
1046}
1047
1048#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1049pub struct PatchPlan {
1050    pub id: PatchId,
1051    pub task: String,
1052    pub allowed_files: Vec<PathBuf>,
1053    pub caution_files: Vec<PathBuf>,
1054    pub forbidden_files: Vec<PathBuf>,
1055    pub change_steps: Vec<String>,
1056    pub risks: Vec<String>,
1057    pub assumptions: Vec<String>,
1058    pub tests: Vec<TestTarget>,
1059    pub rollback_notes: Vec<String>,
1060    pub unified_diff: Option<String>,
1061    pub requires_approval: bool,
1062    pub evidence: Vec<Evidence>,
1063}
1064
1065#[cfg(test)]
1066mod tests {
1067    use super::{
1068        reconcile_score_breakdown, score_component_total, Confidence, ConfidenceBreakdown,
1069        ConfidenceSignalInput, ScoreComponent,
1070    };
1071
1072    #[test]
1073    fn reconciliation_adds_delta_to_match_surfaced_score() {
1074        let mut components = vec![ScoreComponent::single(
1075            "base",
1076            0.4,
1077            vec!["ev:base".into()],
1078            "base signal",
1079        )];
1080
1081        reconcile_score_breakdown(
1082            0.65,
1083            &mut components,
1084            "fallback",
1085            vec!["ev:adjust".into()],
1086            "test score",
1087        );
1088
1089        assert_eq!(components.len(), 2);
1090        assert!((score_component_total(&components) - 0.65).abs() < 0.001);
1091        assert_eq!(components[1].signal, "score_reconciliation");
1092    }
1093
1094    #[test]
1095    fn reconciliation_creates_fallback_for_empty_components() {
1096        let mut components = Vec::new();
1097
1098        reconcile_score_breakdown(
1099            0.85,
1100            &mut components,
1101            "confidence",
1102            vec!["test:id".into()],
1103            "test confidence",
1104        );
1105
1106        assert_eq!(components.len(), 1);
1107        assert_eq!(components[0].signal, "confidence");
1108        assert!((score_component_total(&components) - 0.85).abs() < 0.001);
1109    }
1110
1111    #[test]
1112    fn confidence_breakdown_is_stable_for_same_signals() {
1113        let input = ConfidenceSignalInput {
1114            primary_file_count: 2,
1115            evidence_count: 8,
1116            exact_reference_count: 2,
1117            validation_count: 2,
1118            validation_with_command_count: 1,
1119            negative_evidence_count: 0,
1120            allowed_file_count: 2,
1121            runtime_signal_count: 1,
1122        };
1123
1124        let first = ConfidenceBreakdown::from_signals(input);
1125        let second = ConfidenceBreakdown::from_signals(input);
1126
1127        assert_eq!(first.overall_enum, second.overall_enum);
1128        assert_eq!(first.overall_score, second.overall_score);
1129        assert_eq!(first.components, second.components);
1130        assert!(first.caveats.is_empty());
1131        assert!(first.blockers.is_empty());
1132    }
1133
1134    #[test]
1135    fn confidence_drops_without_exact_tests_or_runtime() {
1136        let grounded = ConfidenceBreakdown::from_signals(ConfidenceSignalInput {
1137            primary_file_count: 1,
1138            evidence_count: 6,
1139            exact_reference_count: 1,
1140            validation_count: 1,
1141            validation_with_command_count: 1,
1142            negative_evidence_count: 0,
1143            allowed_file_count: 1,
1144            runtime_signal_count: 1,
1145        });
1146        let thin = ConfidenceBreakdown::from_signals(ConfidenceSignalInput {
1147            primary_file_count: 1,
1148            evidence_count: 6,
1149            exact_reference_count: 0,
1150            validation_count: 0,
1151            validation_with_command_count: 0,
1152            negative_evidence_count: 0,
1153            allowed_file_count: 1,
1154            runtime_signal_count: 0,
1155        });
1156
1157        assert!(thin.overall_score < grounded.overall_score);
1158        assert_eq!(thin.overall_enum, Confidence::Medium);
1159        assert!(thin
1160            .caveats
1161            .iter()
1162            .any(|caveat| caveat.contains("exact symbol/reference")));
1163        assert!(thin
1164            .caveats
1165            .iter()
1166            .any(|caveat| caveat.contains("no validation")));
1167        assert!(thin
1168            .caveats
1169            .iter()
1170            .any(|caveat| caveat.contains("runtime corroboration")));
1171    }
1172
1173    #[test]
1174    fn negative_evidence_prevents_false_high_confidence() {
1175        let breakdown = ConfidenceBreakdown::from_signals(ConfidenceSignalInput {
1176            primary_file_count: 3,
1177            evidence_count: 12,
1178            exact_reference_count: 3,
1179            validation_count: 3,
1180            validation_with_command_count: 3,
1181            negative_evidence_count: 1,
1182            allowed_file_count: 3,
1183            runtime_signal_count: 1,
1184        });
1185
1186        assert!(breakdown.overall_score <= 0.60);
1187        assert_ne!(breakdown.overall_enum, Confidence::High);
1188        assert!(!breakdown.blockers.is_empty());
1189    }
1190}