Skip to main content

agentstatedeveloper_core/
schema.rs

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    /// Plan B t-002: role/intent tag for classification entries
60    /// (e.g. "diagnostic-test", "fast-test", "fixture-path", "stale-api").
61    /// Optional; only meaningful for kind=Ownership/Concept (the
62    /// classification family). Skipped from JSON when None.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub role: Option<String>,
65    /// Plan B t-002: canonical reproduction or validation command for
66    /// recipe-style entries (e.g. "swift test --filter SongPlayersTests").
67    /// Optional; only meaningful for kind=ValidationScenario/Proof or
68    /// kind=FollowUp (where it is the command that closes the follow-up).
69    #[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    /// A design or implementation decision that was made.
77    Decision,
78    /// An assumption that was made (may need validation).
79    Assumption,
80    /// A hard constraint this symbol must always satisfy.
81    Constraint,
82    /// Rationale for why something was done a particular way.
83    Rationale,
84    /// A known danger: what can go wrong if this changes.
85    Hazard,
86    /// A tradeoff accepted to gain some benefit.
87    Tradeoff,
88    /// An invariant that must always hold at this symbol.
89    Invariant,
90    /// Ownership declaration: which subsystem/team owns this symbol.
91    Ownership,
92    /// Evidence that an invariant holds (test, review, trace, etc.).
93    Proof,
94    /// A concrete scenario that should be validated (behaviour + expected outcome).
95    ValidationScenario,
96    /// A known bug or defect that has not been fixed yet.
97    KnownBug,
98    /// A domain concept (e.g. "Drift Pad clip playhead") — first-class queryable entity.
99    Concept,
100    /// Plan B t-002: replacement-coverage mapping. "Legacy SIDFileParserTests
101    /// coverage now lives in SIDParserAndPlayerTests." Body holds the cross-
102    /// reference in JSON (`from_qname`, `to_qname`, `rationale`).
103    Mapping,
104    /// Plan B t-002: open follow-up tied to an external task system.
105    /// "SID real-file diagnostics still need migration under t-024." The
106    /// `command` field on LedgerEntry may carry the closing command; the
107    /// `external_task_id` evidence variant carries the task pointer.
108    FollowUp,
109    /// Plan G t-002: a speculative claim with confidence. Uses the
110    /// existing `LedgerEntry.confidence: Option<f64>` (0.0-1.0). Promoted
111    /// to Decision once validated. Distinct from Assumption: Assumption
112    /// = "we proceed as if true"; Hypothesis = "I suspect, evidence
113    /// pending".
114    Hypothesis,
115    /// Plan G t-002: multi-symbol structural understanding ("the audio
116    /// pipeline flows input → mix → out"). Body MAY carry JSON
117    /// `{"symbols": [qname...], "diagram": "..."}`. Distinct from
118    /// Concept (single domain entity) — MentalModel spans symbols.
119    MentalModel,
120    /// Plan G t-002: negative evidence. "I tried X; failed because Y;
121    /// pivoting to Z." Body MAY carry JSON `{"tried": "...", "because":
122    /// "..."}`. Saves the next session from re-treading the same path.
123    FailedAttempt,
124    /// Plan G t-002: a known unknown blocking confident action. "What
125    /// does this magic constant 4096 mean? Need to ask before
126    /// changing." Summary is the question; body MAY hold partial
127    /// findings. Distinct from FollowUp: FollowUp is "do X under task
128    /// T-024"; OpenQuestion is "what does this mean?".
129    OpenQuestion,
130}
131
132impl LedgerKind {
133    /// Plan B t-001/t-002: map a LedgerKind to its conclusion class.
134    /// Drives export bucketing into .asd/conclusions/{class}.jsonl.
135    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            // Plan G t-002: thinking kinds bucket into a new
147            // .asd/conclusions/thinking.jsonl file.
148            Hypothesis | MentalModel | FailedAttempt | OpenQuestion => ConclusionClass::Thinking,
149        }
150    }
151}
152
153/// The conclusion-class buckets that drive `.asd/conclusions/*.jsonl`
154/// export and the Plan B sidecar redesign. Each class corresponds to one
155/// JSONL file; LedgerKind variants are bucketed via `conclusion_class()`.
156///
157/// Plan G t-002 added `Thinking` as the 7th class (Hypothesis,
158/// MentalModel, FailedAttempt, OpenQuestion).
159#[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/// Plan C t-002: first-class role-tag vocabulary. The optional
171/// `LedgerEntry.role` field is free-form `String` (so unknown tags don't
172/// break old data), but CLI / MCP / API layers validate against this enum
173/// at write time and warn on unknown values.
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
175pub enum RoleTag {
176    /// Lightweight test, safe in tight feedback loops.
177    FastTest,
178    /// Debug / instrumentation test; not part of main CI.
179    DiagnosticTest,
180    /// Shared test fixture; multiple tests depend on it.
181    FixturePath,
182    /// Deprecated interface; migration tracked.
183    StaleApi,
184    /// Cross-package facade; changes need coordination.
185    PackageBoundary,
186    /// Legacy coverage handled by newer code (paired with Mapping kind).
187    ReplacementCoverage,
188    /// Hot path; changes need perf measurement.
189    PerformanceCritical,
190    /// Not yet reviewed; pending validation.
191    AuditPending,
192}
193
194impl RoleTag {
195    /// Canonical wire string (kebab-case for human readability).
196    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    /// Parse a wire string. Accepts both kebab-case canonical form and
210    /// snake_case for forgiveness. Returns None on unknown input — callers
211    /// that need validation handle the warning themselves.
212    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    /// All canonical tags in stable order — useful for CLI value-enum lists
228    /// and probe assertions.
229    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    /// Plan C t-003: tags that act as ranking PENALTIES when a
243    /// Constraint/Decision entry carries them. Used by the decisions-as-
244    /// constraints pipeline to synthesize WrongLayer-like verdicts.
245    pub fn is_penalty_role(self) -> bool {
246        matches!(self, Self::StaleApi | Self::AuditPending)
247    }
248
249    /// Plan C t-003: tags that act as ranking BOOSTS to peer symbols in
250    /// the same package/file (e.g. package-boundary surfaces the inside
251    /// alternatives).
252    pub fn is_boost_role(self) -> bool {
253        matches!(self, Self::PackageBoundary | Self::PerformanceCritical)
254    }
255}
256
257impl ConclusionClass {
258    /// Filename stem under `.asd/conclusions/` (e.g. `decisions` → `decisions.jsonl`).
259    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    /// All classes in stable order — used by export to walk buckets.
272    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    /// Accumulated runtime-trace evidence. When present, `confidence` is
320    /// re-derived from it on each `asd trace` ingest.
321    #[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/// Accumulated runtime-trace evidence for a symbol's declared effects.
328///
329/// Each `asd trace` ingest classifies the run as either a **confirmation**
330/// (every observed effect was already declared) or a **positive contradiction**
331/// (an effect was observed at runtime that was NOT declared). A declared effect
332/// that simply wasn't exercised is *not* a contradiction — absence of
333/// observation is not evidence of absence — so it never lowers confidence.
334///
335/// `EffectDecl.confidence` is then re-derived from these counts via
336/// [`RuntimeEvidence::derive_confidence`], seeded by the static `prior` so that
337/// with zero runtime evidence the confidence equals the prior, each
338/// confirmation pulls it toward 1.0, and each contradiction toward 0.0.
339#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct RuntimeEvidence {
341    /// Traces whose observed effects were all already declared.
342    pub confirmations: u64,
343    /// Traces that observed an effect not present in the declaration.
344    pub contradictions: u64,
345    /// Static/policy confidence captured at the first runtime observation. Used
346    /// as the prior the runtime evidence updates, and frozen thereafter so the
347    /// derivation stays reproducible from (prior, confirmations, contradictions).
348    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    /// Pseudo-count weight given to the static prior. With zero runtime
356    /// evidence the derived confidence equals the prior; each confirmation
357    /// pulls it toward 1.0 and each contradiction toward 0.0, with the prior
358    /// worth `PRIOR_STRENGTH` observations of resistance.
359    pub const PRIOR_STRENGTH: f64 = 2.0;
360
361    /// Neutral prior used when no static/policy confidence is available.
362    pub const NEUTRAL_PRIOR: f64 = 0.5;
363
364    /// Derive a confidence in `[0, 1]` from the static prior and accumulated
365    /// counts. This is a Laplace-smoothed success rate (equivalently the mean of
366    /// a Beta posterior) with the prior seeded as `PRIOR_STRENGTH`
367    /// pseudo-observations:
368    ///
369    /// ```text
370    /// confidence = (prior*S + confirmations) / (S + confirmations + contradictions)
371    /// ```
372    ///
373    /// With `confirmations == contradictions == 0` this returns `prior` exactly.
374    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    /// Confidence derived from this evidence record.
383    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    /// Language adapter that inferred this effect (e.g. "swift", "python").
396    #[serde(skip_serializing_if = "Option::is_none")]
397    pub adapter: Option<String>,
398    /// Source-code pattern that triggered inference (e.g. "FileManager", "URLSession").
399    #[serde(skip_serializing_if = "Option::is_none")]
400    pub source_pattern: Option<String>,
401    /// Whether verify-effects confirmed this effect against the current source.
402    #[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/// Effect category — either a well-known built-in or a user-defined domain
426/// string (e.g. `"midi.send"`, `"audio.graph.connect"`).
427///
428/// User-defined categories are declared with `asd effect declare --category`
429/// and are not inferred automatically by language adapters.  Well-known
430/// categories may be inferred.
431///
432/// # Extensibility
433/// The `Other(String)` variant accepts any dot-separated namespace string.
434/// Recommended namespaces for common domains:
435/// - `audio.*` — audio engine operations (`audio.graph.connect`, `audio.graph.disconnect`)
436/// - `midi.*` — MIDI I/O (`midi.send`, `midi.receive`)
437/// - `scheduler.*` — sequencer/lane control (`scheduler.restart`, `scheduler.stop`)
438/// - `ui.*` — UI state mutations (`ui.state.mutate`)
439/// - `file.*` — higher-level import/export (`file.import`, `file.export`)
440#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
441pub enum EffectCategory {
442    // Infrastructure effects (inferred by language adapters)
443    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    /// User-defined domain effect (e.g. `"midi.send"`, `"audio.graph.connect"`).
461    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    /// Returns true for effects that are nearly universal and low-signal
489    /// (appear on most symbols, rarely indicate a meaningful side-effect).
490    /// Used to suppress noise in `prepare_change` and `effects_of` output.
491    /// Low-signal effects are only shown when they are the only effects on a symbol.
492    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// ---------------------------------------------------------------------------
631// Scratchpad types
632// ---------------------------------------------------------------------------
633
634/// Status of a [`ScratchEntry`]. Transitions: Draft → Promoted or Discarded.
635#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
636#[serde(rename_all = "lowercase")]
637pub enum ScratchStatus {
638    /// Working note, not yet acted on.
639    Draft,
640    /// Promoted to a ledger entry; `promoted_to` holds the `entry_id`.
641    Promoted,
642    /// Explicitly discarded by the author.
643    Discarded,
644}
645
646/// Ephemeral working note scoped to a symbol and/or named workflow.
647///
648/// Scratch entries are stored locally at `/asd/v1/scratch/<scratch_id>`.
649/// They are **not** synced to the sidecar and not subject to policy gate.
650/// Use [`ScratchStatus::Promoted`] + `promoted_to` to link a note that
651/// has been elevated to a durable [`LedgerEntry`].
652#[derive(Debug, Clone, Serialize, Deserialize)]
653pub struct ScratchEntry {
654    /// Stable ID, format `"scr_<uuid-simple>"`.
655    pub scratch_id: String,
656    /// Optional: scope to an indexed symbol_id.
657    pub symbol_id: Option<String>,
658    /// Optional: named investigation context (e.g. `"tracing-sync-bug"`).
659    pub workflow: Option<String>,
660    /// Agent or user who wrote the note (`agent_id`).
661    pub session: String,
662    /// Markdown-friendly working notes.
663    pub content: String,
664    /// Current status.
665    pub status: ScratchStatus,
666    /// Set when status transitions to `Promoted`; holds the ledger `entry_id`.
667    pub promoted_to: Option<String>,
668    pub created_at: DateTime<Utc>,
669    pub updated_at: DateTime<Utc>,
670    /// When set, the entry is considered expired after this timestamp.
671    pub expires_at: Option<DateTime<Utc>>,
672    /// Freeform tags for grouping.
673    #[serde(default, skip_serializing_if = "Vec::is_empty")]
674    pub tags: Vec<String>,
675}
676
677impl ScratchEntry {
678    /// Create a new draft entry with minimal fields. Caller fills in optional
679    /// `symbol_id`, `workflow`, `expires_at`, and `tags` after construction.
680    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    /// Returns `true` when `expires_at` is set and is in the past.
698    pub fn is_expired(&self) -> bool {
699        self.expires_at.map_or(false, |t| Utc::now() > t)
700    }
701}
702
703// ---------------------------------------------------------------------------
704
705/// Record that a symbol was renamed/moved so its ledger history follows it.
706/// Written to `/asd/v1/rebinds/<from_symbol_id>` at rename time.
707#[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// ---------------------------------------------------------------------------
717// Feedback model
718// ---------------------------------------------------------------------------
719
720/// Verdict an agent or user assigns to a search result.
721#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
722#[serde(rename_all = "snake_case")]
723pub enum FeedbackVerdict {
724    /// This result was relevant and useful for the query.
725    Useful,
726    /// This result appeared but was unrelated to the query intent.
727    Noisy,
728    /// This symbol was missing from results but should have appeared.
729    Missing,
730    /// This symbol appeared in the wrong architectural layer context.
731    WrongLayer,
732    /// Plan C t-005: this symbol's behavior is already covered by another
733    /// symbol. Acts as suppression like `Noisy`; callers should also write
734    /// a `Mapping` ledger entry pointing at the covering symbol so future
735    /// queries see the connection durably.
736    AlreadyCovered,
737    /// Plan C t-005: this symbol is a diagnostic/instrumentation test, not
738    /// production validation. Acts as suppression like `Noisy`; callers
739    /// should also write a `Classification` (`role = diagnostic-test`)
740    /// ledger entry so the t-003 decisions-as-constraints pipeline can
741    /// demote it on future queries.
742    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    /// Parse from the string produced by [`as_str`].  Returns `None` on
758    /// unrecognized values so callers can decide on a fallback. Accepts
759    /// underscores too (`already_covered` / `diagnostic_only`) for
760    /// forgiveness when agents type from memory.
761    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    /// Plan C t-005: returns true when this verdict should suppress the
775    /// symbol from ranked results — used by `apply_feedback_adjustments`
776    /// to fold AlreadyCovered + DiagnosticOnly into the existing
777    /// NEG_INFINITY suppression path.
778    pub fn is_suppression(self) -> bool {
779        matches!(
780            self,
781            Self::Noisy | Self::WrongLayer | Self::AlreadyCovered | Self::DiagnosticOnly
782        )
783    }
784}
785
786/// A single feedback record: a (query, symbol, verdict) triple stored durably.
787///
788/// When `file_scope` is set the verdict applies to all symbols from files
789/// matching that glob pattern, not just the specific symbol.  In that case
790/// `symbol_id` is a synthetic `"fs_<uuid>"` identifier and `symbol_qname` is
791/// left empty.
792#[derive(Debug, Clone, Serialize, Deserialize)]
793pub struct FeedbackEntry {
794    pub entry_id: String,
795    pub symbol_id: String,
796    pub symbol_qname: String,
797    /// Normalized (lowercase, trimmed) query that produced this result.
798    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    /// File path or glob pattern (e.g. `"App/Utility/**"`) that this verdict
805    /// applies to.  When set, the verdict is applied to all symbols whose file
806    /// path matches this pattern for queries in the same query family.
807    #[serde(skip_serializing_if = "Option::is_none")]
808    pub file_scope: Option<String>,
809    /// Plan J t-014: optional expiry. When set, the verdict is
810    /// ignored by `apply_feedback_adjustments` after this timestamp.
811    /// Useful for false-positive feedback that should auto-decay
812    /// (e.g. "this hit doesn't belong here today, but might next
813    /// quarter when the layout shifts"). `None` = persist forever
814    /// (current default behavior for backward compat).
815    #[serde(skip_serializing_if = "Option::is_none")]
816    pub expires_at: Option<DateTime<Utc>>,
817    /// When this verdict was retracted, and by whom. Set by
818    /// `asd feedback withdraw`.
819    ///
820    /// Distinct from `expires_at` on purpose — see DESIGN.md, "Feedback
821    /// withdrawal — tombstone shape". Expiry says *this was right, it is no
822    /// longer relevant*; withdrawal says *this was wrong, it should not have
823    /// been recorded*. Collapsing them would also make a withdrawal revivable
824    /// by future-dating an expiry, which it must not be.
825    #[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    /// Optional free-text reason recorded with the withdrawal.
830    #[serde(skip_serializing_if = "Option::is_none")]
831    pub withdrawn_reason: Option<String>,
832}
833
834impl FeedbackEntry {
835    /// True once this verdict has been retracted.
836    ///
837    /// Consumed by the same two ranking views as [`Self::is_expired`], and
838    /// deliberately NOT by `list_all` — a withdrawn verdict still explains
839    /// why a past search ranked as it did.
840    pub fn is_withdrawn(&self) -> bool {
841        self.withdrawn_at.is_some()
842    }
843
844    /// True when this verdict should no longer influence ranking, for any
845    /// reason. The single predicate the `flat_*` views consult, so a third
846    /// lifecycle state added later has exactly one place to register itself
847    /// rather than two call sites to remember.
848    pub fn is_inert(&self) -> bool {
849        self.is_expired() || self.is_withdrawn()
850    }
851
852    /// Plan J t-014: true when `expires_at` is set and is in the past.
853    ///
854    /// Consumed by `FeedbackStore::flat_verdicts` and
855    /// `flat_file_scope_verdicts`, the two views that feed
856    /// `apply_feedback_adjustments` — so a lapsed verdict loses its ranking
857    /// influence. `list_all` deliberately does NOT filter on this: an expired
858    /// verdict still explains why a past search ranked the way it did.
859    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    //! Plan B t-002: regression tests for the new LedgerKind variants and
867    //! optional LedgerEntry fields (role, command), and for the
868    //! LedgerKind → ConclusionClass mapping that drives JSONL export.
869
870    use super::*;
871
872    #[test]
873    fn feedback_is_expired_returns_false_when_unset() {
874        // Plan J t-014: backward-compat — entries without expires_at
875        // (the default for existing entries) must never count as
876        // expired.
877        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        // If a new variant gets added without updating conclusion_class(),
995        // the compiler-exhaustive match in conclusion_class() will catch it.
996        // This test just exercises every existing variant to make sure no
997        // bucket is empty by accident.
998        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        // Plan A/B/G DESIGN sections name these exactly. Renaming requires
1029        // a migration; lock them down with this test.
1030        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        // Plan G t-002: thinking-class bucket.
1040        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        // Lock the t-002 bucketing decision: all 4 thinking-kinds land
1047        // in ConclusionClass::Thinking, not in Classifications or
1048        // FollowUps. Failing this means the export pipeline would write
1049        // them to the wrong .asd/conclusions/*.jsonl file.
1050        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    // -- Plan C t-002: RoleTag vocabulary lock-down -------------------------
1090
1091    #[test]
1092    fn role_tag_wire_strings_match_design() {
1093        // Locks the canonical wire format to the t-001 design table.
1094        // Changing any of these is a migration.
1095        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        // Plan C t-003 expects exactly these two penalty roles.
1138        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        // And exactly these two boost-peers roles.
1146        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    //! t-001: runtime-trace → confidence derivation. Confidence is derived from
1158    //! accumulated confirmation/contradiction counts, seeded by the static prior.
1159    //! Absence of observation is handled at the classification layer (it never
1160    //! becomes a contradiction), so these tests only cover the count math.
1161
1162    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        // With no runtime observations the confidence is exactly the prior.
1171        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        // Adding a confirmation never lowers; adding a contradiction never raises.
1190        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        // A confident-but-wrong prior is dragged down by sustained contradiction.
1214        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        // And confirmations push a low prior toward 1.0.
1220        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        // Equal confirmations/contradictions wash out toward 0.5 regardless of prior.
1230        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        // New field survives a serde round-trip (the effect store serializes
1252        // EffectDecl to JSON in git + the SQLite cache).
1253        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        // Existing records written before this field must still deserialize.
1273        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}