1use serde::{Deserialize, Serialize};
24use serde_json::Value as JsonValue;
25use uuid::Uuid;
26
27pub type DeviceId = Uuid;
40
41#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
47#[serde(rename_all = "snake_case")]
48pub enum RecordSource {
49 StaticAnalysis,
51 ClaudeEnrich,
53 SessionHook,
55 DeveloperManual,
57 Import,
59}
60
61#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
68#[serde(rename_all = "snake_case")]
69pub enum AgentKind {
70 Claude,
72 Codex,
74 Cli,
76 Supervisor,
79 Unknown,
81}
82
83#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
85#[serde(rename_all = "snake_case")]
86pub enum Category {
87 Gotcha,
88 File,
89 Decision,
90 Stage,
91 Dependency,
92 DevNote,
93 Session,
94 Analytics,
95}
96
97#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
104#[serde(rename_all = "snake_case")]
105pub enum Priority {
106 Low,
107 Normal,
108 High,
109 Critical,
110}
111
112#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
126#[serde(rename_all = "snake_case")]
127pub enum QualityTier {
128 Suppressed,
129 Poor,
130 Acceptable,
131 Good,
132 Excellent,
133}
134
135#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
137#[serde(rename_all = "snake_case")]
138pub enum QualitySignal {
139 HasImperativeVerb,
141 HasCausality,
142 HasSeveritySet,
143 HasReference,
144 RuleLengthAdequate,
145 ReasonLengthAdequate,
146 AffectedFilesSpecified,
147 HasSpecificIdentifier,
148 VaguePhrasing,
150 NoActionableRule,
151 NoReason,
152 TooShort,
153 DuplicatesFilePurpose,
154}
155
156#[derive(Serialize, Deserialize, Debug, Clone)]
178pub struct QualityScore {
179 pub value: f32,
181 pub tier: QualityTier,
182 pub signals: Vec<QualitySignal>,
183 pub computed_at: u64,
186}
187
188impl QualityScore {
189 pub fn layer0_default() -> Self {
191 Self {
192 value: 0.10,
193 tier: QualityTier::Suppressed,
194 signals: vec![],
195 computed_at: 0,
196 }
197 }
198
199 pub fn doc_comment_default() -> Self {
206 Self {
207 value: 0.40,
208 tier: QualityTier::Acceptable,
209 signals: vec![],
210 computed_at: 0,
211 }
212 }
213
214 pub fn developer_entry_default() -> Self {
225 Self {
226 value: 0.65,
227 tier: QualityTier::Good,
228 signals: vec![],
229 computed_at: 0,
230 }
231 }
232
233 pub fn cochange_default() -> Self {
234 Self {
235 value: 0.40,
236 tier: QualityTier::Acceptable,
237 signals: vec![],
238 computed_at: 0,
239 }
240 }
241
242 pub fn cochange_strong() -> Self {
249 Self {
250 value: 0.60,
251 tier: QualityTier::Acceptable,
252 signals: vec![],
253 computed_at: 0,
254 }
255 }
256
257 pub fn tier_from_value(value: f32) -> QualityTier {
267 if !value.is_finite() || value < 0.2 {
270 QualityTier::Suppressed
271 } else if value < 0.4 {
272 QualityTier::Poor
273 } else if value < 0.7 {
274 QualityTier::Acceptable
275 } else if value < 0.9 {
276 QualityTier::Good
277 } else {
278 QualityTier::Excellent
279 }
280 }
281}
282
283#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
295#[serde(rename_all = "snake_case")]
296pub enum StalenessTier {
297 Fresh,
298 Aging,
299 Stale,
300 Liability,
302 Tombstone,
304}
305
306#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
310#[serde(rename_all = "snake_case")]
311pub enum StalenessSignal {
312 NotAccessedDays(u32),
313 LinesChangedPct(f32),
315 EntryPointsChanged(u32),
316 ImportsChanged(u32),
317 FileDeleted,
318 FileRenamed {
319 new_path: String,
320 },
321 DependencyBumped {
322 dep: String,
323 old_ver: String,
324 new_ver: String,
325 },
326 LinkedFileChanged {
327 path: String,
328 },
329 CascadeFromDecision(String),
331 TodosChanged,
333 UnsafeCountChanged(i32),
335 UnwrapCountChanged(i32),
337 GitCommitsSince(u32),
339}
340
341impl std::fmt::Display for StalenessSignal {
342 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
343 match self {
344 Self::NotAccessedDays(d) => write!(f, "not accessed for {d} days"),
345 Self::LinesChangedPct(pct) => write!(f, "{:.0}% of lines changed", pct * 100.0),
346 Self::EntryPointsChanged(n) => write!(f, "{n} entry points changed"),
347 Self::ImportsChanged(n) => write!(f, "{n} imports changed"),
348 Self::FileDeleted => write!(f, "source file deleted"),
349 Self::FileRenamed { new_path } => write!(f, "file renamed to {new_path}"),
350 Self::DependencyBumped {
351 dep,
352 old_ver,
353 new_ver,
354 } => write!(f, "{dep} bumped {old_ver} \u{2192} {new_ver}"),
355 Self::LinkedFileChanged { path } => write!(f, "linked file {path} changed"),
356 Self::CascadeFromDecision(key) => write!(f, "cascaded from {key}"),
357 Self::TodosChanged => write!(f, "TODOs changed"),
358 Self::UnsafeCountChanged(delta) => write!(f, "unsafe count changed by {delta}"),
359 Self::UnwrapCountChanged(delta) => write!(f, "unwrap count changed by {delta}"),
360 Self::GitCommitsSince(n) => write!(f, "{n} commits since last confirmation"),
361 }
362 }
363}
364
365#[derive(Serialize, Deserialize, Debug, Clone)]
382pub struct StalenessScore {
383 pub value: f32,
385 pub tier: StalenessTier,
386 pub signals: Vec<StalenessSignal>,
387 pub computed_at: u64,
390 pub last_record_sha: String,
393}
394
395impl StalenessScore {
396 pub fn fresh() -> Self {
398 Self {
399 value: 0.0,
400 tier: StalenessTier::Fresh,
401 signals: vec![],
402 computed_at: 0,
403 last_record_sha: String::new(),
404 }
405 }
406
407 pub fn tier_from_value(value: f32) -> StalenessTier {
417 if !value.is_finite() {
418 return StalenessTier::Stale;
419 }
420 if value < 0.2 {
421 StalenessTier::Fresh
422 } else if value < 0.4 {
423 StalenessTier::Aging
424 } else if value < 0.7 {
425 StalenessTier::Stale
426 } else if value < 0.9 {
427 StalenessTier::Liability
428 } else {
429 StalenessTier::Tombstone
430 }
431 }
432}
433
434#[derive(Serialize, Deserialize, Debug, Clone)]
466pub struct ConfidenceScore {
467 pub value: f32,
469 pub confirmation_count: u32,
471 pub contributor_count: u32,
473 pub last_challenged: Option<u64>,
475 pub challenge_count: u32,
476}
477
478impl ConfidenceScore {
479 pub fn base_for_source(source: &RecordSource) -> f32 {
481 match source {
482 RecordSource::DeveloperManual => 0.80,
483 RecordSource::Import => 0.70,
484 RecordSource::ClaudeEnrich => 0.60,
485 RecordSource::SessionHook => 0.50,
486 RecordSource::StaticAnalysis => 0.10,
487 }
488 }
489
490 pub fn for_new_record(source: &RecordSource) -> Self {
496 Self {
497 value: Self::base_for_source(source),
498 confirmation_count: 0,
499 contributor_count: 1,
500 last_challenged: None,
501 challenge_count: 0,
502 }
503 }
504}
505
506#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
512#[serde(rename_all = "snake_case")]
513pub enum TombstoneReason {
514 FileDeleted,
515 FileRenamed { new_path: String },
516 ManualDeletion,
517 Superseded,
518}
519
520#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
524#[serde(rename_all = "snake_case")]
525pub enum RecordLifecycle {
526 Active,
527 Tombstoned { reason: TombstoneReason, at: u64 },
528 Superseded { by_key: String },
529}
530
531#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
540pub struct RecordVersion {
541 pub device_id: DeviceId,
543 pub logical_clock: u64,
545 pub wall_clock: u64,
547}
548
549#[derive(Serialize, Deserialize, Debug, Clone)]
565pub struct Record {
566 pub key: String,
568 pub value: String,
571 pub category: Category,
572 pub priority: Priority,
573 pub tags: Vec<String>,
575 pub created_at: u64,
577 pub updated_at: u64,
579 pub ref_url: Option<String>,
581 pub staleness: StalenessScore,
582 pub lifecycle: RecordLifecycle,
583 pub version: RecordVersion,
586 pub quality: QualityScore,
587 pub access_count: u32,
589 pub last_accessed: u64,
591 pub source: RecordSource,
592 pub confidence: ConfidenceScore,
593 pub gap_analysis_score: f32,
595 #[serde(default)]
606 pub payload: Option<JsonValue>,
607}
608
609impl Record {
610 pub fn device_id(&self) -> DeviceId {
614 self.version.device_id
615 }
616
617 pub fn payload_as<T: serde::de::DeserializeOwned>(&self) -> Option<T> {
622 self.payload
623 .as_ref()
624 .and_then(|p| serde_json::from_value(p.clone()).ok())
625 }
626
627 pub fn layer0_file_stub(
634 key: impl Into<String>,
635 device_id: DeviceId,
636 logical_clock: u64,
637 wall_clock: u64,
638 ) -> Self {
639 Self {
640 key: key.into(),
641 value: String::new(),
642 category: Category::File,
643 priority: Priority::Normal,
644 tags: vec![],
645 created_at: wall_clock,
646 updated_at: wall_clock,
647 ref_url: None,
648 staleness: StalenessScore::fresh(),
649 lifecycle: RecordLifecycle::Active,
650 version: RecordVersion {
651 device_id,
652 logical_clock,
653 wall_clock,
654 },
655 quality: QualityScore::layer0_default(),
656 access_count: 0,
657 last_accessed: 0,
658 source: RecordSource::StaticAnalysis,
659 confidence: ConfidenceScore::for_new_record(&RecordSource::StaticAnalysis),
660 gap_analysis_score: 0.0,
661 payload: None,
662 }
663 }
664}
665
666#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
672#[serde(rename_all = "snake_case")]
673pub enum TodoKind {
674 Todo,
675 Fixme,
676 Hack,
677 Note,
678 Deprecated,
679}
680
681#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
683pub struct TodoComment {
684 pub text: String,
685 pub line: u32,
686 pub kind: TodoKind,
687}
688
689#[derive(Serialize, Deserialize, Debug, Clone)]
695pub struct FileRecord {
696 pub path: String,
697 pub purpose: String,
699 pub entry_points: Vec<String>,
701 pub imports: Vec<String>,
703 pub gotcha_keys: Vec<String>,
705 pub decision_keys: Vec<String>,
707 pub todos: Vec<TodoComment>,
708 pub unsafe_count: u32,
709 pub unwrap_count: u32,
710 pub change_frequency: u32,
712 pub last_author: Option<String>,
713 pub is_hotspot: bool,
715 pub token_cost_estimate: u32,
717 pub last_modified_session: u64,
719 #[serde(default)]
722 pub content_hash: Option<String>,
723 #[serde(default)]
725 pub line_count: u32,
726 #[serde(default)]
730 pub blast_radius: Option<crate::analysis::blast_radius::BlastRadius>,
731 #[serde(default)]
734 pub propagated_staleness: Option<crate::analysis::propagation::PropagatedStaleness>,
735}
736
737impl FileRecord {
738 #[allow(clippy::too_many_arguments)]
744 pub fn layer0_stub(
745 path: impl Into<String>,
746 entry_points: Vec<String>,
747 imports: Vec<String>,
748 todos: Vec<TodoComment>,
749 unsafe_count: u32,
750 unwrap_count: u32,
751 change_frequency: u32,
752 last_author: Option<String>,
753 is_hotspot: bool,
754 token_cost_estimate: u32,
755 last_modified_session: u64,
756 ) -> Self {
757 Self {
758 path: path.into(),
759 purpose: String::new(),
760 entry_points,
761 imports,
762 gotcha_keys: vec![],
763 decision_keys: vec![],
764 todos,
765 unsafe_count,
766 unwrap_count,
767 change_frequency,
768 last_author,
769 is_hotspot,
770 token_cost_estimate,
771 last_modified_session,
772 content_hash: None,
773 line_count: 0,
774 blast_radius: None,
775 propagated_staleness: None,
776 }
777 }
778}
779
780#[derive(Serialize, Deserialize, Debug, Clone)]
793pub struct GotchaRecord {
794 pub rule: String,
796 pub reason: String,
798 pub severity: Priority,
799 #[serde(default)]
800 pub affected_files: Vec<String>,
801 #[serde(default)]
802 pub ref_url: Option<String>,
803 #[serde(default)]
805 pub discovered_session: u64,
806 #[serde(default)]
809 pub confirmed: bool,
810}
811
812#[derive(Serialize, Deserialize, Debug, Clone)]
821pub struct StaleReviewEntry {
822 pub key: String,
823 pub staleness_value: f32,
824 pub tier: StalenessTier,
825 pub last_updated: u64,
826 pub signals: Vec<String>,
827}
828
829#[derive(Serialize, Deserialize, Debug, Clone)]
831pub struct StaleReviewPayload {
832 pub session_timestamp: u64,
833 pub entries: Vec<StaleReviewEntry>,
834}
835
836#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
845#[serde(rename_all = "snake_case")]
846pub enum GapType {
847 HotFileNoRecord,
849 HotFileNoPurpose,
851 HotFileNoGotchas,
853 FrequentlyReadNoEnrich,
855 OrphanedDecision,
857 DependencyUnknown,
859 CoChangePairUnmapped,
861 StaleHotspot,
863 HotFileNoTests,
865 HighFanInNoContract,
867}
868
869#[derive(Serialize, Deserialize, Debug, Clone)]
873pub struct KnowledgeGap {
874 pub key: String,
876 pub gap_type: GapType,
877 pub risk_score: f32,
879 pub description: String,
880 pub action_hint: String,
882}
883
884#[derive(Serialize, Deserialize, Debug, Clone)]
903pub struct ContextPacket {
904 pub stage: Option<Record>,
906 pub critical_gotchas: Vec<Record>,
911 pub file_records: Vec<FileRecord>,
913 pub related_decisions: Vec<Record>,
915 pub recent_session: Option<String>,
917 pub token_estimate: u32,
919 pub stale_warnings: Vec<String>,
921 pub unconfirmed_candidates: Vec<String>,
923 pub knowledge_gaps: Vec<KnowledgeGap>,
925 pub compliance_rate: Option<f32>,
927 pub injection_string: String,
929}
930
931#[derive(Serialize, Deserialize, Debug, Clone)]
953pub struct OnboardingScore {
954 pub estimated_minutes: f32,
955 pub critical_files_covered: f32,
957 pub gotcha_coverage: f32,
959 pub decision_coverage: f32,
961 pub avg_confidence: f32,
963 pub computed_at: u64,
964}
965
966#[cfg(test)]
971mod tests {
972 use super::*;
973
974 fn device_id() -> DeviceId {
977 Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap()
978 }
979
980 fn sample_record() -> Record {
981 Record {
982 key: "gotcha:inference-async".to_string(),
983 value: "Never call .await inside a rayon::spawn closure — it panics.".to_string(),
984 category: Category::Gotcha,
985 priority: Priority::Critical,
986 tags: vec!["async".to_string(), "rayon".to_string()],
987 created_at: 1_710_520_800,
988 updated_at: 1_710_520_800,
989 ref_url: Some("https://github.com/example/issue/42".to_string()),
990 staleness: StalenessScore::fresh(),
991 lifecycle: RecordLifecycle::Active,
992 version: RecordVersion {
993 device_id: device_id(),
994 logical_clock: 1,
995 wall_clock: 1_710_520_800,
996 },
997 quality: QualityScore {
998 value: 0.85,
999 tier: QualityTier::Good,
1000 signals: vec![
1001 QualitySignal::HasImperativeVerb,
1002 QualitySignal::HasCausality,
1003 ],
1004 computed_at: 1_710_520_800,
1005 },
1006 access_count: 0,
1007 last_accessed: 0,
1008 source: RecordSource::DeveloperManual,
1009 confidence: ConfidenceScore::for_new_record(&RecordSource::DeveloperManual),
1010 gap_analysis_score: 0.0,
1011 payload: None,
1012 }
1013 }
1014
1015 fn sample_file_record() -> FileRecord {
1016 FileRecord {
1017 path: "src/store/db.rs".to_string(),
1018 purpose: "Initialises SurrealKV trees and exposes the Store handle.".to_string(),
1019 entry_points: vec!["Store::open".to_string()],
1020 imports: vec!["surrealkv".to_string()],
1021 gotcha_keys: vec!["gotcha:inference-async".to_string()],
1022 decision_keys: vec![],
1023 todos: vec![TodoComment {
1024 text: "add fsync benchmark".to_string(),
1025 line: 42,
1026 kind: TodoKind::Todo,
1027 }],
1028 unsafe_count: 0,
1029 unwrap_count: 1,
1030 change_frequency: 12,
1031 last_author: Some("ioni".to_string()),
1032 is_hotspot: false,
1033 token_cost_estimate: 180,
1034 last_modified_session: 1_710_520_800,
1035 content_hash: None,
1036 line_count: 0,
1037 blast_radius: None,
1038 propagated_staleness: None,
1039 }
1040 }
1041
1042 fn sample_context_packet() -> ContextPacket {
1043 ContextPacket {
1044 stage: None,
1045 critical_gotchas: vec![sample_record()],
1046 file_records: vec![sample_file_record()],
1047 related_decisions: vec![],
1048 recent_session: Some(
1049 "Implemented storage layer. SurrealKV tree opened cleanly.".to_string(),
1050 ),
1051 token_estimate: 420,
1052 stale_warnings: vec![],
1053 unconfirmed_candidates: vec!["file:src/analysis/walker.rs".to_string()],
1054 knowledge_gaps: vec![KnowledgeGap {
1055 key: "file:src/analysis/parser.rs".to_string(),
1056 gap_type: GapType::HotFileNoGotchas,
1057 risk_score: 0.72,
1058 description: "Hot file with 23 commits in 60d and no gotchas".to_string(),
1059 action_hint: "mati gotcha add src/analysis/parser.rs".to_string(),
1060 }],
1061 compliance_rate: None,
1062 injection_string: String::new(),
1063 }
1064 }
1065
1066 fn assert_serde_roundtrip<T>(value: &T)
1070 where
1071 T: Serialize + for<'de> Deserialize<'de>,
1072 {
1073 let json1 = serde_json::to_string(value).expect("serialization failed");
1074 let restored: T = serde_json::from_str(&json1).expect("deserialization failed");
1075 let json2 = serde_json::to_string(&restored).expect("re-serialization failed");
1076 assert_eq!(json1, json2, "serde round-trip produced different JSON");
1077 }
1078
1079 #[test]
1082 fn record_serde_roundtrip() {
1083 assert_serde_roundtrip(&sample_record());
1084 }
1085
1086 #[test]
1087 fn file_record_serde_roundtrip() {
1088 assert_serde_roundtrip(&sample_file_record());
1089 }
1090
1091 #[test]
1095 fn file_record_backward_compat_no_blast_radius() {
1096 let json = r#"{
1097 "path": "src/main.rs",
1098 "purpose": "Entry point",
1099 "entry_points": ["main"],
1100 "imports": [],
1101 "gotcha_keys": [],
1102 "decision_keys": [],
1103 "todos": [],
1104 "unsafe_count": 0,
1105 "unwrap_count": 0,
1106 "change_frequency": 5,
1107 "last_author": "dev",
1108 "is_hotspot": false,
1109 "token_cost_estimate": 100,
1110 "last_modified_session": 1710520800
1111 }"#;
1112 let fr: FileRecord = serde_json::from_str(json).unwrap();
1113 assert!(fr.blast_radius.is_none());
1114 assert_eq!(fr.path, "src/main.rs");
1115 assert_eq!(fr.content_hash, None);
1116 assert_eq!(fr.line_count, 0);
1117 }
1118
1119 #[test]
1120 fn gotcha_record_serde_roundtrip() {
1121 let gotcha = GotchaRecord {
1122 rule: "Never hold a write transaction across an await point.".to_string(),
1123 reason: "SurrealKV write txns are not Send; the future will not compile.".to_string(),
1124 severity: Priority::Critical,
1125 affected_files: vec!["src/store/db.rs".to_string()],
1126 ref_url: Some("https://github.com/example/issue/99".to_string()),
1127 discovered_session: 1_710_520_800,
1128 confirmed: true,
1129 };
1130 assert_serde_roundtrip(&gotcha);
1131 }
1132
1133 #[test]
1134 fn context_packet_serde_roundtrip() {
1135 assert_serde_roundtrip(&sample_context_packet());
1136 }
1137
1138 #[test]
1141 fn record_lifecycle_tombstoned_serde() {
1142 let lifecycle = RecordLifecycle::Tombstoned {
1143 reason: TombstoneReason::FileDeleted,
1144 at: 1_710_520_800,
1145 };
1146 assert_serde_roundtrip(&lifecycle);
1147 }
1148
1149 #[test]
1150 fn record_lifecycle_superseded_serde() {
1151 let lifecycle = RecordLifecycle::Superseded {
1152 by_key: "gotcha:inference-async-v2".to_string(),
1153 };
1154 assert_serde_roundtrip(&lifecycle);
1155 }
1156
1157 #[test]
1158 fn tombstone_reason_file_renamed_serde() {
1159 let reason = TombstoneReason::FileRenamed {
1160 new_path: "src/store/backend.rs".to_string(),
1161 };
1162 assert_serde_roundtrip(&reason);
1163 }
1164
1165 #[test]
1168 fn staleness_signal_dependency_bumped_serde() {
1169 let signal = StalenessSignal::DependencyBumped {
1170 dep: "tokio".to_string(),
1171 old_ver: "1.40".to_string(),
1172 new_ver: "1.50".to_string(),
1173 };
1174 assert_serde_roundtrip(&signal);
1175 }
1176
1177 #[test]
1178 fn staleness_signal_file_renamed_serde() {
1179 let signal = StalenessSignal::FileRenamed {
1180 new_path: "src/store/backend.rs".to_string(),
1181 };
1182 assert_serde_roundtrip(&signal);
1183 }
1184
1185 #[test]
1186 fn staleness_signal_cascade_serde() {
1187 let signal = StalenessSignal::CascadeFromDecision("decision:storage-engine".to_string());
1188 assert_serde_roundtrip(&signal);
1189 }
1190
1191 #[test]
1192 fn staleness_score_fresh_default() {
1193 let s = StalenessScore::fresh();
1194 assert_eq!(s.tier, StalenessTier::Fresh);
1195 assert_eq!(s.value, 0.0);
1196 assert!(s.signals.is_empty());
1197 assert_eq!(s.computed_at, 0, "0 = not yet computed sentinel");
1198 assert!(s.last_record_sha.is_empty());
1199 }
1200
1201 #[test]
1204 fn quality_tier_ranges() {
1205 assert_eq!(QualityScore::tier_from_value(0.00), QualityTier::Suppressed);
1206 assert_eq!(QualityScore::tier_from_value(0.10), QualityTier::Suppressed);
1207 assert_eq!(QualityScore::tier_from_value(0.19), QualityTier::Suppressed);
1208 assert_eq!(QualityScore::tier_from_value(0.20), QualityTier::Poor);
1209 assert_eq!(QualityScore::tier_from_value(0.30), QualityTier::Poor);
1210 assert_eq!(QualityScore::tier_from_value(0.39), QualityTier::Poor);
1211 assert_eq!(QualityScore::tier_from_value(0.40), QualityTier::Acceptable);
1212 assert_eq!(QualityScore::tier_from_value(0.55), QualityTier::Acceptable);
1213 assert_eq!(QualityScore::tier_from_value(0.69), QualityTier::Acceptable);
1214 assert_eq!(QualityScore::tier_from_value(0.70), QualityTier::Good);
1215 assert_eq!(QualityScore::tier_from_value(0.80), QualityTier::Good);
1216 assert_eq!(QualityScore::tier_from_value(0.89), QualityTier::Good);
1217 assert_eq!(QualityScore::tier_from_value(0.90), QualityTier::Excellent);
1219 assert_eq!(QualityScore::tier_from_value(0.95), QualityTier::Excellent);
1220 assert_eq!(QualityScore::tier_from_value(1.00), QualityTier::Excellent);
1221 }
1222
1223 #[test]
1226 fn confidence_base_scores_by_source() {
1227 assert_eq!(
1228 ConfidenceScore::base_for_source(&RecordSource::DeveloperManual),
1229 0.80
1230 );
1231 assert_eq!(
1232 ConfidenceScore::base_for_source(&RecordSource::Import),
1233 0.70
1234 );
1235 assert_eq!(
1236 ConfidenceScore::base_for_source(&RecordSource::ClaudeEnrich),
1237 0.60
1238 );
1239 assert_eq!(
1240 ConfidenceScore::base_for_source(&RecordSource::SessionHook),
1241 0.50
1242 );
1243 assert_eq!(
1244 ConfidenceScore::base_for_source(&RecordSource::StaticAnalysis),
1245 0.10
1246 );
1247 }
1248
1249 #[test]
1250 fn confidence_for_new_record_value_matches_base() {
1251 let source = RecordSource::ClaudeEnrich;
1252 let score = ConfidenceScore::for_new_record(&source);
1253 assert_eq!(score.value, ConfidenceScore::base_for_source(&source));
1254 assert_eq!(score.confirmation_count, 0);
1255 assert_eq!(score.contributor_count, 1);
1256 assert!(score.last_challenged.is_none());
1257 assert_eq!(score.challenge_count, 0);
1258 }
1259
1260 #[test]
1263 fn priority_total_ordering() {
1264 assert!(Priority::Critical > Priority::High);
1265 assert!(Priority::High > Priority::Normal);
1266 assert!(Priority::Normal > Priority::Low);
1267 assert!(Priority::Critical > Priority::Low);
1268 assert_eq!(Priority::High, Priority::High);
1269 }
1270
1271 #[test]
1274 fn record_device_id_accessor_matches_version() {
1275 let rec = sample_record();
1276 assert_eq!(rec.device_id(), rec.version.device_id);
1277 }
1278
1279 #[test]
1282 fn quality_tier_non_finite_is_suppressed() {
1283 assert_eq!(
1286 QualityScore::tier_from_value(f32::NAN),
1287 QualityTier::Suppressed
1288 );
1289 assert_eq!(
1290 QualityScore::tier_from_value(f32::INFINITY),
1291 QualityTier::Suppressed
1292 );
1293 assert_eq!(
1294 QualityScore::tier_from_value(f32::NEG_INFINITY),
1295 QualityTier::Suppressed
1296 );
1297 }
1298
1299 #[test]
1300 fn quality_tier_out_of_range_finite_saturates() {
1301 assert_eq!(QualityScore::tier_from_value(-1.0), QualityTier::Suppressed);
1303 assert_eq!(
1304 QualityScore::tier_from_value(-0.001),
1305 QualityTier::Suppressed
1306 );
1307 assert_eq!(QualityScore::tier_from_value(1.001), QualityTier::Excellent);
1308 assert_eq!(QualityScore::tier_from_value(100.0), QualityTier::Excellent);
1309 }
1310
1311 #[test]
1312 fn layer0_default_quality_is_suppressed_tier() {
1313 let q = QualityScore::layer0_default();
1314 assert_eq!(q.tier, QualityTier::Suppressed);
1315 assert_eq!(q.value, 0.10);
1316 assert!(q.signals.is_empty());
1317 assert_eq!(q.computed_at, 0, "0 = not yet computed sentinel");
1318 }
1319
1320 #[test]
1323 fn confidence_for_new_record_all_sources_correct() {
1324 let cases: &[(RecordSource, f32)] = &[
1325 (RecordSource::DeveloperManual, 0.80),
1326 (RecordSource::Import, 0.70),
1327 (RecordSource::ClaudeEnrich, 0.60),
1328 (RecordSource::SessionHook, 0.50),
1329 (RecordSource::StaticAnalysis, 0.10),
1330 ];
1331 for (source, expected) in cases {
1332 let score = ConfidenceScore::for_new_record(source);
1333 assert!(
1334 (score.value - expected).abs() < f32::EPSILON,
1335 "{source:?}: expected {expected}, got {}",
1336 score.value
1337 );
1338 assert_eq!(score.confirmation_count, 0);
1339 assert_eq!(score.contributor_count, 1);
1340 assert!(score.last_challenged.is_none());
1341 assert_eq!(score.challenge_count, 0);
1342 }
1343 }
1344
1345 #[test]
1346 fn confidence_base_scores_are_all_distinct() {
1347 let scores: Vec<f32> = [
1348 RecordSource::DeveloperManual,
1349 RecordSource::Import,
1350 RecordSource::ClaudeEnrich,
1351 RecordSource::SessionHook,
1352 RecordSource::StaticAnalysis,
1353 ]
1354 .iter()
1355 .map(ConfidenceScore::base_for_source)
1356 .collect();
1357
1358 for i in 0..scores.len() {
1359 for j in (i + 1)..scores.len() {
1360 assert!(
1361 (scores[i] - scores[j]).abs() > f32::EPSILON,
1362 "sources {i} and {j} have identical base score {}",
1363 scores[i]
1364 );
1365 }
1366 }
1367 }
1368
1369 #[test]
1372 fn priority_exhaustive_pairwise_ordering() {
1373 use std::cmp::Ordering::*;
1374 let pairs = [
1375 (Priority::Low, Priority::Normal, Less),
1376 (Priority::Low, Priority::High, Less),
1377 (Priority::Low, Priority::Critical, Less),
1378 (Priority::Normal, Priority::High, Less),
1379 (Priority::Normal, Priority::Critical, Less),
1380 (Priority::High, Priority::Critical, Less),
1381 (Priority::Low, Priority::Low, Equal),
1382 (Priority::Normal, Priority::Normal, Equal),
1383 (Priority::High, Priority::High, Equal),
1384 (Priority::Critical, Priority::Critical, Equal),
1385 ];
1386 for (a, b, expected) in pairs {
1387 assert_eq!(
1388 a.cmp(&b),
1389 expected,
1390 "{a:?}.cmp({b:?}) should be {expected:?}"
1391 );
1392 if expected == Less {
1394 assert_eq!(b.cmp(&a), std::cmp::Ordering::Greater, "{b:?}.cmp({a:?})");
1395 }
1396 }
1397 }
1398
1399 #[test]
1402 fn staleness_all_signal_variants_serde() {
1403 let signals: Vec<StalenessSignal> = vec![
1404 StalenessSignal::NotAccessedDays(30),
1405 StalenessSignal::LinesChangedPct(0.75),
1406 StalenessSignal::EntryPointsChanged(2),
1407 StalenessSignal::ImportsChanged(5),
1408 StalenessSignal::FileDeleted,
1409 StalenessSignal::FileRenamed {
1410 new_path: "src/foo.rs".to_string(),
1411 },
1412 StalenessSignal::DependencyBumped {
1413 dep: "tokio".to_string(),
1414 old_ver: "1.40".to_string(),
1415 new_ver: "1.50".to_string(),
1416 },
1417 StalenessSignal::LinkedFileChanged {
1418 path: "src/bar.rs".to_string(),
1419 },
1420 StalenessSignal::CascadeFromDecision("decision:arch".to_string()),
1421 StalenessSignal::TodosChanged,
1422 StalenessSignal::UnsafeCountChanged(3),
1423 StalenessSignal::UnwrapCountChanged(-2),
1424 StalenessSignal::GitCommitsSince(7),
1425 ];
1426 for signal in &signals {
1427 let json = serde_json::to_string(signal).expect("serialize");
1428 let restored: StalenessSignal = serde_json::from_str(&json).expect("deserialize");
1429 let json2 = serde_json::to_string(&restored).expect("re-serialize");
1430 assert_eq!(json, json2, "roundtrip failed for: {json}");
1431 }
1432 }
1433
1434 #[test]
1437 fn tombstone_reason_all_variants_serde() {
1438 let reasons = vec![
1439 TombstoneReason::FileDeleted,
1440 TombstoneReason::FileRenamed {
1441 new_path: "src/new.rs".to_string(),
1442 },
1443 TombstoneReason::ManualDeletion,
1444 TombstoneReason::Superseded,
1445 ];
1446 for reason in &reasons {
1447 assert_serde_roundtrip(reason);
1448 }
1449 }
1450
1451 #[test]
1454 fn category_serializes_as_snake_case() {
1455 let cases = [
1456 (Category::Gotcha, "\"gotcha\""),
1457 (Category::File, "\"file\""),
1458 (Category::Decision, "\"decision\""),
1459 (Category::Stage, "\"stage\""),
1460 (Category::Dependency, "\"dependency\""),
1461 (Category::DevNote, "\"dev_note\""),
1462 (Category::Session, "\"session\""),
1463 (Category::Analytics, "\"analytics\""),
1464 ];
1465 for (cat, expected_json) in cases {
1466 let json = serde_json::to_string(&cat).unwrap();
1467 assert_eq!(json, expected_json, "Category::{cat:?}");
1468 }
1469 }
1470
1471 #[test]
1472 fn record_source_serializes_as_snake_case() {
1473 let cases = [
1474 (RecordSource::StaticAnalysis, "\"static_analysis\""),
1475 (RecordSource::ClaudeEnrich, "\"claude_enrich\""),
1476 (RecordSource::SessionHook, "\"session_hook\""),
1477 (RecordSource::DeveloperManual, "\"developer_manual\""),
1478 (RecordSource::Import, "\"import\""),
1479 ];
1480 for (src, expected_json) in cases {
1481 let json = serde_json::to_string(&src).unwrap();
1482 assert_eq!(json, expected_json, "RecordSource::{src:?}");
1483 }
1484 }
1485
1486 #[test]
1487 fn staleness_tier_serializes_as_snake_case() {
1488 let cases = [
1490 (StalenessTier::Fresh, "\"fresh\""),
1491 (StalenessTier::Aging, "\"aging\""),
1492 (StalenessTier::Stale, "\"stale\""),
1493 (StalenessTier::Liability, "\"liability\""),
1494 (StalenessTier::Tombstone, "\"tombstone\""),
1495 ];
1496 for (tier, expected_json) in cases {
1497 let json = serde_json::to_string(&tier).unwrap();
1498 assert_eq!(json, expected_json, "StalenessTier::{tier:?}");
1499 }
1500 }
1501
1502 #[test]
1505 fn gotcha_record_layer0_stub_is_unconfirmed() {
1506 let stub = GotchaRecord {
1509 rule: "Do not call .await inside rayon::spawn.".to_string(),
1510 reason: "rayon threads have no tokio runtime.".to_string(),
1511 severity: Priority::Critical,
1512 affected_files: vec!["src/analysis/walker.rs".to_string()],
1513 ref_url: None,
1514 discovered_session: 0,
1515 confirmed: false,
1516 };
1517 assert!(
1518 !stub.confirmed,
1519 "Layer 0 stubs must be unconfirmed on construction"
1520 );
1521
1522 let json = serde_json::to_string(&stub).unwrap();
1524 let restored: GotchaRecord = serde_json::from_str(&json).unwrap();
1525 assert!(
1526 !restored.confirmed,
1527 "confirmed flag must survive serde roundtrip"
1528 );
1529 assert!(json.contains("\"confirmed\":false"), "wire format: {json}");
1531 }
1532
1533 #[test]
1534 fn gotcha_record_confirmed_true_roundtrips() {
1535 let confirmed = GotchaRecord {
1536 rule: "Use SurrealKV::with_versioning(true, 0) for indefinite retention.".to_string(),
1537 reason: "0 means retain all versions forever, not disabled.".to_string(),
1538 severity: Priority::High,
1539 affected_files: vec!["src/store/db.rs".to_string()],
1540 ref_url: Some("https://github.com/example/issue/5".to_string()),
1541 discovered_session: 1_710_520_800,
1542 confirmed: true,
1543 };
1544 assert_serde_roundtrip(&confirmed);
1545 let json = serde_json::to_string(&confirmed).unwrap();
1546 assert!(json.contains("\"confirmed\":true"));
1547 }
1548
1549 #[test]
1552 fn staleness_score_fully_populated_serde() {
1553 let s = StalenessScore {
1554 value: 0.87,
1555 tier: StalenessTier::Liability,
1556 signals: vec![
1557 StalenessSignal::NotAccessedDays(90),
1558 StalenessSignal::LinesChangedPct(0.6),
1559 StalenessSignal::EntryPointsChanged(3),
1560 StalenessSignal::FileRenamed {
1561 new_path: "src/store/backend.rs".to_string(),
1562 },
1563 ],
1564 computed_at: 1_710_520_800,
1565 last_record_sha: "deadbeefcafe0123".to_string(),
1566 };
1567 assert_serde_roundtrip(&s);
1568 let json = serde_json::to_string(&s).unwrap();
1569 let restored: StalenessScore = serde_json::from_str(&json).unwrap();
1570 assert_eq!(restored.tier, StalenessTier::Liability);
1571 assert_eq!(restored.signals.len(), 4);
1572 assert_eq!(restored.last_record_sha, "deadbeefcafe0123");
1573 }
1574
1575 #[test]
1576 fn quality_score_with_all_positive_signals_serde() {
1577 let q = QualityScore {
1578 value: 0.92,
1579 tier: QualityTier::Excellent,
1580 signals: vec![
1581 QualitySignal::HasImperativeVerb,
1582 QualitySignal::HasCausality,
1583 QualitySignal::HasSeveritySet,
1584 QualitySignal::HasReference,
1585 QualitySignal::RuleLengthAdequate,
1586 QualitySignal::ReasonLengthAdequate,
1587 QualitySignal::AffectedFilesSpecified,
1588 QualitySignal::HasSpecificIdentifier,
1589 ],
1590 computed_at: 1_710_520_800,
1591 };
1592 assert_serde_roundtrip(&q);
1593 let json = serde_json::to_string(&q).unwrap();
1594 let restored: QualityScore = serde_json::from_str(&json).unwrap();
1595 assert_eq!(restored.tier, QualityTier::Excellent);
1596 assert_eq!(restored.signals.len(), 8);
1597 }
1598
1599 #[test]
1600 fn confidence_score_with_challenge_history_serde() {
1601 let c = ConfidenceScore {
1603 value: 0.45,
1604 confirmation_count: 1,
1605 contributor_count: 3,
1606 last_challenged: Some(1_710_500_000),
1607 challenge_count: 2,
1608 };
1609 let json = serde_json::to_string(&c).unwrap();
1610 let restored: ConfidenceScore = serde_json::from_str(&json).unwrap();
1611 assert_eq!(restored.last_challenged, Some(1_710_500_000));
1612 assert_eq!(restored.challenge_count, 2);
1613 assert_eq!(restored.contributor_count, 3);
1614 let json2 = serde_json::to_string(&restored).unwrap();
1615 assert_eq!(json, json2);
1616 }
1617
1618 #[test]
1619 fn record_ref_url_none_does_not_become_some() {
1620 let mut r = sample_record();
1622 r.ref_url = None;
1623 let json = serde_json::to_string(&r).unwrap();
1624 let restored: Record = serde_json::from_str(&json).unwrap();
1625 assert!(
1626 restored.ref_url.is_none(),
1627 "ref_url: None must not become Some after roundtrip"
1628 );
1629 assert!(
1630 json.contains("\"ref_url\":null"),
1631 "wire format must encode None as null"
1632 );
1633 }
1634
1635 #[test]
1636 fn context_packet_zero_knowledge_case_serde() {
1637 let empty = ContextPacket {
1639 stage: None,
1640 critical_gotchas: vec![],
1641 file_records: vec![],
1642 related_decisions: vec![],
1643 recent_session: None,
1644 token_estimate: 0,
1645 stale_warnings: vec![],
1646 unconfirmed_candidates: vec![],
1647 knowledge_gaps: vec![],
1648 compliance_rate: None,
1649 injection_string: String::new(),
1650 };
1651 assert_serde_roundtrip(&empty);
1652 let json = serde_json::to_string(&empty).unwrap();
1653 let restored: ContextPacket = serde_json::from_str(&json).unwrap();
1654 assert!(restored.critical_gotchas.is_empty());
1655 assert!(restored.file_records.is_empty());
1656 assert!(restored.stage.is_none());
1657 assert_eq!(restored.token_estimate, 0);
1658 }
1659
1660 #[test]
1661 fn record_tags_empty_and_many_both_survive_serde() {
1662 let mut r = sample_record();
1663
1664 r.tags = vec![];
1665 let json_empty = serde_json::to_string(&r).unwrap();
1666 let restored_empty: Record = serde_json::from_str(&json_empty).unwrap();
1667 assert!(
1668 restored_empty.tags.is_empty(),
1669 "empty tags must remain empty"
1670 );
1671
1672 r.tags = (0..50).map(|i| format!("tag-{i:03}")).collect();
1673 let json_many = serde_json::to_string(&r).unwrap();
1674 let restored_many: Record = serde_json::from_str(&json_many).unwrap();
1675 assert_eq!(restored_many.tags.len(), 50);
1676 assert_eq!(restored_many.tags[0], "tag-000");
1677 assert_eq!(restored_many.tags[49], "tag-049");
1678 }
1679
1680 #[test]
1681 fn file_record_layer0_stub_serde() {
1682 let stub = FileRecord::layer0_stub(
1684 "src/analysis/walker.rs",
1685 vec![],
1686 vec!["ignore".to_string(), "rayon".to_string()],
1687 vec![],
1688 0,
1689 3,
1690 17,
1691 None,
1692 true,
1693 0,
1694 0,
1695 );
1696 assert_serde_roundtrip(&stub);
1697 let json = serde_json::to_string(&stub).unwrap();
1698 let restored: FileRecord = serde_json::from_str(&json).unwrap();
1699 assert!(
1700 restored.purpose.is_empty(),
1701 "empty purpose must remain empty"
1702 );
1703 assert!(restored.entry_points.is_empty());
1704 assert!(restored.last_author.is_none());
1705 assert!(restored.is_hotspot);
1706 assert_eq!(restored.unwrap_count, 3);
1707 }
1708
1709 #[test]
1710 fn layer0_file_record_builder_sets_suppressed_quality() {
1711 let record =
1712 Record::layer0_file_stub("file:src/analysis/walker.rs", device_id(), 7, 1_710_520_800);
1713
1714 assert_eq!(record.key, "file:src/analysis/walker.rs");
1715 assert_eq!(record.category, Category::File);
1716 assert!(record.value.is_empty());
1717 assert_eq!(record.quality.value, 0.10);
1718 assert_eq!(record.quality.tier, QualityTier::Suppressed);
1719 assert_eq!(record.source, RecordSource::StaticAnalysis);
1720 assert_eq!(record.confidence.value, 0.10);
1721 assert_eq!(record.confidence.contributor_count, 1);
1722 }
1723
1724 #[test]
1727 fn stale_review_entry_serde_roundtrip() {
1728 let entry = StaleReviewEntry {
1729 key: "file:src/store/db.rs".to_string(),
1730 staleness_value: 0.72,
1731 tier: StalenessTier::Stale,
1732 last_updated: 1_710_520_800,
1733 signals: vec![
1734 "not accessed for 45 days".to_string(),
1735 "3 entry points changed".to_string(),
1736 ],
1737 };
1738 assert_serde_roundtrip(&entry);
1739 }
1740
1741 #[test]
1742 fn stale_review_payload_serde_roundtrip() {
1743 let payload = StaleReviewPayload {
1744 session_timestamp: 1_710_520_800,
1745 entries: vec![
1746 StaleReviewEntry {
1747 key: "file:src/store/db.rs".to_string(),
1748 staleness_value: 0.72,
1749 tier: StalenessTier::Stale,
1750 last_updated: 1_710_500_000,
1751 signals: vec!["not accessed for 45 days".to_string()],
1752 },
1753 StaleReviewEntry {
1754 key: "gotcha:inference-async".to_string(),
1755 staleness_value: 0.85,
1756 tier: StalenessTier::Liability,
1757 last_updated: 1_710_400_000,
1758 signals: vec![
1759 "90 commits since last confirmation".to_string(),
1760 "75% of lines changed".to_string(),
1761 ],
1762 },
1763 ],
1764 };
1765 assert_serde_roundtrip(&payload);
1766 let json = serde_json::to_string(&payload).unwrap();
1767 let restored: StaleReviewPayload = serde_json::from_str(&json).unwrap();
1768 assert_eq!(restored.entries.len(), 2);
1769 assert_eq!(restored.session_timestamp, 1_710_520_800);
1770 }
1771
1772 #[test]
1773 fn stale_review_payload_empty_entries_serde() {
1774 let payload = StaleReviewPayload {
1775 session_timestamp: 1_710_520_800,
1776 entries: vec![],
1777 };
1778 assert_serde_roundtrip(&payload);
1779 let json = serde_json::to_string(&payload).unwrap();
1780 let restored: StaleReviewPayload = serde_json::from_str(&json).unwrap();
1781 assert!(restored.entries.is_empty());
1782 }
1783
1784 #[test]
1787 fn staleness_signal_git_commits_since_serde() {
1788 let signal = StalenessSignal::GitCommitsSince(42);
1789 assert_serde_roundtrip(&signal);
1790 let json = serde_json::to_string(&signal).unwrap();
1791 assert!(json.contains("git_commits_since"), "wire format: {json}");
1792 }
1793
1794 #[test]
1795 fn staleness_signal_git_commits_since_display() {
1796 let signal = StalenessSignal::GitCommitsSince(7);
1797 assert_eq!(signal.to_string(), "7 commits since last confirmation");
1798 }
1799
1800 #[test]
1801 fn staleness_signal_display_all_variants() {
1802 let signals: Vec<StalenessSignal> = vec![
1804 StalenessSignal::NotAccessedDays(30),
1805 StalenessSignal::LinesChangedPct(0.75),
1806 StalenessSignal::EntryPointsChanged(2),
1807 StalenessSignal::ImportsChanged(5),
1808 StalenessSignal::FileDeleted,
1809 StalenessSignal::FileRenamed {
1810 new_path: "src/foo.rs".to_string(),
1811 },
1812 StalenessSignal::DependencyBumped {
1813 dep: "tokio".to_string(),
1814 old_ver: "1.40".to_string(),
1815 new_ver: "1.50".to_string(),
1816 },
1817 StalenessSignal::LinkedFileChanged {
1818 path: "src/bar.rs".to_string(),
1819 },
1820 StalenessSignal::CascadeFromDecision("decision:arch".to_string()),
1821 StalenessSignal::TodosChanged,
1822 StalenessSignal::UnsafeCountChanged(3),
1823 StalenessSignal::UnwrapCountChanged(-2),
1824 StalenessSignal::GitCommitsSince(7),
1825 ];
1826 for signal in &signals {
1827 let display = signal.to_string();
1828 assert!(
1829 !display.is_empty(),
1830 "Display for {signal:?} should not be empty"
1831 );
1832 }
1833 }
1834}