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