1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5pub const ASD_SCHEMA_VERSION: &str = "0.1.0";
6pub const ASD_PATH_PREFIX: &str = "/asd/v1";
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct Symbol {
10 pub symbol_id: String,
11 pub symbol_fp: String,
12 pub qname: String,
13 pub language: String,
14 pub kind: SymbolKind,
15 pub file: String,
16 pub start: Position,
17 pub end: Position,
18 pub signature: Option<String>,
19 #[serde(default, skip_serializing_if = "Option::is_none")]
20 pub doc: Option<String>,
21}
22
23#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
24#[serde(rename_all = "snake_case")]
25pub enum SymbolKind {
26 Module,
27 Function,
28 Method,
29 Class,
30 Variable,
31}
32
33#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
34pub struct Position {
35 pub line: u32,
36 pub col: u32,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct LedgerEntry {
41 pub entry_id: String,
42 pub symbol_id: String,
43 pub kind: LedgerKind,
44 pub summary: String,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub body: Option<String>,
47 pub author: Author,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub confidence: Option<f64>,
50 #[serde(default, skip_serializing_if = "Vec::is_empty")]
51 pub evidence: Vec<Evidence>,
52 #[serde(default, skip_serializing_if = "Vec::is_empty")]
53 pub supersedes: Vec<String>,
54 pub created_at: DateTime<Utc>,
55 #[serde(default, skip_serializing_if = "Vec::is_empty")]
56 pub tags: Vec<String>,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub matched_policy: Option<String>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub role: Option<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub command: Option<String>,
71}
72
73#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
74#[serde(rename_all = "snake_case")]
75pub enum LedgerKind {
76 Decision,
78 Assumption,
80 Constraint,
82 Rationale,
84 Hazard,
86 Tradeoff,
88 Invariant,
90 Ownership,
92 Proof,
94 ValidationScenario,
96 KnownBug,
98 Concept,
100 Mapping,
104 FollowUp,
109 Hypothesis,
115 MentalModel,
120 FailedAttempt,
124 OpenQuestion,
130}
131
132impl LedgerKind {
133 pub fn conclusion_class(self) -> ConclusionClass {
136 use LedgerKind::*;
137 match self {
138 Decision | Assumption | Constraint | Rationale | Tradeoff | Invariant => {
139 ConclusionClass::Decisions
140 }
141 Ownership | Concept => ConclusionClass::Classifications,
142 Mapping => ConclusionClass::Mappings,
143 Hazard | KnownBug => ConclusionClass::Hazards,
144 ValidationScenario | Proof => ConclusionClass::Recipes,
145 FollowUp => ConclusionClass::FollowUps,
146 Hypothesis | MentalModel | FailedAttempt | OpenQuestion => ConclusionClass::Thinking,
149 }
150 }
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
160pub enum ConclusionClass {
161 Decisions,
162 Classifications,
163 Mappings,
164 Hazards,
165 Recipes,
166 FollowUps,
167 Thinking,
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
175pub enum RoleTag {
176 FastTest,
178 DiagnosticTest,
180 FixturePath,
182 StaleApi,
184 PackageBoundary,
186 ReplacementCoverage,
188 PerformanceCritical,
190 AuditPending,
192}
193
194impl RoleTag {
195 pub fn as_str(self) -> &'static str {
197 match self {
198 Self::FastTest => "fast-test",
199 Self::DiagnosticTest => "diagnostic-test",
200 Self::FixturePath => "fixture-path",
201 Self::StaleApi => "stale-api",
202 Self::PackageBoundary => "package-boundary",
203 Self::ReplacementCoverage => "replacement-coverage",
204 Self::PerformanceCritical => "performance-critical",
205 Self::AuditPending => "audit-pending",
206 }
207 }
208
209 pub fn from_str(s: &str) -> Option<Self> {
213 let norm = s.trim().to_ascii_lowercase().replace('_', "-");
214 match norm.as_str() {
215 "fast-test" => Some(Self::FastTest),
216 "diagnostic-test" => Some(Self::DiagnosticTest),
217 "fixture-path" => Some(Self::FixturePath),
218 "stale-api" => Some(Self::StaleApi),
219 "package-boundary" => Some(Self::PackageBoundary),
220 "replacement-coverage" => Some(Self::ReplacementCoverage),
221 "performance-critical" => Some(Self::PerformanceCritical),
222 "audit-pending" => Some(Self::AuditPending),
223 _ => None,
224 }
225 }
226
227 pub fn all() -> &'static [RoleTag] {
230 &[
231 Self::FastTest,
232 Self::DiagnosticTest,
233 Self::FixturePath,
234 Self::StaleApi,
235 Self::PackageBoundary,
236 Self::ReplacementCoverage,
237 Self::PerformanceCritical,
238 Self::AuditPending,
239 ]
240 }
241
242 pub fn is_penalty_role(self) -> bool {
246 matches!(self, Self::StaleApi | Self::AuditPending)
247 }
248
249 pub fn is_boost_role(self) -> bool {
253 matches!(self, Self::PackageBoundary | Self::PerformanceCritical)
254 }
255}
256
257impl ConclusionClass {
258 pub fn filename_stem(self) -> &'static str {
260 match self {
261 Self::Decisions => "decisions",
262 Self::Classifications => "classifications",
263 Self::Mappings => "mappings",
264 Self::Hazards => "hazards",
265 Self::Recipes => "recipes",
266 Self::FollowUps => "followups",
267 Self::Thinking => "thinking",
268 }
269 }
270
271 pub fn all() -> &'static [ConclusionClass] {
273 &[
274 Self::Decisions,
275 Self::Classifications,
276 Self::Mappings,
277 Self::Hazards,
278 Self::Recipes,
279 Self::FollowUps,
280 Self::Thinking,
281 ]
282 }
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct Author {
287 pub kind: AuthorKind,
288 pub id: String,
289}
290
291#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
292#[serde(rename_all = "snake_case")]
293pub enum AuthorKind {
294 Agent,
295 Human,
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize)]
299#[serde(tag = "type", rename_all = "snake_case")]
300pub enum Evidence {
301 Trace { id: String },
302 Ledger { id: String },
303 Test { qname: String },
304 Ctxone { id: String },
305 External { url: String },
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize)]
309pub struct EffectDecl {
310 pub symbol_id: String,
311 #[serde(default)]
312 pub declared: Vec<Effect>,
313 #[serde(default)]
314 pub transitive: Vec<TransitiveEffect>,
315 #[serde(skip_serializing_if = "Option::is_none")]
316 pub verification: Option<Verification>,
317 #[serde(skip_serializing_if = "Option::is_none")]
318 pub confidence: Option<f64>,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub runtime: Option<RuntimeEvidence>,
323 #[serde(skip_serializing_if = "Option::is_none")]
324 pub matched_policy: Option<String>,
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct RuntimeEvidence {
341 pub confirmations: u64,
343 pub contradictions: u64,
345 pub prior: f64,
349 #[serde(skip_serializing_if = "Option::is_none")]
350 pub last_trace_id: Option<String>,
351 pub last_observed_at: DateTime<Utc>,
352}
353
354impl RuntimeEvidence {
355 pub const PRIOR_STRENGTH: f64 = 2.0;
360
361 pub const NEUTRAL_PRIOR: f64 = 0.5;
363
364 pub fn derive_confidence(prior: f64, confirmations: u64, contradictions: u64) -> f64 {
375 let p0 = prior.clamp(0.0, 1.0);
376 let s = Self::PRIOR_STRENGTH;
377 let alpha = p0 * s + confirmations as f64;
378 let total = s + confirmations as f64 + contradictions as f64;
379 alpha / total
380 }
381
382 pub fn confidence(&self) -> f64 {
384 Self::derive_confidence(self.prior, self.confirmations, self.contradictions)
385 }
386}
387
388#[derive(Debug, Clone, Serialize, Deserialize)]
389pub struct Effect {
390 pub effect: EffectCategory,
391 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
392 pub qualifiers: serde_json::Value,
393 #[serde(skip_serializing_if = "Option::is_none")]
394 pub note: Option<String>,
395 #[serde(skip_serializing_if = "Option::is_none")]
397 pub adapter: Option<String>,
398 #[serde(skip_serializing_if = "Option::is_none")]
400 pub source_pattern: Option<String>,
401 #[serde(skip_serializing_if = "Option::is_none")]
403 pub verified: Option<bool>,
404}
405
406impl Effect {
407 pub fn new(effect: EffectCategory) -> Self {
408 Self {
409 effect,
410 qualifiers: serde_json::Value::Null,
411 note: None,
412 adapter: None,
413 source_pattern: None,
414 verified: None,
415 }
416 }
417}
418
419impl Default for Effect {
420 fn default() -> Self {
421 Self::new(EffectCategory::Pure)
422 }
423}
424
425#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
441pub enum EffectCategory {
442 IoFsRead,
444 IoFsWrite,
445 IoNetIn,
446 IoNetOut,
447 IoDbRead,
448 IoDbWrite,
449 StateGlobalRead,
450 StateGlobalWrite,
451 StateProcess,
452 EnvRead,
453 TimeRead,
454 TimeSleep,
455 Random,
456 ProcSpawn,
457 Throw,
458 Log,
459 Pure,
460 Other(String),
462}
463
464impl EffectCategory {
465 pub fn as_str(&self) -> &str {
466 match self {
467 EffectCategory::IoFsRead => "io.fs.read",
468 EffectCategory::IoFsWrite => "io.fs.write",
469 EffectCategory::IoNetIn => "io.net.in",
470 EffectCategory::IoNetOut => "io.net.out",
471 EffectCategory::IoDbRead => "io.db.read",
472 EffectCategory::IoDbWrite => "io.db.write",
473 EffectCategory::StateGlobalRead => "state.global.read",
474 EffectCategory::StateGlobalWrite => "state.global.write",
475 EffectCategory::StateProcess => "state.process",
476 EffectCategory::EnvRead => "env.read",
477 EffectCategory::TimeRead => "time.read",
478 EffectCategory::TimeSleep => "time.sleep",
479 EffectCategory::Random => "random",
480 EffectCategory::ProcSpawn => "proc.spawn",
481 EffectCategory::Throw => "throw",
482 EffectCategory::Log => "log",
483 EffectCategory::Pure => "pure",
484 EffectCategory::Other(s) => s.as_str(),
485 }
486 }
487
488 pub fn is_low_signal(&self) -> bool {
493 matches!(
494 self,
495 Self::Throw | Self::Random | Self::Log | Self::Pure | Self::TimeRead | Self::TimeSleep
496 )
497 }
498
499 pub fn from_str(s: &str) -> Self {
500 match s {
501 "io.fs.read" => EffectCategory::IoFsRead,
502 "io.fs.write" => EffectCategory::IoFsWrite,
503 "io.net.in" => EffectCategory::IoNetIn,
504 "io.net.out" => EffectCategory::IoNetOut,
505 "io.db.read" => EffectCategory::IoDbRead,
506 "io.db.write" => EffectCategory::IoDbWrite,
507 "state.global.read" => EffectCategory::StateGlobalRead,
508 "state.global.write" => EffectCategory::StateGlobalWrite,
509 "state.process" => EffectCategory::StateProcess,
510 "env.read" => EffectCategory::EnvRead,
511 "time.read" => EffectCategory::TimeRead,
512 "time.sleep" => EffectCategory::TimeSleep,
513 "random" => EffectCategory::Random,
514 "proc.spawn" => EffectCategory::ProcSpawn,
515 "throw" => EffectCategory::Throw,
516 "log" => EffectCategory::Log,
517 "pure" => EffectCategory::Pure,
518 other => EffectCategory::Other(other.to_string()),
519 }
520 }
521}
522
523impl serde::Serialize for EffectCategory {
524 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
525 s.serialize_str(self.as_str())
526 }
527}
528
529impl<'de> serde::Deserialize<'de> for EffectCategory {
530 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
531 let s = String::deserialize(d)?;
532 Ok(EffectCategory::from_str(&s))
533 }
534}
535
536#[derive(Debug, Clone, Serialize, Deserialize)]
537pub struct TransitiveEffect {
538 pub effect: EffectCategory,
539 pub via: Vec<String>,
540 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
541 pub qualifiers: serde_json::Value,
542}
543
544#[derive(Debug, Clone, Serialize, Deserialize)]
545pub struct Verification {
546 pub by: VerificationSource,
547 pub at: DateTime<Utc>,
548 pub status: VerificationStatus,
549 #[serde(default)]
550 pub mismatches: Vec<Mismatch>,
551}
552
553#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
554#[serde(rename_all = "kebab-case")]
555pub enum VerificationSource {
556 StaticChecker,
557 RuntimeTracer,
558 TestObserved,
559}
560
561#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
562#[serde(rename_all = "snake_case")]
563pub enum VerificationStatus {
564 Ok,
565 Mismatch,
566 Unverified,
567}
568
569#[derive(Debug, Clone, Serialize, Deserialize)]
570pub struct Mismatch {
571 pub kind: String,
572 pub effect: EffectCategory,
573 #[serde(skip_serializing_if = "Option::is_none")]
574 pub detected_in: Option<String>,
575 #[serde(skip_serializing_if = "Option::is_none")]
576 pub note: Option<String>,
577}
578
579impl LedgerEntry {
580 pub fn new(
581 symbol_id: impl Into<String>,
582 kind: LedgerKind,
583 summary: impl Into<String>,
584 author: Author,
585 ) -> Self {
586 Self {
587 entry_id: format!("led_{}", Uuid::new_v4().simple()),
588 symbol_id: symbol_id.into(),
589 kind,
590 summary: summary.into(),
591 body: None,
592 author,
593 confidence: None,
594 evidence: Vec::new(),
595 supersedes: Vec::new(),
596 created_at: Utc::now(),
597 tags: Vec::new(),
598 matched_policy: None,
599 role: None,
600 command: None,
601 }
602 }
603}
604
605impl LedgerKind {
606 pub fn as_str(self) -> &'static str {
607 match self {
608 LedgerKind::Decision => "decision",
609 LedgerKind::Assumption => "assumption",
610 LedgerKind::Constraint => "constraint",
611 LedgerKind::Rationale => "rationale",
612 LedgerKind::Hazard => "hazard",
613 LedgerKind::Tradeoff => "tradeoff",
614 LedgerKind::Invariant => "invariant",
615 LedgerKind::Ownership => "ownership",
616 LedgerKind::Proof => "proof",
617 LedgerKind::ValidationScenario => "validation_scenario",
618 LedgerKind::KnownBug => "known_bug",
619 LedgerKind::Concept => "concept",
620 LedgerKind::Mapping => "mapping",
621 LedgerKind::FollowUp => "follow_up",
622 LedgerKind::Hypothesis => "hypothesis",
623 LedgerKind::MentalModel => "mental_model",
624 LedgerKind::FailedAttempt => "failed_attempt",
625 LedgerKind::OpenQuestion => "open_question",
626 }
627 }
628}
629
630#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
636#[serde(rename_all = "lowercase")]
637pub enum ScratchStatus {
638 Draft,
640 Promoted,
642 Discarded,
644}
645
646#[derive(Debug, Clone, Serialize, Deserialize)]
653pub struct ScratchEntry {
654 pub scratch_id: String,
656 pub symbol_id: Option<String>,
658 pub workflow: Option<String>,
660 pub session: String,
662 pub content: String,
664 pub status: ScratchStatus,
666 pub promoted_to: Option<String>,
668 pub created_at: DateTime<Utc>,
669 pub updated_at: DateTime<Utc>,
670 pub expires_at: Option<DateTime<Utc>>,
672 #[serde(default, skip_serializing_if = "Vec::is_empty")]
674 pub tags: Vec<String>,
675}
676
677impl ScratchEntry {
678 pub fn new(content: impl Into<String>, session: impl Into<String>) -> Self {
681 let now = Utc::now();
682 Self {
683 scratch_id: format!("scr_{}", Uuid::new_v4().simple()),
684 symbol_id: None,
685 workflow: None,
686 session: session.into(),
687 content: content.into(),
688 status: ScratchStatus::Draft,
689 promoted_to: None,
690 created_at: now,
691 updated_at: now,
692 expires_at: None,
693 tags: Vec::new(),
694 }
695 }
696
697 pub fn is_expired(&self) -> bool {
699 self.expires_at.map_or(false, |t| Utc::now() > t)
700 }
701}
702
703#[derive(Debug, Clone, Serialize, Deserialize)]
708pub struct Rebind {
709 pub from_symbol_id: String,
710 pub to_symbol_id: String,
711 pub to_qname: String,
712 pub at: DateTime<Utc>,
713 pub by: String,
714}
715
716#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
722#[serde(rename_all = "snake_case")]
723pub enum FeedbackVerdict {
724 Useful,
726 Noisy,
728 Missing,
730 WrongLayer,
732 AlreadyCovered,
737 DiagnosticOnly,
743}
744
745impl FeedbackVerdict {
746 pub fn as_str(self) -> &'static str {
747 match self {
748 Self::Useful => "useful",
749 Self::Noisy => "noisy",
750 Self::Missing => "missing",
751 Self::WrongLayer => "wrong-layer",
752 Self::AlreadyCovered => "already-covered",
753 Self::DiagnosticOnly => "diagnostic-only",
754 }
755 }
756
757 pub fn from_str(s: &str) -> Option<Self> {
762 let norm = s.trim().to_ascii_lowercase().replace('_', "-");
763 match norm.as_str() {
764 "useful" => Some(Self::Useful),
765 "noisy" => Some(Self::Noisy),
766 "missing" => Some(Self::Missing),
767 "wrong-layer" => Some(Self::WrongLayer),
768 "already-covered" => Some(Self::AlreadyCovered),
769 "diagnostic-only" => Some(Self::DiagnosticOnly),
770 _ => None,
771 }
772 }
773
774 pub fn is_suppression(self) -> bool {
779 matches!(
780 self,
781 Self::Noisy | Self::WrongLayer | Self::AlreadyCovered | Self::DiagnosticOnly
782 )
783 }
784}
785
786#[derive(Debug, Clone, Serialize, Deserialize)]
793pub struct FeedbackEntry {
794 pub entry_id: String,
795 pub symbol_id: String,
796 pub symbol_qname: String,
797 pub query: String,
799 pub verdict: FeedbackVerdict,
800 pub author: String,
801 pub created_at: DateTime<Utc>,
802 #[serde(skip_serializing_if = "Option::is_none")]
803 pub note: Option<String>,
804 #[serde(skip_serializing_if = "Option::is_none")]
808 pub file_scope: Option<String>,
809 #[serde(skip_serializing_if = "Option::is_none")]
816 pub expires_at: Option<DateTime<Utc>>,
817 #[serde(skip_serializing_if = "Option::is_none")]
826 pub withdrawn_at: Option<DateTime<Utc>>,
827 #[serde(skip_serializing_if = "Option::is_none")]
828 pub withdrawn_by: Option<String>,
829 #[serde(skip_serializing_if = "Option::is_none")]
831 pub withdrawn_reason: Option<String>,
832}
833
834impl FeedbackEntry {
835 pub fn is_withdrawn(&self) -> bool {
841 self.withdrawn_at.is_some()
842 }
843
844 pub fn is_inert(&self) -> bool {
849 self.is_expired() || self.is_withdrawn()
850 }
851
852 pub fn is_expired(&self) -> bool {
860 self.expires_at.map_or(false, |t| Utc::now() > t)
861 }
862}
863
864#[cfg(test)]
865mod plan_b_schema_tests {
866 use super::*;
871
872 #[test]
873 fn feedback_is_expired_returns_false_when_unset() {
874 let e = FeedbackEntry {
878 entry_id: "fb_x".into(),
879 symbol_id: "sym_x".into(),
880 symbol_qname: "pkg.x".into(),
881 query: "q".into(),
882 verdict: FeedbackVerdict::Useful,
883 author: "a".into(),
884 created_at: Utc::now(),
885 note: None,
886 file_scope: None,
887 expires_at: None,
888 withdrawn_at: None,
889 withdrawn_by: None,
890 withdrawn_reason: None,
891 };
892 assert!(!e.is_expired());
893 }
894
895 #[test]
896 fn feedback_is_expired_returns_true_when_past() {
897 let mut e = FeedbackEntry {
898 entry_id: "fb_x".into(),
899 symbol_id: "sym_x".into(),
900 symbol_qname: "pkg.x".into(),
901 query: "q".into(),
902 verdict: FeedbackVerdict::Useful,
903 author: "a".into(),
904 created_at: Utc::now(),
905 note: None,
906 file_scope: None,
907 expires_at: None,
908 withdrawn_at: None,
909 withdrawn_by: None,
910 withdrawn_reason: None,
911 };
912 e.expires_at = Some(Utc::now() - chrono::Duration::days(1));
913 assert!(e.is_expired(), "expired 1 day ago must report expired");
914 }
915
916 #[test]
917 fn feedback_is_expired_returns_false_when_future() {
918 let mut e = FeedbackEntry {
919 entry_id: "fb_x".into(),
920 symbol_id: "sym_x".into(),
921 symbol_qname: "pkg.x".into(),
922 query: "q".into(),
923 verdict: FeedbackVerdict::Useful,
924 author: "a".into(),
925 created_at: Utc::now(),
926 note: None,
927 file_scope: None,
928 expires_at: None,
929 withdrawn_at: None,
930 withdrawn_by: None,
931 withdrawn_reason: None,
932 };
933 e.expires_at = Some(Utc::now() + chrono::Duration::days(30));
934 assert!(!e.is_expired(), "future expiry must NOT report expired");
935 }
936
937 #[test]
938 fn new_kinds_serialize_to_snake_case() {
939 let json = serde_json::to_string(&LedgerKind::Mapping).unwrap();
940 assert_eq!(json, "\"mapping\"");
941 let json = serde_json::to_string(&LedgerKind::FollowUp).unwrap();
942 assert_eq!(json, "\"follow_up\"");
943 }
944
945 #[test]
946 fn new_kinds_round_trip_through_serde() {
947 for kind in [LedgerKind::Mapping, LedgerKind::FollowUp] {
948 let json = serde_json::to_string(&kind).unwrap();
949 let back: LedgerKind = serde_json::from_str(&json).unwrap();
950 assert_eq!(back, kind);
951 }
952 }
953
954 #[test]
955 fn optional_fields_are_omitted_when_none() {
956 let entry = LedgerEntry::new(
957 "sym_test",
958 LedgerKind::Decision,
959 "some decision",
960 Author {
961 kind: AuthorKind::Agent,
962 id: "agent".into(),
963 },
964 );
965 let json = serde_json::to_string(&entry).unwrap();
966 assert!(!json.contains("\"role\""));
967 assert!(!json.contains("\"command\""));
968 }
969
970 #[test]
971 fn optional_fields_round_trip_when_set() {
972 let mut entry = LedgerEntry::new(
973 "sym_test",
974 LedgerKind::FollowUp,
975 "SID real-file diagnostics still need migration",
976 Author {
977 kind: AuthorKind::Human,
978 id: "craig".into(),
979 },
980 );
981 entry.role = Some("diagnostic-test".into());
982 entry.command = Some("swift test --filter SongPlayersTests".into());
983 let json = serde_json::to_string(&entry).unwrap();
984 let back: LedgerEntry = serde_json::from_str(&json).unwrap();
985 assert_eq!(back.role.as_deref(), Some("diagnostic-test"));
986 assert_eq!(
987 back.command.as_deref(),
988 Some("swift test --filter SongPlayersTests")
989 );
990 }
991
992 #[test]
993 fn every_ledger_kind_buckets_to_a_conclusion_class() {
994 use ConclusionClass::*;
999 let pairs = [
1000 (LedgerKind::Decision, Decisions),
1001 (LedgerKind::Assumption, Decisions),
1002 (LedgerKind::Constraint, Decisions),
1003 (LedgerKind::Rationale, Decisions),
1004 (LedgerKind::Tradeoff, Decisions),
1005 (LedgerKind::Invariant, Decisions),
1006 (LedgerKind::Ownership, Classifications),
1007 (LedgerKind::Concept, Classifications),
1008 (LedgerKind::Mapping, Mappings),
1009 (LedgerKind::Hazard, Hazards),
1010 (LedgerKind::KnownBug, Hazards),
1011 (LedgerKind::ValidationScenario, Recipes),
1012 (LedgerKind::Proof, Recipes),
1013 (LedgerKind::FollowUp, FollowUps),
1014 ];
1015 for (kind, expected) in pairs {
1016 assert_eq!(
1017 kind.conclusion_class(),
1018 expected,
1019 "kind {:?} should bucket to {:?}",
1020 kind,
1021 expected
1022 );
1023 }
1024 }
1025
1026 #[test]
1027 fn conclusion_class_filename_stems_match_design() {
1028 assert_eq!(ConclusionClass::Decisions.filename_stem(), "decisions");
1031 assert_eq!(
1032 ConclusionClass::Classifications.filename_stem(),
1033 "classifications"
1034 );
1035 assert_eq!(ConclusionClass::Mappings.filename_stem(), "mappings");
1036 assert_eq!(ConclusionClass::Hazards.filename_stem(), "hazards");
1037 assert_eq!(ConclusionClass::Recipes.filename_stem(), "recipes");
1038 assert_eq!(ConclusionClass::FollowUps.filename_stem(), "followups");
1039 assert_eq!(ConclusionClass::Thinking.filename_stem(), "thinking");
1041 assert_eq!(ConclusionClass::all().len(), 7);
1042 }
1043
1044 #[test]
1045 fn plan_g_thinking_kinds_bucket_to_thinking_class() {
1046 for kind in [
1051 LedgerKind::Hypothesis,
1052 LedgerKind::MentalModel,
1053 LedgerKind::FailedAttempt,
1054 LedgerKind::OpenQuestion,
1055 ] {
1056 assert_eq!(
1057 kind.conclusion_class(),
1058 ConclusionClass::Thinking,
1059 "kind {:?} must bucket to Thinking",
1060 kind
1061 );
1062 }
1063 }
1064
1065 #[test]
1066 fn plan_g_thinking_kinds_wire_strings_match_design() {
1067 assert_eq!(LedgerKind::Hypothesis.as_str(), "hypothesis");
1068 assert_eq!(LedgerKind::MentalModel.as_str(), "mental_model");
1069 assert_eq!(LedgerKind::FailedAttempt.as_str(), "failed_attempt");
1070 assert_eq!(LedgerKind::OpenQuestion.as_str(), "open_question");
1071 }
1072
1073 #[test]
1074 fn plan_g_thinking_kinds_serde_round_trip() {
1075 use serde_json::json;
1076 for kind in [
1077 LedgerKind::Hypothesis,
1078 LedgerKind::MentalModel,
1079 LedgerKind::FailedAttempt,
1080 LedgerKind::OpenQuestion,
1081 ] {
1082 let v = serde_json::to_value(kind).unwrap();
1083 assert_eq!(v, json!(kind.as_str()));
1084 let back: LedgerKind = serde_json::from_value(v).unwrap();
1085 assert_eq!(back, kind);
1086 }
1087 }
1088
1089 #[test]
1092 fn role_tag_wire_strings_match_design() {
1093 assert_eq!(RoleTag::FastTest.as_str(), "fast-test");
1096 assert_eq!(RoleTag::DiagnosticTest.as_str(), "diagnostic-test");
1097 assert_eq!(RoleTag::FixturePath.as_str(), "fixture-path");
1098 assert_eq!(RoleTag::StaleApi.as_str(), "stale-api");
1099 assert_eq!(RoleTag::PackageBoundary.as_str(), "package-boundary");
1100 assert_eq!(
1101 RoleTag::ReplacementCoverage.as_str(),
1102 "replacement-coverage"
1103 );
1104 assert_eq!(
1105 RoleTag::PerformanceCritical.as_str(),
1106 "performance-critical"
1107 );
1108 assert_eq!(RoleTag::AuditPending.as_str(), "audit-pending");
1109 assert_eq!(RoleTag::all().len(), 8);
1110 }
1111
1112 #[test]
1113 fn role_tag_round_trips_via_from_str() {
1114 for tag in RoleTag::all() {
1115 assert_eq!(RoleTag::from_str(tag.as_str()), Some(*tag));
1116 }
1117 }
1118
1119 #[test]
1120 fn role_tag_from_str_accepts_snake_case_and_whitespace() {
1121 assert_eq!(RoleTag::from_str("fast_test"), Some(RoleTag::FastTest));
1122 assert_eq!(RoleTag::from_str(" Stale-API "), Some(RoleTag::StaleApi));
1123 assert_eq!(
1124 RoleTag::from_str("audit_pending"),
1125 Some(RoleTag::AuditPending)
1126 );
1127 }
1128
1129 #[test]
1130 fn role_tag_from_str_returns_none_on_unknown() {
1131 assert_eq!(RoleTag::from_str("not-a-real-tag"), None);
1132 assert_eq!(RoleTag::from_str(""), None);
1133 }
1134
1135 #[test]
1136 fn penalty_and_boost_roles_match_t003_design() {
1137 let penalties: Vec<&str> = RoleTag::all()
1139 .iter()
1140 .filter(|t| t.is_penalty_role())
1141 .map(|t| t.as_str())
1142 .collect();
1143 assert_eq!(penalties, vec!["stale-api", "audit-pending"]);
1144
1145 let boosts: Vec<&str> = RoleTag::all()
1147 .iter()
1148 .filter(|t| t.is_boost_role())
1149 .map(|t| t.as_str())
1150 .collect();
1151 assert_eq!(boosts, vec!["package-boundary", "performance-critical"]);
1152 }
1153}
1154
1155#[cfg(test)]
1156mod runtime_evidence_tests {
1157 use super::*;
1163
1164 fn derive(prior: f64, conf: u64, contra: u64) -> f64 {
1165 RuntimeEvidence::derive_confidence(prior, conf, contra)
1166 }
1167
1168 #[test]
1169 fn zero_evidence_equals_prior() {
1170 for p in [0.0, 0.2, 0.5, 0.8, 1.0] {
1172 assert!(
1173 (derive(p, 0, 0) - p).abs() < 1e-9,
1174 "prior {p} not preserved"
1175 );
1176 }
1177 }
1178
1179 #[test]
1180 fn confirmations_raise_contradictions_lower() {
1181 let prior = 0.5;
1182 assert!(derive(prior, 3, 0) > prior, "confirmations should raise");
1183 assert!(derive(prior, 0, 3) < prior, "contradictions should lower");
1184 }
1185
1186 #[test]
1187 fn monotonic_in_each_count() {
1188 let prior = 0.6;
1189 for n in 0..20 {
1191 assert!(derive(prior, n + 1, 0) >= derive(prior, n, 0));
1192 assert!(derive(prior, 0, n + 1) <= derive(prior, 0, n));
1193 }
1194 }
1195
1196 #[test]
1197 fn always_within_unit_interval() {
1198 for &p in &[-1.0, 0.0, 0.3, 1.0, 2.0] {
1199 for conf in [0u64, 1, 50, 10_000] {
1200 for contra in [0u64, 1, 50, 10_000] {
1201 let c = derive(p, conf, contra);
1202 assert!(
1203 (0.0..=1.0).contains(&c),
1204 "out of range: {c} for {p},{conf},{contra}"
1205 );
1206 }
1207 }
1208 }
1209 }
1210
1211 #[test]
1212 fn evidence_overwhelms_prior_asymptotically() {
1213 let c = derive(0.9, 0, 1_000);
1215 assert!(
1216 c < 0.05,
1217 "1000 contradictions should crush a 0.9 prior, got {c}"
1218 );
1219 let c2 = derive(0.1, 1_000, 0);
1221 assert!(
1222 c2 > 0.95,
1223 "1000 confirmations should lift a 0.1 prior, got {c2}"
1224 );
1225 }
1226
1227 #[test]
1228 fn balanced_evidence_trends_to_half() {
1229 let c = derive(0.9, 500, 500);
1231 assert!(
1232 (c - 0.5).abs() < 0.02,
1233 "balanced evidence should near 0.5, got {c}"
1234 );
1235 }
1236
1237 #[test]
1238 fn confidence_method_matches_free_fn() {
1239 let ev = RuntimeEvidence {
1240 confirmations: 4,
1241 contradictions: 1,
1242 prior: 0.5,
1243 last_trace_id: Some("trc_x".into()),
1244 last_observed_at: Utc::now(),
1245 };
1246 assert!((ev.confidence() - derive(0.5, 4, 1)).abs() < 1e-12);
1247 }
1248
1249 #[test]
1250 fn effect_decl_persists_runtime_and_is_backward_compatible() {
1251 let d = EffectDecl {
1254 symbol_id: "s".into(),
1255 declared: Vec::new(),
1256 transitive: Vec::new(),
1257 verification: None,
1258 confidence: Some(0.83),
1259 runtime: Some(RuntimeEvidence {
1260 confirmations: 3,
1261 contradictions: 1,
1262 prior: 0.5,
1263 last_trace_id: Some("trc_1".into()),
1264 last_observed_at: Utc::now(),
1265 }),
1266 matched_policy: None,
1267 };
1268 let back: EffectDecl = serde_json::from_str(&serde_json::to_string(&d).unwrap()).unwrap();
1269 let rt = back.runtime.expect("runtime survives round-trip");
1270 assert_eq!((rt.confirmations, rt.contradictions, rt.prior), (3, 1, 0.5));
1271
1272 let old = r#"{"symbol_id":"s","declared":[],"transitive":[],"confidence":0.5}"#;
1274 let parsed: EffectDecl = serde_json::from_str(old).unwrap();
1275 assert!(parsed.runtime.is_none());
1276 }
1277}