use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub const ASD_SCHEMA_VERSION: &str = "0.1.0";
pub const ASD_PATH_PREFIX: &str = "/asd/v1";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Symbol {
pub symbol_id: String,
pub symbol_fp: String,
pub qname: String,
pub language: String,
pub kind: SymbolKind,
pub file: String,
pub start: Position,
pub end: Position,
pub signature: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub doc: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SymbolKind {
Module,
Function,
Method,
Class,
Variable,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Position {
pub line: u32,
pub col: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LedgerEntry {
pub entry_id: String,
pub symbol_id: String,
pub kind: LedgerKind,
pub summary: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
pub author: Author,
#[serde(skip_serializing_if = "Option::is_none")]
pub confidence: Option<f64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub evidence: Vec<Evidence>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub supersedes: Vec<String>,
pub created_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub matched_policy: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum LedgerKind {
Decision,
Assumption,
Constraint,
Rationale,
Hazard,
Tradeoff,
Invariant,
Ownership,
Proof,
ValidationScenario,
KnownBug,
Concept,
Mapping,
FollowUp,
Hypothesis,
MentalModel,
FailedAttempt,
OpenQuestion,
}
impl LedgerKind {
pub fn conclusion_class(self) -> ConclusionClass {
use LedgerKind::*;
match self {
Decision | Assumption | Constraint | Rationale | Tradeoff | Invariant => {
ConclusionClass::Decisions
}
Ownership | Concept => ConclusionClass::Classifications,
Mapping => ConclusionClass::Mappings,
Hazard | KnownBug => ConclusionClass::Hazards,
ValidationScenario | Proof => ConclusionClass::Recipes,
FollowUp => ConclusionClass::FollowUps,
Hypothesis | MentalModel | FailedAttempt | OpenQuestion => ConclusionClass::Thinking,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ConclusionClass {
Decisions,
Classifications,
Mappings,
Hazards,
Recipes,
FollowUps,
Thinking,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RoleTag {
FastTest,
DiagnosticTest,
FixturePath,
StaleApi,
PackageBoundary,
ReplacementCoverage,
PerformanceCritical,
AuditPending,
}
impl RoleTag {
pub fn as_str(self) -> &'static str {
match self {
Self::FastTest => "fast-test",
Self::DiagnosticTest => "diagnostic-test",
Self::FixturePath => "fixture-path",
Self::StaleApi => "stale-api",
Self::PackageBoundary => "package-boundary",
Self::ReplacementCoverage => "replacement-coverage",
Self::PerformanceCritical => "performance-critical",
Self::AuditPending => "audit-pending",
}
}
pub fn from_str(s: &str) -> Option<Self> {
let norm = s.trim().to_ascii_lowercase().replace('_', "-");
match norm.as_str() {
"fast-test" => Some(Self::FastTest),
"diagnostic-test" => Some(Self::DiagnosticTest),
"fixture-path" => Some(Self::FixturePath),
"stale-api" => Some(Self::StaleApi),
"package-boundary" => Some(Self::PackageBoundary),
"replacement-coverage" => Some(Self::ReplacementCoverage),
"performance-critical" => Some(Self::PerformanceCritical),
"audit-pending" => Some(Self::AuditPending),
_ => None,
}
}
pub fn all() -> &'static [RoleTag] {
&[
Self::FastTest,
Self::DiagnosticTest,
Self::FixturePath,
Self::StaleApi,
Self::PackageBoundary,
Self::ReplacementCoverage,
Self::PerformanceCritical,
Self::AuditPending,
]
}
pub fn is_penalty_role(self) -> bool {
matches!(self, Self::StaleApi | Self::AuditPending)
}
pub fn is_boost_role(self) -> bool {
matches!(self, Self::PackageBoundary | Self::PerformanceCritical)
}
}
impl ConclusionClass {
pub fn filename_stem(self) -> &'static str {
match self {
Self::Decisions => "decisions",
Self::Classifications => "classifications",
Self::Mappings => "mappings",
Self::Hazards => "hazards",
Self::Recipes => "recipes",
Self::FollowUps => "followups",
Self::Thinking => "thinking",
}
}
pub fn all() -> &'static [ConclusionClass] {
&[
Self::Decisions,
Self::Classifications,
Self::Mappings,
Self::Hazards,
Self::Recipes,
Self::FollowUps,
Self::Thinking,
]
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Author {
pub kind: AuthorKind,
pub id: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AuthorKind {
Agent,
Human,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Evidence {
Trace { id: String },
Ledger { id: String },
Test { qname: String },
Ctxone { id: String },
External { url: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EffectDecl {
pub symbol_id: String,
#[serde(default)]
pub declared: Vec<Effect>,
#[serde(default)]
pub transitive: Vec<TransitiveEffect>,
#[serde(skip_serializing_if = "Option::is_none")]
pub verification: Option<Verification>,
#[serde(skip_serializing_if = "Option::is_none")]
pub confidence: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime: Option<RuntimeEvidence>,
#[serde(skip_serializing_if = "Option::is_none")]
pub matched_policy: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuntimeEvidence {
pub confirmations: u64,
pub contradictions: u64,
pub prior: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_trace_id: Option<String>,
pub last_observed_at: DateTime<Utc>,
}
impl RuntimeEvidence {
pub const PRIOR_STRENGTH: f64 = 2.0;
pub const NEUTRAL_PRIOR: f64 = 0.5;
pub fn derive_confidence(prior: f64, confirmations: u64, contradictions: u64) -> f64 {
let p0 = prior.clamp(0.0, 1.0);
let s = Self::PRIOR_STRENGTH;
let alpha = p0 * s + confirmations as f64;
let total = s + confirmations as f64 + contradictions as f64;
alpha / total
}
pub fn confidence(&self) -> f64 {
Self::derive_confidence(self.prior, self.confirmations, self.contradictions)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Effect {
pub effect: EffectCategory,
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
pub qualifiers: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub adapter: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_pattern: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub verified: Option<bool>,
}
impl Effect {
pub fn new(effect: EffectCategory) -> Self {
Self {
effect,
qualifiers: serde_json::Value::Null,
note: None,
adapter: None,
source_pattern: None,
verified: None,
}
}
}
impl Default for Effect {
fn default() -> Self {
Self::new(EffectCategory::Pure)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum EffectCategory {
IoFsRead,
IoFsWrite,
IoNetIn,
IoNetOut,
IoDbRead,
IoDbWrite,
StateGlobalRead,
StateGlobalWrite,
StateProcess,
EnvRead,
TimeRead,
TimeSleep,
Random,
ProcSpawn,
Throw,
Log,
Pure,
Other(String),
}
impl EffectCategory {
pub fn as_str(&self) -> &str {
match self {
EffectCategory::IoFsRead => "io.fs.read",
EffectCategory::IoFsWrite => "io.fs.write",
EffectCategory::IoNetIn => "io.net.in",
EffectCategory::IoNetOut => "io.net.out",
EffectCategory::IoDbRead => "io.db.read",
EffectCategory::IoDbWrite => "io.db.write",
EffectCategory::StateGlobalRead => "state.global.read",
EffectCategory::StateGlobalWrite => "state.global.write",
EffectCategory::StateProcess => "state.process",
EffectCategory::EnvRead => "env.read",
EffectCategory::TimeRead => "time.read",
EffectCategory::TimeSleep => "time.sleep",
EffectCategory::Random => "random",
EffectCategory::ProcSpawn => "proc.spawn",
EffectCategory::Throw => "throw",
EffectCategory::Log => "log",
EffectCategory::Pure => "pure",
EffectCategory::Other(s) => s.as_str(),
}
}
pub fn is_low_signal(&self) -> bool {
matches!(
self,
Self::Throw | Self::Random | Self::Log | Self::Pure | Self::TimeRead | Self::TimeSleep
)
}
pub fn from_str(s: &str) -> Self {
match s {
"io.fs.read" => EffectCategory::IoFsRead,
"io.fs.write" => EffectCategory::IoFsWrite,
"io.net.in" => EffectCategory::IoNetIn,
"io.net.out" => EffectCategory::IoNetOut,
"io.db.read" => EffectCategory::IoDbRead,
"io.db.write" => EffectCategory::IoDbWrite,
"state.global.read" => EffectCategory::StateGlobalRead,
"state.global.write" => EffectCategory::StateGlobalWrite,
"state.process" => EffectCategory::StateProcess,
"env.read" => EffectCategory::EnvRead,
"time.read" => EffectCategory::TimeRead,
"time.sleep" => EffectCategory::TimeSleep,
"random" => EffectCategory::Random,
"proc.spawn" => EffectCategory::ProcSpawn,
"throw" => EffectCategory::Throw,
"log" => EffectCategory::Log,
"pure" => EffectCategory::Pure,
other => EffectCategory::Other(other.to_string()),
}
}
}
impl serde::Serialize for EffectCategory {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.as_str())
}
}
impl<'de> serde::Deserialize<'de> for EffectCategory {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
Ok(EffectCategory::from_str(&s))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransitiveEffect {
pub effect: EffectCategory,
pub via: Vec<String>,
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
pub qualifiers: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Verification {
pub by: VerificationSource,
pub at: DateTime<Utc>,
pub status: VerificationStatus,
#[serde(default)]
pub mismatches: Vec<Mismatch>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum VerificationSource {
StaticChecker,
RuntimeTracer,
TestObserved,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VerificationStatus {
Ok,
Mismatch,
Unverified,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Mismatch {
pub kind: String,
pub effect: EffectCategory,
#[serde(skip_serializing_if = "Option::is_none")]
pub detected_in: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
impl LedgerEntry {
pub fn new(
symbol_id: impl Into<String>,
kind: LedgerKind,
summary: impl Into<String>,
author: Author,
) -> Self {
Self {
entry_id: format!("led_{}", Uuid::new_v4().simple()),
symbol_id: symbol_id.into(),
kind,
summary: summary.into(),
body: None,
author,
confidence: None,
evidence: Vec::new(),
supersedes: Vec::new(),
created_at: Utc::now(),
tags: Vec::new(),
matched_policy: None,
role: None,
command: None,
}
}
}
impl LedgerKind {
pub fn as_str(self) -> &'static str {
match self {
LedgerKind::Decision => "decision",
LedgerKind::Assumption => "assumption",
LedgerKind::Constraint => "constraint",
LedgerKind::Rationale => "rationale",
LedgerKind::Hazard => "hazard",
LedgerKind::Tradeoff => "tradeoff",
LedgerKind::Invariant => "invariant",
LedgerKind::Ownership => "ownership",
LedgerKind::Proof => "proof",
LedgerKind::ValidationScenario => "validation_scenario",
LedgerKind::KnownBug => "known_bug",
LedgerKind::Concept => "concept",
LedgerKind::Mapping => "mapping",
LedgerKind::FollowUp => "follow_up",
LedgerKind::Hypothesis => "hypothesis",
LedgerKind::MentalModel => "mental_model",
LedgerKind::FailedAttempt => "failed_attempt",
LedgerKind::OpenQuestion => "open_question",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ScratchStatus {
Draft,
Promoted,
Discarded,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScratchEntry {
pub scratch_id: String,
pub symbol_id: Option<String>,
pub workflow: Option<String>,
pub session: String,
pub content: String,
pub status: ScratchStatus,
pub promoted_to: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub expires_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
}
impl ScratchEntry {
pub fn new(content: impl Into<String>, session: impl Into<String>) -> Self {
let now = Utc::now();
Self {
scratch_id: format!("scr_{}", Uuid::new_v4().simple()),
symbol_id: None,
workflow: None,
session: session.into(),
content: content.into(),
status: ScratchStatus::Draft,
promoted_to: None,
created_at: now,
updated_at: now,
expires_at: None,
tags: Vec::new(),
}
}
pub fn is_expired(&self) -> bool {
self.expires_at.map_or(false, |t| Utc::now() > t)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Rebind {
pub from_symbol_id: String,
pub to_symbol_id: String,
pub to_qname: String,
pub at: DateTime<Utc>,
pub by: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FeedbackVerdict {
Useful,
Noisy,
Missing,
WrongLayer,
AlreadyCovered,
DiagnosticOnly,
}
impl FeedbackVerdict {
pub fn as_str(self) -> &'static str {
match self {
Self::Useful => "useful",
Self::Noisy => "noisy",
Self::Missing => "missing",
Self::WrongLayer => "wrong-layer",
Self::AlreadyCovered => "already-covered",
Self::DiagnosticOnly => "diagnostic-only",
}
}
pub fn from_str(s: &str) -> Option<Self> {
let norm = s.trim().to_ascii_lowercase().replace('_', "-");
match norm.as_str() {
"useful" => Some(Self::Useful),
"noisy" => Some(Self::Noisy),
"missing" => Some(Self::Missing),
"wrong-layer" => Some(Self::WrongLayer),
"already-covered" => Some(Self::AlreadyCovered),
"diagnostic-only" => Some(Self::DiagnosticOnly),
_ => None,
}
}
pub fn is_suppression(self) -> bool {
matches!(
self,
Self::Noisy | Self::WrongLayer | Self::AlreadyCovered | Self::DiagnosticOnly
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeedbackEntry {
pub entry_id: String,
pub symbol_id: String,
pub symbol_qname: String,
pub query: String,
pub verdict: FeedbackVerdict,
pub author: String,
pub created_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_scope: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub withdrawn_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub withdrawn_by: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub withdrawn_reason: Option<String>,
}
impl FeedbackEntry {
pub fn is_withdrawn(&self) -> bool {
self.withdrawn_at.is_some()
}
pub fn is_inert(&self) -> bool {
self.is_expired() || self.is_withdrawn()
}
pub fn is_expired(&self) -> bool {
self.expires_at.map_or(false, |t| Utc::now() > t)
}
}
#[cfg(test)]
mod plan_b_schema_tests {
use super::*;
#[test]
fn feedback_is_expired_returns_false_when_unset() {
let e = FeedbackEntry {
entry_id: "fb_x".into(),
symbol_id: "sym_x".into(),
symbol_qname: "pkg.x".into(),
query: "q".into(),
verdict: FeedbackVerdict::Useful,
author: "a".into(),
created_at: Utc::now(),
note: None,
file_scope: None,
expires_at: None,
withdrawn_at: None,
withdrawn_by: None,
withdrawn_reason: None,
};
assert!(!e.is_expired());
}
#[test]
fn feedback_is_expired_returns_true_when_past() {
let mut e = FeedbackEntry {
entry_id: "fb_x".into(),
symbol_id: "sym_x".into(),
symbol_qname: "pkg.x".into(),
query: "q".into(),
verdict: FeedbackVerdict::Useful,
author: "a".into(),
created_at: Utc::now(),
note: None,
file_scope: None,
expires_at: None,
withdrawn_at: None,
withdrawn_by: None,
withdrawn_reason: None,
};
e.expires_at = Some(Utc::now() - chrono::Duration::days(1));
assert!(e.is_expired(), "expired 1 day ago must report expired");
}
#[test]
fn feedback_is_expired_returns_false_when_future() {
let mut e = FeedbackEntry {
entry_id: "fb_x".into(),
symbol_id: "sym_x".into(),
symbol_qname: "pkg.x".into(),
query: "q".into(),
verdict: FeedbackVerdict::Useful,
author: "a".into(),
created_at: Utc::now(),
note: None,
file_scope: None,
expires_at: None,
withdrawn_at: None,
withdrawn_by: None,
withdrawn_reason: None,
};
e.expires_at = Some(Utc::now() + chrono::Duration::days(30));
assert!(!e.is_expired(), "future expiry must NOT report expired");
}
#[test]
fn new_kinds_serialize_to_snake_case() {
let json = serde_json::to_string(&LedgerKind::Mapping).unwrap();
assert_eq!(json, "\"mapping\"");
let json = serde_json::to_string(&LedgerKind::FollowUp).unwrap();
assert_eq!(json, "\"follow_up\"");
}
#[test]
fn new_kinds_round_trip_through_serde() {
for kind in [LedgerKind::Mapping, LedgerKind::FollowUp] {
let json = serde_json::to_string(&kind).unwrap();
let back: LedgerKind = serde_json::from_str(&json).unwrap();
assert_eq!(back, kind);
}
}
#[test]
fn optional_fields_are_omitted_when_none() {
let entry = LedgerEntry::new(
"sym_test",
LedgerKind::Decision,
"some decision",
Author {
kind: AuthorKind::Agent,
id: "agent".into(),
},
);
let json = serde_json::to_string(&entry).unwrap();
assert!(!json.contains("\"role\""));
assert!(!json.contains("\"command\""));
}
#[test]
fn optional_fields_round_trip_when_set() {
let mut entry = LedgerEntry::new(
"sym_test",
LedgerKind::FollowUp,
"SID real-file diagnostics still need migration",
Author {
kind: AuthorKind::Human,
id: "craig".into(),
},
);
entry.role = Some("diagnostic-test".into());
entry.command = Some("swift test --filter SongPlayersTests".into());
let json = serde_json::to_string(&entry).unwrap();
let back: LedgerEntry = serde_json::from_str(&json).unwrap();
assert_eq!(back.role.as_deref(), Some("diagnostic-test"));
assert_eq!(
back.command.as_deref(),
Some("swift test --filter SongPlayersTests")
);
}
#[test]
fn every_ledger_kind_buckets_to_a_conclusion_class() {
use ConclusionClass::*;
let pairs = [
(LedgerKind::Decision, Decisions),
(LedgerKind::Assumption, Decisions),
(LedgerKind::Constraint, Decisions),
(LedgerKind::Rationale, Decisions),
(LedgerKind::Tradeoff, Decisions),
(LedgerKind::Invariant, Decisions),
(LedgerKind::Ownership, Classifications),
(LedgerKind::Concept, Classifications),
(LedgerKind::Mapping, Mappings),
(LedgerKind::Hazard, Hazards),
(LedgerKind::KnownBug, Hazards),
(LedgerKind::ValidationScenario, Recipes),
(LedgerKind::Proof, Recipes),
(LedgerKind::FollowUp, FollowUps),
];
for (kind, expected) in pairs {
assert_eq!(
kind.conclusion_class(),
expected,
"kind {:?} should bucket to {:?}",
kind,
expected
);
}
}
#[test]
fn conclusion_class_filename_stems_match_design() {
assert_eq!(ConclusionClass::Decisions.filename_stem(), "decisions");
assert_eq!(
ConclusionClass::Classifications.filename_stem(),
"classifications"
);
assert_eq!(ConclusionClass::Mappings.filename_stem(), "mappings");
assert_eq!(ConclusionClass::Hazards.filename_stem(), "hazards");
assert_eq!(ConclusionClass::Recipes.filename_stem(), "recipes");
assert_eq!(ConclusionClass::FollowUps.filename_stem(), "followups");
assert_eq!(ConclusionClass::Thinking.filename_stem(), "thinking");
assert_eq!(ConclusionClass::all().len(), 7);
}
#[test]
fn plan_g_thinking_kinds_bucket_to_thinking_class() {
for kind in [
LedgerKind::Hypothesis,
LedgerKind::MentalModel,
LedgerKind::FailedAttempt,
LedgerKind::OpenQuestion,
] {
assert_eq!(
kind.conclusion_class(),
ConclusionClass::Thinking,
"kind {:?} must bucket to Thinking",
kind
);
}
}
#[test]
fn plan_g_thinking_kinds_wire_strings_match_design() {
assert_eq!(LedgerKind::Hypothesis.as_str(), "hypothesis");
assert_eq!(LedgerKind::MentalModel.as_str(), "mental_model");
assert_eq!(LedgerKind::FailedAttempt.as_str(), "failed_attempt");
assert_eq!(LedgerKind::OpenQuestion.as_str(), "open_question");
}
#[test]
fn plan_g_thinking_kinds_serde_round_trip() {
use serde_json::json;
for kind in [
LedgerKind::Hypothesis,
LedgerKind::MentalModel,
LedgerKind::FailedAttempt,
LedgerKind::OpenQuestion,
] {
let v = serde_json::to_value(kind).unwrap();
assert_eq!(v, json!(kind.as_str()));
let back: LedgerKind = serde_json::from_value(v).unwrap();
assert_eq!(back, kind);
}
}
#[test]
fn role_tag_wire_strings_match_design() {
assert_eq!(RoleTag::FastTest.as_str(), "fast-test");
assert_eq!(RoleTag::DiagnosticTest.as_str(), "diagnostic-test");
assert_eq!(RoleTag::FixturePath.as_str(), "fixture-path");
assert_eq!(RoleTag::StaleApi.as_str(), "stale-api");
assert_eq!(RoleTag::PackageBoundary.as_str(), "package-boundary");
assert_eq!(
RoleTag::ReplacementCoverage.as_str(),
"replacement-coverage"
);
assert_eq!(
RoleTag::PerformanceCritical.as_str(),
"performance-critical"
);
assert_eq!(RoleTag::AuditPending.as_str(), "audit-pending");
assert_eq!(RoleTag::all().len(), 8);
}
#[test]
fn role_tag_round_trips_via_from_str() {
for tag in RoleTag::all() {
assert_eq!(RoleTag::from_str(tag.as_str()), Some(*tag));
}
}
#[test]
fn role_tag_from_str_accepts_snake_case_and_whitespace() {
assert_eq!(RoleTag::from_str("fast_test"), Some(RoleTag::FastTest));
assert_eq!(RoleTag::from_str(" Stale-API "), Some(RoleTag::StaleApi));
assert_eq!(
RoleTag::from_str("audit_pending"),
Some(RoleTag::AuditPending)
);
}
#[test]
fn role_tag_from_str_returns_none_on_unknown() {
assert_eq!(RoleTag::from_str("not-a-real-tag"), None);
assert_eq!(RoleTag::from_str(""), None);
}
#[test]
fn penalty_and_boost_roles_match_t003_design() {
let penalties: Vec<&str> = RoleTag::all()
.iter()
.filter(|t| t.is_penalty_role())
.map(|t| t.as_str())
.collect();
assert_eq!(penalties, vec!["stale-api", "audit-pending"]);
let boosts: Vec<&str> = RoleTag::all()
.iter()
.filter(|t| t.is_boost_role())
.map(|t| t.as_str())
.collect();
assert_eq!(boosts, vec!["package-boundary", "performance-critical"]);
}
}
#[cfg(test)]
mod runtime_evidence_tests {
use super::*;
fn derive(prior: f64, conf: u64, contra: u64) -> f64 {
RuntimeEvidence::derive_confidence(prior, conf, contra)
}
#[test]
fn zero_evidence_equals_prior() {
for p in [0.0, 0.2, 0.5, 0.8, 1.0] {
assert!(
(derive(p, 0, 0) - p).abs() < 1e-9,
"prior {p} not preserved"
);
}
}
#[test]
fn confirmations_raise_contradictions_lower() {
let prior = 0.5;
assert!(derive(prior, 3, 0) > prior, "confirmations should raise");
assert!(derive(prior, 0, 3) < prior, "contradictions should lower");
}
#[test]
fn monotonic_in_each_count() {
let prior = 0.6;
for n in 0..20 {
assert!(derive(prior, n + 1, 0) >= derive(prior, n, 0));
assert!(derive(prior, 0, n + 1) <= derive(prior, 0, n));
}
}
#[test]
fn always_within_unit_interval() {
for &p in &[-1.0, 0.0, 0.3, 1.0, 2.0] {
for conf in [0u64, 1, 50, 10_000] {
for contra in [0u64, 1, 50, 10_000] {
let c = derive(p, conf, contra);
assert!(
(0.0..=1.0).contains(&c),
"out of range: {c} for {p},{conf},{contra}"
);
}
}
}
}
#[test]
fn evidence_overwhelms_prior_asymptotically() {
let c = derive(0.9, 0, 1_000);
assert!(
c < 0.05,
"1000 contradictions should crush a 0.9 prior, got {c}"
);
let c2 = derive(0.1, 1_000, 0);
assert!(
c2 > 0.95,
"1000 confirmations should lift a 0.1 prior, got {c2}"
);
}
#[test]
fn balanced_evidence_trends_to_half() {
let c = derive(0.9, 500, 500);
assert!(
(c - 0.5).abs() < 0.02,
"balanced evidence should near 0.5, got {c}"
);
}
#[test]
fn confidence_method_matches_free_fn() {
let ev = RuntimeEvidence {
confirmations: 4,
contradictions: 1,
prior: 0.5,
last_trace_id: Some("trc_x".into()),
last_observed_at: Utc::now(),
};
assert!((ev.confidence() - derive(0.5, 4, 1)).abs() < 1e-12);
}
#[test]
fn effect_decl_persists_runtime_and_is_backward_compatible() {
let d = EffectDecl {
symbol_id: "s".into(),
declared: Vec::new(),
transitive: Vec::new(),
verification: None,
confidence: Some(0.83),
runtime: Some(RuntimeEvidence {
confirmations: 3,
contradictions: 1,
prior: 0.5,
last_trace_id: Some("trc_1".into()),
last_observed_at: Utc::now(),
}),
matched_policy: None,
};
let back: EffectDecl = serde_json::from_str(&serde_json::to_string(&d).unwrap()).unwrap();
let rt = back.runtime.expect("runtime survives round-trip");
assert_eq!((rt.confirmations, rt.contradictions, rt.prior), (3, 1, 0.5));
let old = r#"{"symbol_id":"s","declared":[],"transitive":[],"confidence":0.5}"#;
let parsed: EffectDecl = serde_json::from_str(old).unwrap();
assert!(parsed.runtime.is_none());
}
}