Skip to main content

mati_core/store/
record.rs

1//! Core data types for the mati knowledge store.
2//!
3//! All types in this module are the canonical definitions used throughout
4//! every layer of mati (storage, graph, search, MCP, CLI). Do not redefine
5//! these elsewhere — import from `mati_core::store`.
6//!
7//! Key namespacing convention:
8//! ```text
9//! gotcha:<slug>          file:<path>          decision:<slug>
10//! stage:current          dep:<ecosystem>:<name> dev_note:<slug>
11//! session:<timestamp>    analytics:<type>_<date>
12//! graph:edge:<from>:<kind>:<to>
13//! ```
14//!
15//! # Float equality note
16//!
17//! Structs containing `f32` score fields (`QualityScore`, `StalenessScore`,
18//! `ConfidenceScore`, and anything that embeds them) intentionally do **not**
19//! derive `PartialEq`. Floating-point arithmetic produces values that are
20//! semantically equal but bitwise distinct, making derived `==` a footgun for
21//! computed scores. Use field-level epsilon comparison in tests and comparators.
22
23use serde::{Deserialize, Serialize};
24use serde_json::Value as JsonValue;
25use uuid::Uuid;
26
27// ─────────────────────────────────────────────
28// Primitive aliases
29// ─────────────────────────────────────────────
30
31/// UUID v7 generated once at `mati init`, stored in `~/.mati/config.toml`.
32/// Stamps every record write for Lamport-clock conflict resolution.
33///
34/// Requires the `uuid` crate with `features = ["v4", "v7"]`.
35/// NOTE: v7 generation is deferred to M-05 (`mati init`). Until then,
36/// callers use `Uuid::new_v4()` as a placeholder.
37pub type DeviceId = Uuid;
38
39// ─────────────────────────────────────────────
40// Enums — record metadata
41// ─────────────────────────────────────────────
42
43/// Which layer of mati produced this record.
44#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
45#[serde(rename_all = "snake_case")]
46pub enum RecordSource {
47    /// tree-sitter, git, dep parsing — Layer 0
48    StaticAnalysis,
49    /// `mati enrich` batch — Layer 1
50    ClaudeEnrich,
51    /// session-end harvest — Layer 2
52    SessionHook,
53    /// `mati gotcha add` / `mati note`
54    DeveloperManual,
55    /// `mati import` (CLAUDE.md or JSON)
56    Import,
57}
58
59/// Which agent issued a daemon request, used for attribution in
60/// `MutationEvent` and `Record.created_by` / `Record.last_modified_by`.
61///
62/// Client-declared, not server-verified — same-UID processes are trusted
63/// (THREAT_MODEL.md §3.C, §3.I; ADR-018). The daemon stamps `pid` from
64/// `SO_PEERCRED` separately; this enum is the human/tool side of attribution.
65#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
66#[serde(rename_all = "snake_case")]
67pub enum AgentKind {
68    /// MCP stdio client (Claude Code's rmcp transport).
69    Claude,
70    /// Codex hooks (`codex-*` variants).
71    Codex,
72    /// Direct CLI invocation by the developer.
73    Cli,
74    /// Daemon-internal operations (e.g. repair on startup, periodic
75    /// reparse). Stamped server-side, never client-declared.
76    Supervisor,
77    /// Attribution unknown or pre-v2 record.
78    Unknown,
79}
80
81/// Semantic category of a record. Determines key prefix and injection behaviour.
82#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
83#[serde(rename_all = "snake_case")]
84pub enum Category {
85    Gotcha,
86    File,
87    Decision,
88    Stage,
89    Dependency,
90    DevNote,
91    Session,
92    Analytics,
93}
94
95/// Severity / importance ranking.
96///
97/// Derived `Ord`: `Low(0) < Normal(1) < High(2) < Critical(3)`.
98///
99/// **Do not reorder variants.** The derived ordering depends on declaration
100/// position. Reordering silently inverts all priority comparisons.
101#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
102#[serde(rename_all = "snake_case")]
103pub enum Priority {
104    Low,
105    Normal,
106    High,
107    Critical,
108}
109
110// ─────────────────────────────────────────────
111// Quality scoring
112// ─────────────────────────────────────────────
113
114/// Computed tier from `QualityScore::value` (half-open intervals):
115///
116/// ```text
117/// Suppressed  [0.0, 0.2)   never injected — worse than nothing
118/// Poor        [0.2, 0.4)   injected with "[mati] LOW QUALITY — verify"
119/// Acceptable  [0.4, 0.7)   injected normally
120/// Good        [0.7, 0.9)   prioritised in bootstrap
121/// Excellent   [0.9, 1.0]   used as template in `mati garden`
122/// ```
123#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
124#[serde(rename_all = "snake_case")]
125pub enum QualityTier {
126    Suppressed,
127    Poor,
128    Acceptable,
129    Good,
130    Excellent,
131}
132
133/// Individual signals that raise or lower the computed quality score.
134#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
135#[serde(rename_all = "snake_case")]
136pub enum QualitySignal {
137    // ── Positive ────────────────────────────
138    HasImperativeVerb,
139    HasCausality,
140    HasSeveritySet,
141    HasReference,
142    RuleLengthAdequate,
143    ReasonLengthAdequate,
144    AffectedFilesSpecified,
145    HasSpecificIdentifier,
146    // ── Negative (penalties) ────────────────
147    VaguePhrasing,
148    NoActionableRule,
149    NoReason,
150    TooShort,
151    DuplicatesFilePurpose,
152}
153
154/// Composite quality score for a [`Record`].
155///
156/// Formula (ARCHITECTURE.md §5):
157/// ```text
158/// quality =
159///   has_imperative_verb  × 0.20
160///   + has_causality      × 0.25
161///   + has_severity       × 0.10
162///   + has_reference      × 0.15
163///   + length_score       × 0.15
164///   + specificity_score  × 0.15
165///
166/// penalties:
167///   vague_phrase_detected → × 0.5
168///   no_reason             → × 0.6
169///   too_short             → × 0.4
170/// ```
171/// Layer 0 `StaticAnalysis` records default to `0.10` (Suppressed).
172/// Recomputed by `RecordQualityAnalyzer` on every write and `mati enrich`.
173///
174/// Does **not** derive `PartialEq` — see module-level float equality note.
175#[derive(Serialize, Deserialize, Debug, Clone)]
176pub struct QualityScore {
177    /// 0.0 (useless) → 1.0 (Claude-optimal)
178    pub value: f32,
179    pub tier: QualityTier,
180    pub signals: Vec<QualitySignal>,
181    /// Unix timestamp (seconds) when this score was last computed.
182    /// `0` = not yet computed (sentinel).
183    pub computed_at: u64,
184}
185
186impl QualityScore {
187    /// Default for a Layer 0 `StaticAnalysis` stub — Suppressed, never injected.
188    pub fn layer0_default() -> Self {
189        Self {
190            value: 0.10,
191            tier: QualityTier::Suppressed,
192            signals: vec![],
193            computed_at: 0,
194        }
195    }
196
197    /// Quality for a file record whose purpose was extracted from a language-
198    /// canonical doc comment (Rust `//!`, Go `// Package`, Python docstring).
199    ///
200    /// `Acceptable` tier (0.40) passes the `quality >= 0.4` injection gate.
201    /// Paired with `confidence = 0.45` in `init.rs`, these records surface as
202    /// `additionalContext` (allow + attach) rather than deny + inject.
203    pub fn doc_comment_default() -> Self {
204        Self {
205            value: 0.40,
206            tier: QualityTier::Acceptable,
207            signals: vec![],
208            computed_at: 0,
209        }
210    }
211
212    /// Quality for an auto-generated co-change gotcha (normal signal).
213    ///
214    /// `Acceptable` tier (0.40): passes quality gate.
215    /// Paired with `confidence = 0.45` (0.3–0.6 band) → additionalContext injection.
216    /// `confirmed: true` is set on the gotcha because co-change is objective git data,
217    /// but the confidence band keeps it out of the deny+inject path.
218    /// Quality for a developer-manually-added record (`mati gotcha add`, `mati note`).
219    ///
220    /// `Good` tier (0.65): developer is explicitly asserting the record is important.
221    /// Paired with `DeveloperManual` confidence (0.80) + `confirmed=true` → deny+inject path.
222    pub fn developer_entry_default() -> Self {
223        Self {
224            value: 0.65,
225            tier: QualityTier::Good,
226            signals: vec![],
227            computed_at: 0,
228        }
229    }
230
231    pub fn cochange_default() -> Self {
232        Self {
233            value: 0.40,
234            tier: QualityTier::Acceptable,
235            signals: vec![],
236            computed_at: 0,
237        }
238    }
239
240    /// Quality for a strong co-change gotcha (ratio >= 0.90 AND count >= 20).
241    ///
242    /// `Acceptable` tier (0.60): passes quality gate.
243    /// Paired with `confidence = 0.65` → deny+inject path.
244    /// A near-perfect co-change ratio over 20+ commits is strong enough evidence
245    /// that Claude should be forced to see the coupling before editing either file.
246    pub fn cochange_strong() -> Self {
247        Self {
248            value: 0.60,
249            tier: QualityTier::Acceptable,
250            signals: vec![],
251            computed_at: 0,
252        }
253    }
254
255    /// Derive `QualityTier` from a raw score value (half-open intervals).
256    ///
257    /// ```text
258    /// [0.0, 0.2) → Suppressed
259    /// [0.2, 0.4) → Poor
260    /// [0.4, 0.7) → Acceptable
261    /// [0.7, 0.9) → Good
262    /// [0.9, 1.0] → Excellent
263    /// ```
264    pub fn tier_from_value(value: f32) -> QualityTier {
265        // Non-finite values (NaN, ±∞) would pass all comparisons silently
266        // and land in the else-Excellent branch — a hook-injection security bug.
267        if !value.is_finite() || value < 0.2 {
268            QualityTier::Suppressed
269        } else if value < 0.4 {
270            QualityTier::Poor
271        } else if value < 0.7 {
272            QualityTier::Acceptable
273        } else if value < 0.9 {
274            QualityTier::Good
275        } else {
276            QualityTier::Excellent
277        }
278    }
279}
280
281// ─────────────────────────────────────────────
282// Staleness scoring
283// ─────────────────────────────────────────────
284
285/// Staleness tier — determines injection and hook behaviour.
286///
287/// At `Tombstone`: PreToolUse allows file reads through unconditionally.
288/// Record excluded from injection entirely. Trusting a wrong record is a
289/// worse failure mode than a cache miss (ARCHITECTURE.md §17).
290///
291/// Sync merge rule: `Tombstone > Liability > Stale > Aging > Fresh`.
292#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
293#[serde(rename_all = "snake_case")]
294pub enum StalenessTier {
295    Fresh,
296    Aging,
297    Stale,
298    /// Blocks injection; injected into PreToolUse as a warning.
299    Liability,
300    /// Record fully excluded. Hook passes file reads through unconditionally.
301    Tombstone,
302}
303
304/// Individual signals that feed the staleness composite score.
305///
306/// Derives `PartialEq` (not `Eq`) because `LinesChangedPct(f32)` contains f32.
307#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
308#[serde(rename_all = "snake_case")]
309pub enum StalenessSignal {
310    NotAccessedDays(u32),
311    /// Percentage of lines changed since last confirmation (0.0–1.0).
312    LinesChangedPct(f32),
313    EntryPointsChanged(u32),
314    ImportsChanged(u32),
315    FileDeleted,
316    FileRenamed {
317        new_path: String,
318    },
319    DependencyBumped {
320        dep: String,
321        old_ver: String,
322        new_ver: String,
323    },
324    LinkedFileChanged {
325        path: String,
326    },
327    /// Another decision or gotcha this record depends on was modified.
328    CascadeFromDecision(String),
329    /// TODOs were added, removed, or changed.
330    TodosChanged,
331    /// Net change in `unsafe` block count (positive = added, negative = removed).
332    UnsafeCountChanged(i32),
333    /// Net change in `.unwrap()` call count (positive = added, negative = removed).
334    UnwrapCountChanged(i32),
335    /// Number of commits touching this file since last staleness confirmation.
336    GitCommitsSince(u32),
337}
338
339impl std::fmt::Display for StalenessSignal {
340    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
341        match self {
342            Self::NotAccessedDays(d) => write!(f, "not accessed for {d} days"),
343            Self::LinesChangedPct(pct) => write!(f, "{:.0}% of lines changed", pct * 100.0),
344            Self::EntryPointsChanged(n) => write!(f, "{n} entry points changed"),
345            Self::ImportsChanged(n) => write!(f, "{n} imports changed"),
346            Self::FileDeleted => write!(f, "source file deleted"),
347            Self::FileRenamed { new_path } => write!(f, "file renamed to {new_path}"),
348            Self::DependencyBumped {
349                dep,
350                old_ver,
351                new_ver,
352            } => write!(f, "{dep} bumped {old_ver} \u{2192} {new_ver}"),
353            Self::LinkedFileChanged { path } => write!(f, "linked file {path} changed"),
354            Self::CascadeFromDecision(key) => write!(f, "cascaded from {key}"),
355            Self::TodosChanged => write!(f, "TODOs changed"),
356            Self::UnsafeCountChanged(delta) => write!(f, "unsafe count changed by {delta}"),
357            Self::UnwrapCountChanged(delta) => write!(f, "unwrap count changed by {delta}"),
358            Self::GitCommitsSince(n) => write!(f, "{n} commits since last confirmation"),
359        }
360    }
361}
362
363/// Replaces the flat `stale: bool` with a scored, tiered system.
364///
365/// Formula (ARCHITECTURE.md §17):
366/// ```text
367/// staleness =
368///   time_factor       × 0.20
369///   + git_factor      × 0.35
370///   + semantic_factor × 0.25
371///   + dep_factor      × 0.10
372///   + cascade_factor  × 0.10
373/// ```
374/// Hard overrides:
375/// - `FileDeleted`  → `Tombstone` (1.0)
376/// - `FileRenamed`  → `Liability` (0.85) until path is corrected
377///
378/// Does **not** derive `PartialEq` — see module-level float equality note.
379#[derive(Serialize, Deserialize, Debug, Clone)]
380pub struct StalenessScore {
381    /// 0.0 (completely fresh) → 1.0 (tombstone)
382    pub value: f32,
383    pub tier: StalenessTier,
384    pub signals: Vec<StalenessSignal>,
385    /// Unix timestamp (seconds) when this score was last computed.
386    /// `0` = not yet computed (sentinel).
387    pub computed_at: u64,
388    /// Git SHA of the source file at the time this record was last confirmed.
389    /// Empty string = not yet established.
390    pub last_record_sha: String,
391}
392
393impl StalenessScore {
394    /// Fresh record with no signals — used when a record is first created.
395    pub fn fresh() -> Self {
396        Self {
397            value: 0.0,
398            tier: StalenessTier::Fresh,
399            signals: vec![],
400            computed_at: 0,
401            last_record_sha: String::new(),
402        }
403    }
404
405    /// Derive `StalenessTier` from a raw score value (half-open intervals).
406    ///
407    /// ```text
408    /// [0.0, 0.2) → Fresh
409    /// [0.2, 0.4) → Aging
410    /// [0.4, 0.7) → Stale
411    /// [0.7, 0.9) → Liability
412    /// [0.9, 1.0] → Tombstone
413    /// ```
414    pub fn tier_from_value(value: f32) -> StalenessTier {
415        if !value.is_finite() {
416            return StalenessTier::Stale;
417        }
418        if value < 0.2 {
419            StalenessTier::Fresh
420        } else if value < 0.4 {
421            StalenessTier::Aging
422        } else if value < 0.7 {
423            StalenessTier::Stale
424        } else if value < 0.9 {
425            StalenessTier::Liability
426        } else {
427            StalenessTier::Tombstone
428        }
429    }
430}
431
432// ─────────────────────────────────────────────
433// Confidence scoring
434// ─────────────────────────────────────────────
435
436/// How much the system trusts this record's accuracy.
437///
438/// Formula (ARCHITECTURE.md §13.1):
439/// ```text
440/// base_score:
441///   DeveloperManual → 0.80
442///   Import          → 0.70
443///   ClaudeEnrich    → 0.60
444///   SessionHook     → 0.50
445///   StaticAnalysis  → 0.10
446///
447/// confidence = base_score
448///   × log2(confirmation_count + 2)
449///   × min(contributor_count, 3) / 3
450///   × recency_weight(last_accessed)   90-day half-life
451///   × ref_boost                       1.5× if ref_url set
452/// ```
453/// Recomputed on every `mem_get`, written back with `Durability::Eventual`.
454///
455/// Hook injection thresholds:
456/// ```text
457/// >= 0.6 + confirmed  → deny file read, inject record
458/// 0.3 – 0.6           → allow read + attach as additionalContext
459/// < 0.3               → allow read, no injection
460/// ```
461///
462/// Does **not** derive `PartialEq` — see module-level float equality note.
463#[derive(Serialize, Deserialize, Debug, Clone)]
464pub struct ConfidenceScore {
465    /// 0.0 → 1.0
466    pub value: f32,
467    /// How many times this record has been explicitly confirmed correct.
468    pub confirmation_count: u32,
469    /// How many distinct contributors have written or confirmed this record.
470    pub contributor_count: u32,
471    /// Unix timestamp of the last time this record was challenged or disputed.
472    pub last_challenged: Option<u64>,
473    pub challenge_count: u32,
474}
475
476impl ConfidenceScore {
477    /// Initial confidence value for a freshly created record by source type.
478    pub fn base_for_source(source: &RecordSource) -> f32 {
479        match source {
480            RecordSource::DeveloperManual => 0.80,
481            RecordSource::Import => 0.70,
482            RecordSource::ClaudeEnrich => 0.60,
483            RecordSource::SessionHook => 0.50,
484            RecordSource::StaticAnalysis => 0.10,
485        }
486    }
487
488    /// Construct a [`ConfidenceScore`] for a newly created record.
489    ///
490    /// Sets `value` from `base_for_source` and zeros all counters. Use this
491    /// instead of constructing manually to prevent `value` from diverging from
492    /// the source-derived base.
493    pub fn for_new_record(source: &RecordSource) -> Self {
494        Self {
495            value: Self::base_for_source(source),
496            confirmation_count: 0,
497            contributor_count: 1,
498            last_challenged: None,
499            challenge_count: 0,
500        }
501    }
502}
503
504// ─────────────────────────────────────────────
505// Record lifecycle
506// ─────────────────────────────────────────────
507
508/// Why a record was tombstoned.
509#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
510#[serde(rename_all = "snake_case")]
511pub enum TombstoneReason {
512    FileDeleted,
513    FileRenamed { new_path: String },
514    ManualDeletion,
515    Superseded,
516}
517
518/// Current lifecycle state of a record.
519///
520/// Sync merge rule: `Tombstoned > Superseded > Active` (severity wins).
521#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
522#[serde(rename_all = "snake_case")]
523pub enum RecordLifecycle {
524    Active,
525    Tombstoned { reason: TombstoneReason, at: u64 },
526    Superseded { by_key: String },
527}
528
529// ─────────────────────────────────────────────
530// Sync / versioning
531// ─────────────────────────────────────────────
532
533/// Lamport clock + wall clock per record write.
534///
535/// Wall clock is **never** used for conflict ordering — only for display.
536/// All ordering uses `logical_clock` (see ARCHITECTURE.md §20).
537#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
538pub struct RecordVersion {
539    /// UUID v7, generated once per device at `mati init`.
540    pub device_id: DeviceId,
541    /// Lamport clock — incremented on every local write.
542    pub logical_clock: u64,
543    /// Wall clock at time of write — display only, never for conflict ordering.
544    pub wall_clock: u64,
545}
546
547// ─────────────────────────────────────────────
548// Universal record
549// ─────────────────────────────────────────────
550
551/// The universal store entry. All categories (gotcha, file, decision, …)
552/// share this struct. Category-specific detail is in `value` (human-readable)
553/// and in the typed `FileRecord` / `GotchaRecord` for Layer 0/1 fast paths.
554///
555/// Does **not** derive `PartialEq` — see module-level float equality note.
556///
557/// Key namespacing:
558/// ```text
559/// gotcha:<slug>     file:<path>     decision:<slug>
560/// stage:current     dep:<ecosystem>:<name> dev_note:<slug>
561/// ```
562#[derive(Serialize, Deserialize, Debug, Clone)]
563pub struct Record {
564    /// Namespaced key — primary storage identifier and graph node key.
565    pub key: String,
566    /// Human-readable content: purpose (file), rule (gotcha), body (decision).
567    /// Indexed by tantivy for full-text search.
568    pub value: String,
569    pub category: Category,
570    pub priority: Priority,
571    /// Free-form tags for search and filtering.
572    pub tags: Vec<String>,
573    /// Unix timestamp (seconds) when this record was first created.
574    pub created_at: u64,
575    /// Unix timestamp (seconds) of the last write.
576    pub updated_at: u64,
577    /// URL to a PR, issue, doc, or incident that explains this record.
578    pub ref_url: Option<String>,
579    pub staleness: StalenessScore,
580    pub lifecycle: RecordLifecycle,
581    /// Versioning for Lamport-clock conflict resolution (see [`RecordVersion`]).
582    /// Use `record.version.device_id` to identify the authoring device.
583    pub version: RecordVersion,
584    pub quality: QualityScore,
585    /// How many times this record has been read via `mem_get` or hooks.
586    pub access_count: u32,
587    /// Unix timestamp (seconds) of the last access.
588    pub last_accessed: u64,
589    pub source: RecordSource,
590    pub confidence: ConfidenceScore,
591    /// Pre-computed gap risk score: `change_frequency × (1 - coverage_score)`.
592    pub gap_analysis_score: f32,
593    /// Structured per-category payload — typed data in JSON form.
594    ///
595    /// - `file:*`     → `FileRecord`
596    /// - `gotcha:*`   → `GotchaRecord`
597    /// - `decision:*` → serialized decision body (TBD Layer 1)
598    /// - `analytics:*`, `session:*` → arbitrary JSON blob (DailyAgg, StaleReviewPayload, …)
599    ///
600    /// `value` is always the human-readable text: rule, purpose, body.
601    /// `payload` carries all structured fields so read sites never parse `value` as JSON.
602    /// Stored as-is in MessagePack (serde_json::Value → msgpack map).
603    #[serde(default)]
604    pub payload: Option<JsonValue>,
605}
606
607impl Record {
608    /// The device that last wrote this record.
609    ///
610    /// Convenience accessor — delegates to `self.version.device_id`.
611    pub fn device_id(&self) -> DeviceId {
612        self.version.device_id
613    }
614
615    /// Deserialize the structured payload into a typed value.
616    ///
617    /// Returns `None` when `payload` is absent or the JSON shape does not match `T`.
618    /// Always prefer this over `serde_json::from_str(&self.value)`.
619    pub fn payload_as<T: serde::de::DeserializeOwned>(&self) -> Option<T> {
620        self.payload
621            .as_ref()
622            .and_then(|p| serde_json::from_value(p.clone()).ok())
623    }
624
625    /// Construct a layer-0 file stub for `file:<path>`.
626    ///
627    /// This is the persisted companion to [`FileRecord::layer0_stub`].
628    /// Layer 0 file records start empty on purpose/value, but still get the
629    /// suppressed quality default so they never surface in Claude-facing
630    /// injection paths until enrichment raises them.
631    pub fn layer0_file_stub(
632        key: impl Into<String>,
633        device_id: DeviceId,
634        logical_clock: u64,
635        wall_clock: u64,
636    ) -> Self {
637        Self {
638            key: key.into(),
639            value: String::new(),
640            category: Category::File,
641            priority: Priority::Normal,
642            tags: vec![],
643            created_at: wall_clock,
644            updated_at: wall_clock,
645            ref_url: None,
646            staleness: StalenessScore::fresh(),
647            lifecycle: RecordLifecycle::Active,
648            version: RecordVersion {
649                device_id,
650                logical_clock,
651                wall_clock,
652            },
653            quality: QualityScore::layer0_default(),
654            access_count: 0,
655            last_accessed: 0,
656            source: RecordSource::StaticAnalysis,
657            confidence: ConfidenceScore::for_new_record(&RecordSource::StaticAnalysis),
658            gap_analysis_score: 0.0,
659            payload: None,
660        }
661    }
662}
663
664// ─────────────────────────────────────────────
665// File record
666// ─────────────────────────────────────────────
667
668/// Kind of inline developer comment extracted by tree-sitter.
669#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
670#[serde(rename_all = "snake_case")]
671pub enum TodoKind {
672    Todo,
673    Fixme,
674    Hack,
675    Note,
676    Deprecated,
677}
678
679/// A TODO/FIXME/HACK comment extracted from source code by tree-sitter.
680#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
681pub struct TodoComment {
682    pub text: String,
683    pub line: u32,
684    pub kind: TodoKind,
685}
686
687/// Per-file knowledge — stored under `file:<path>`, linked to `gotcha:*`
688/// and `decision:*` via graph edges.
689///
690/// Does **not** derive `PartialEq` — contains `token_cost_estimate` which
691/// may be computed and is not meaningful to compare directly.
692#[derive(Serialize, Deserialize, Debug, Clone)]
693pub struct FileRecord {
694    pub path: String,
695    /// One-sentence purpose extracted by Layer 1 enrichment. Empty at Layer 0.
696    pub purpose: String,
697    /// Public functions / types / entry points visible from other modules.
698    pub entry_points: Vec<String>,
699    /// Import / use paths found by tree-sitter.
700    pub imports: Vec<String>,
701    /// Keys of associated `gotcha:*` records.
702    pub gotcha_keys: Vec<String>,
703    /// Keys of associated `decision:*` records.
704    pub decision_keys: Vec<String>,
705    pub todos: Vec<TodoComment>,
706    pub unsafe_count: u32,
707    pub unwrap_count: u32,
708    /// Commit count touching this file (from git2, capped at 5 000 most recent non-merge commits).
709    pub change_frequency: u32,
710    pub last_author: Option<String>,
711    /// True when `change_frequency` puts this file in the top 10% of the repo.
712    pub is_hotspot: bool,
713    /// Rough token count estimate for `mem_bootstrap` budget enforcement.
714    pub token_cost_estimate: u32,
715    /// Session timestamp of the last time this record was updated.
716    pub last_modified_session: u64,
717    /// SHA-256 hex digest of file content at the time of last Layer 0 scan.
718    /// `None` for non-parseable files or the first scan (no stored baseline).
719    #[serde(default)]
720    pub content_hash: Option<String>,
721    /// Newline count at last scan (≈ line count). 0 for non-parseable files.
722    #[serde(default)]
723    pub line_count: u32,
724    /// Blast radius — how many files depend on this one (direct + transitive).
725    /// Computed during `mati init` Phase 10a from Imports edges in the graph.
726    /// `None` for stores created before blast radius was introduced.
727    #[serde(default)]
728    pub blast_radius: Option<crate::analysis::blast_radius::BlastRadius>,
729    /// Staleness inherited from upstream stale sources via Imports edges.
730    /// `None` for stores created before staleness propagation was introduced.
731    #[serde(default)]
732    pub propagated_staleness: Option<crate::analysis::propagation::PropagatedStaleness>,
733}
734
735impl FileRecord {
736    /// Construct a layer-0 file stub from static-analysis signals.
737    ///
738    /// `purpose`, `gotcha_keys`, and `decision_keys` intentionally start empty.
739    /// The Layer 0 pipeline only records structural facts; enrichment fills in
740    /// the human-readable purpose later.
741    #[allow(clippy::too_many_arguments)]
742    pub fn layer0_stub(
743        path: impl Into<String>,
744        entry_points: Vec<String>,
745        imports: Vec<String>,
746        todos: Vec<TodoComment>,
747        unsafe_count: u32,
748        unwrap_count: u32,
749        change_frequency: u32,
750        last_author: Option<String>,
751        is_hotspot: bool,
752        token_cost_estimate: u32,
753        last_modified_session: u64,
754    ) -> Self {
755        Self {
756            path: path.into(),
757            purpose: String::new(),
758            entry_points,
759            imports,
760            gotcha_keys: vec![],
761            decision_keys: vec![],
762            todos,
763            unsafe_count,
764            unwrap_count,
765            change_frequency,
766            last_author,
767            is_hotspot,
768            token_cost_estimate,
769            last_modified_session,
770            content_hash: None,
771            line_count: 0,
772            blast_radius: None,
773            propagated_staleness: None,
774        }
775    }
776}
777
778// ─────────────────────────────────────────────
779// Gotcha record
780// ─────────────────────────────────────────────
781
782/// A confirmed (or candidate) gotcha — a non-obvious rule that Claude must
783/// know before reading or editing the associated file(s).
784///
785/// `confirmed: false` = Layer 0 candidate stub. Never injected.
786/// `confirmed: true` + `confidence >= 0.6` + `quality >= 0.4`
787///   → pre-read hook denies the file read and injects this record instead.
788///
789/// Does **not** derive `PartialEq` — embedded via `Record` which carries scores.
790#[derive(Serialize, Deserialize, Debug, Clone)]
791pub struct GotchaRecord {
792    /// The actionable rule. Must start with an imperative verb for Good quality.
793    pub rule: String,
794    /// Why this rule exists. Causality sentence.
795    pub reason: String,
796    pub severity: Priority,
797    #[serde(default)]
798    pub affected_files: Vec<String>,
799    #[serde(default)]
800    pub ref_url: Option<String>,
801    /// Timestamp of the session in which this gotcha was first discovered.
802    #[serde(default)]
803    pub discovered_session: u64,
804    /// Whether a developer has explicitly confirmed this record is accurate.
805    /// Layer 0 stubs are always `false` until confirmed via `mati gotcha add`.
806    #[serde(default)]
807    pub confirmed: bool,
808}
809
810// ─────────────────────────────────────────────
811// Stale review (M-13-C)
812// ─────────────────────────────────────────────
813
814/// A single entry in a stale-review session payload.
815///
816/// Surfaced to Claude via `mem_bootstrap` stale warnings section.
817/// Stored inside `StaleReviewPayload` in `session:<ts>` records.
818#[derive(Serialize, Deserialize, Debug, Clone)]
819pub struct StaleReviewEntry {
820    pub key: String,
821    pub staleness_value: f32,
822    pub tier: StalenessTier,
823    pub last_updated: u64,
824    pub signals: Vec<String>,
825}
826
827/// Payload written to `session:<ts>` after a stale-review pass.
828#[derive(Serialize, Deserialize, Debug, Clone)]
829pub struct StaleReviewPayload {
830    pub session_timestamp: u64,
831    pub entries: Vec<StaleReviewEntry>,
832}
833
834// ─────────────────────────────────────────────
835// Knowledge gaps
836// ─────────────────────────────────────────────
837
838/// Classification of why a knowledge gap exists.
839///
840/// Computed by `KnowledgeGapAnalyzer` — async, post-session, non-blocking.
841/// Gap severity formula: `change_frequency × (1 - coverage_score)`.
842#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
843#[serde(rename_all = "snake_case")]
844pub enum GapType {
845    /// Hot file with no record at all.
846    HotFileNoRecord,
847    /// Hot file has a record but `purpose` is empty.
848    HotFileNoPurpose,
849    /// Hot file has no associated `gotcha:*` records.
850    HotFileNoGotchas,
851    /// File read frequently by Claude but never enriched past Layer 0.
852    FrequentlyReadNoEnrich,
853    /// A `decision:*` record with no `affected_files`.
854    OrphanedDecision,
855    /// A `dep:*` record with no confirmed gotchas.
856    DependencyUnknown,
857    /// Two files co-change in >70% of commits but have no explicit graph edge.
858    CoChangePairUnmapped,
859    /// Hot file's record hasn't been updated since a significant refactor.
860    StaleHotspot,
861    /// Hotspot file with no corresponding test file detected in the repo.
862    HotFileNoTests,
863    /// File imported by many others but has no gotchas or decisions documented.
864    HighFanInNoContract,
865}
866
867/// A detected knowledge gap with risk score and suggested resolution action.
868///
869/// Does **not** derive `PartialEq` — `risk_score` is a computed f32.
870#[derive(Serialize, Deserialize, Debug, Clone)]
871pub struct KnowledgeGap {
872    /// Namespaced key of the file, dep, or decision with the gap.
873    pub key: String,
874    pub gap_type: GapType,
875    /// Computed risk score: `change_frequency × (1 - coverage_score)`.
876    pub risk_score: f32,
877    pub description: String,
878    /// Suggested `mati` CLI command to resolve the gap.
879    pub action_hint: String,
880}
881
882// ─────────────────────────────────────────────
883// Context packet (mem_bootstrap output)
884// ─────────────────────────────────────────────
885
886/// What `mem_bootstrap()` returns to Claude. Token-budgeted to 2,000 tokens.
887///
888/// Assembly order (ARCHITECTURE.md §6):
889/// 1. Resolve `context_files` to graph nodes
890/// 2. Traverse `HasGotcha` edges — direct gotchas for each file
891/// 3. Traverse `Imports` one hop — gotchas for imported files
892/// 4. Traverse `AffectedBy` edges — relevant architectural decisions
893/// 5. Token-budget the result to 2,000 tokens
894/// 6. Sort gotchas by `confidence × severity`
895///
896/// The MCP tool returns `injection_string` as the top-level tool result text.
897/// The full struct is used internally for structured rendering and debugging.
898///
899/// Does **not** derive `PartialEq` — transitively contains f32 score fields.
900#[derive(Serialize, Deserialize, Debug, Clone)]
901pub struct ContextPacket {
902    /// Current `stage:current` record, if set.
903    pub stage: Option<Record>,
904    /// Gotchas sorted by `confidence × severity`. Only `confirmed: true` records.
905    /// Type is [`Record`] (not `GotchaRecord`) — the base record is the storage
906    /// unit. `mem_bootstrap` callers must look up the typed detail via
907    /// `mati_core::store::GotchaRecord` when the rule/reason fields are needed.
908    pub critical_gotchas: Vec<Record>,
909    /// File records for the requested context files.
910    pub file_records: Vec<FileRecord>,
911    /// Decision records reached via `AffectedBy` graph traversal.
912    pub related_decisions: Vec<Record>,
913    /// Plain-text summary of the last session (from `session-harvest`).
914    pub recent_session: Option<String>,
915    /// Estimated token count of this packet.
916    pub token_estimate: u32,
917    /// Human-readable staleness warnings for records approaching Liability tier.
918    pub stale_warnings: Vec<String>,
919    /// Keys of `confirmed: false` Layer 0 stubs surfaced for developer review.
920    pub unconfirmed_candidates: Vec<String>,
921    /// Top knowledge gaps ranked by risk score.
922    pub knowledge_gaps: Vec<KnowledgeGap>,
923    /// Compliance rate for the last 7 days. Present only when < 0.85.
924    pub compliance_rate: Option<f32>,
925    /// Pre-formatted markdown string returned as the MCP tool result text.
926    pub injection_string: String,
927}
928
929// ─────────────────────────────────────────────
930// Health / onboarding
931// ─────────────────────────────────────────────
932
933/// Onboarding time estimate based on current knowledge coverage.
934///
935/// Formula (ARCHITECTURE.md §13.3):
936/// ```text
937/// base_time = 22 minutes
938///
939/// reduction_factors:
940///   hotspot_coverage  × 0.40
941///   gotcha_coverage   × 0.25
942///   decision_coverage × 0.15
943///   confidence_weight × 0.20
944///
945/// estimated_minutes = base_time × (1 - weighted_reduction)
946/// ```
947/// Stored as `analytics:onboarding_score` with `Durability::Eventual`.
948///
949/// Does **not** derive `PartialEq` — all fields are computed f32 values.
950#[derive(Serialize, Deserialize, Debug, Clone)]
951pub struct OnboardingScore {
952    pub estimated_minutes: f32,
953    /// Fraction of hotspot files with a non-empty purpose (0.0–1.0).
954    pub critical_files_covered: f32,
955    /// Fraction of hotspot files with ≥1 confirmed gotcha (0.0–1.0).
956    pub gotcha_coverage: f32,
957    /// Fraction of architectural decisions documented (0.0–1.0).
958    pub decision_coverage: f32,
959    /// Average confidence across all confirmed records.
960    pub avg_confidence: f32,
961    pub computed_at: u64,
962}
963
964// ─────────────────────────────────────────────
965// Tests
966// ─────────────────────────────────────────────
967
968#[cfg(test)]
969mod tests {
970    use super::*;
971
972    // ── Helpers ─────────────────────────────────────────────────────────────
973
974    fn device_id() -> DeviceId {
975        Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap()
976    }
977
978    fn sample_record() -> Record {
979        Record {
980            key: "gotcha:inference-async".to_string(),
981            value: "Never call .await inside a rayon::spawn closure — it panics.".to_string(),
982            category: Category::Gotcha,
983            priority: Priority::Critical,
984            tags: vec!["async".to_string(), "rayon".to_string()],
985            created_at: 1_710_520_800,
986            updated_at: 1_710_520_800,
987            ref_url: Some("https://github.com/example/issue/42".to_string()),
988            staleness: StalenessScore::fresh(),
989            lifecycle: RecordLifecycle::Active,
990            version: RecordVersion {
991                device_id: device_id(),
992                logical_clock: 1,
993                wall_clock: 1_710_520_800,
994            },
995            quality: QualityScore {
996                value: 0.85,
997                tier: QualityTier::Good,
998                signals: vec![
999                    QualitySignal::HasImperativeVerb,
1000                    QualitySignal::HasCausality,
1001                ],
1002                computed_at: 1_710_520_800,
1003            },
1004            access_count: 0,
1005            last_accessed: 0,
1006            source: RecordSource::DeveloperManual,
1007            confidence: ConfidenceScore::for_new_record(&RecordSource::DeveloperManual),
1008            gap_analysis_score: 0.0,
1009            payload: None,
1010        }
1011    }
1012
1013    fn sample_file_record() -> FileRecord {
1014        FileRecord {
1015            path: "src/store/db.rs".to_string(),
1016            purpose: "Initialises SurrealKV trees and exposes the Store handle.".to_string(),
1017            entry_points: vec!["Store::open".to_string()],
1018            imports: vec!["surrealkv".to_string()],
1019            gotcha_keys: vec!["gotcha:inference-async".to_string()],
1020            decision_keys: vec![],
1021            todos: vec![TodoComment {
1022                text: "add fsync benchmark".to_string(),
1023                line: 42,
1024                kind: TodoKind::Todo,
1025            }],
1026            unsafe_count: 0,
1027            unwrap_count: 1,
1028            change_frequency: 12,
1029            last_author: Some("ioni".to_string()),
1030            is_hotspot: false,
1031            token_cost_estimate: 180,
1032            last_modified_session: 1_710_520_800,
1033            content_hash: None,
1034            line_count: 0,
1035            blast_radius: None,
1036            propagated_staleness: None,
1037        }
1038    }
1039
1040    fn sample_context_packet() -> ContextPacket {
1041        ContextPacket {
1042            stage: None,
1043            critical_gotchas: vec![sample_record()],
1044            file_records: vec![sample_file_record()],
1045            related_decisions: vec![],
1046            recent_session: Some(
1047                "Implemented storage layer. SurrealKV tree opened cleanly.".to_string(),
1048            ),
1049            token_estimate: 420,
1050            stale_warnings: vec![],
1051            unconfirmed_candidates: vec!["file:src/analysis/walker.rs".to_string()],
1052            knowledge_gaps: vec![KnowledgeGap {
1053                key: "file:src/analysis/parser.rs".to_string(),
1054                gap_type: GapType::HotFileNoGotchas,
1055                risk_score: 0.72,
1056                description: "Hot file with 23 commits in 60d and no gotchas".to_string(),
1057                action_hint: "mati gotcha add src/analysis/parser.rs".to_string(),
1058            }],
1059            compliance_rate: None,
1060            injection_string: String::new(),
1061        }
1062    }
1063
1064    /// Round-trip helper: serialise, deserialise, re-serialise and compare
1065    /// JSON strings. This avoids relying on `PartialEq` for f32-containing
1066    /// types while still fully exercising the serde impls.
1067    fn assert_serde_roundtrip<T>(value: &T)
1068    where
1069        T: Serialize + for<'de> Deserialize<'de>,
1070    {
1071        let json1 = serde_json::to_string(value).expect("serialization failed");
1072        let restored: T = serde_json::from_str(&json1).expect("deserialization failed");
1073        let json2 = serde_json::to_string(&restored).expect("re-serialization failed");
1074        assert_eq!(json1, json2, "serde round-trip produced different JSON");
1075    }
1076
1077    // ── Round-trip tests ─────────────────────────────────────────────────────
1078
1079    #[test]
1080    fn record_serde_roundtrip() {
1081        assert_serde_roundtrip(&sample_record());
1082    }
1083
1084    #[test]
1085    fn file_record_serde_roundtrip() {
1086        assert_serde_roundtrip(&sample_file_record());
1087    }
1088
1089    /// Old stores serialized FileRecord without the `blast_radius` field.
1090    /// `#[serde(default)]` on the field must make deserialization succeed
1091    /// with `blast_radius == None`.
1092    #[test]
1093    fn file_record_backward_compat_no_blast_radius() {
1094        let json = r#"{
1095            "path": "src/main.rs",
1096            "purpose": "Entry point",
1097            "entry_points": ["main"],
1098            "imports": [],
1099            "gotcha_keys": [],
1100            "decision_keys": [],
1101            "todos": [],
1102            "unsafe_count": 0,
1103            "unwrap_count": 0,
1104            "change_frequency": 5,
1105            "last_author": "dev",
1106            "is_hotspot": false,
1107            "token_cost_estimate": 100,
1108            "last_modified_session": 1710520800
1109        }"#;
1110        let fr: FileRecord = serde_json::from_str(json).unwrap();
1111        assert!(fr.blast_radius.is_none());
1112        assert_eq!(fr.path, "src/main.rs");
1113        assert_eq!(fr.content_hash, None);
1114        assert_eq!(fr.line_count, 0);
1115    }
1116
1117    #[test]
1118    fn gotcha_record_serde_roundtrip() {
1119        let gotcha = GotchaRecord {
1120            rule: "Never hold a write transaction across an await point.".to_string(),
1121            reason: "SurrealKV write txns are not Send; the future will not compile.".to_string(),
1122            severity: Priority::Critical,
1123            affected_files: vec!["src/store/db.rs".to_string()],
1124            ref_url: Some("https://github.com/example/issue/99".to_string()),
1125            discovered_session: 1_710_520_800,
1126            confirmed: true,
1127        };
1128        assert_serde_roundtrip(&gotcha);
1129    }
1130
1131    #[test]
1132    fn context_packet_serde_roundtrip() {
1133        assert_serde_roundtrip(&sample_context_packet());
1134    }
1135
1136    // ── Lifecycle & tombstone serde ──────────────────────────────────────────
1137
1138    #[test]
1139    fn record_lifecycle_tombstoned_serde() {
1140        let lifecycle = RecordLifecycle::Tombstoned {
1141            reason: TombstoneReason::FileDeleted,
1142            at: 1_710_520_800,
1143        };
1144        assert_serde_roundtrip(&lifecycle);
1145    }
1146
1147    #[test]
1148    fn record_lifecycle_superseded_serde() {
1149        let lifecycle = RecordLifecycle::Superseded {
1150            by_key: "gotcha:inference-async-v2".to_string(),
1151        };
1152        assert_serde_roundtrip(&lifecycle);
1153    }
1154
1155    #[test]
1156    fn tombstone_reason_file_renamed_serde() {
1157        let reason = TombstoneReason::FileRenamed {
1158            new_path: "src/store/backend.rs".to_string(),
1159        };
1160        assert_serde_roundtrip(&reason);
1161    }
1162
1163    // ── Staleness signal serde ───────────────────────────────────────────────
1164
1165    #[test]
1166    fn staleness_signal_dependency_bumped_serde() {
1167        let signal = StalenessSignal::DependencyBumped {
1168            dep: "tokio".to_string(),
1169            old_ver: "1.40".to_string(),
1170            new_ver: "1.50".to_string(),
1171        };
1172        assert_serde_roundtrip(&signal);
1173    }
1174
1175    #[test]
1176    fn staleness_signal_file_renamed_serde() {
1177        let signal = StalenessSignal::FileRenamed {
1178            new_path: "src/store/backend.rs".to_string(),
1179        };
1180        assert_serde_roundtrip(&signal);
1181    }
1182
1183    #[test]
1184    fn staleness_signal_cascade_serde() {
1185        let signal = StalenessSignal::CascadeFromDecision("decision:storage-engine".to_string());
1186        assert_serde_roundtrip(&signal);
1187    }
1188
1189    #[test]
1190    fn staleness_score_fresh_default() {
1191        let s = StalenessScore::fresh();
1192        assert_eq!(s.tier, StalenessTier::Fresh);
1193        assert_eq!(s.value, 0.0);
1194        assert!(s.signals.is_empty());
1195        assert_eq!(s.computed_at, 0, "0 = not yet computed sentinel");
1196        assert!(s.last_record_sha.is_empty());
1197    }
1198
1199    // ── Quality tier thresholds ──────────────────────────────────────────────
1200
1201    #[test]
1202    fn quality_tier_ranges() {
1203        assert_eq!(QualityScore::tier_from_value(0.00), QualityTier::Suppressed);
1204        assert_eq!(QualityScore::tier_from_value(0.10), QualityTier::Suppressed);
1205        assert_eq!(QualityScore::tier_from_value(0.19), QualityTier::Suppressed);
1206        assert_eq!(QualityScore::tier_from_value(0.20), QualityTier::Poor);
1207        assert_eq!(QualityScore::tier_from_value(0.30), QualityTier::Poor);
1208        assert_eq!(QualityScore::tier_from_value(0.39), QualityTier::Poor);
1209        assert_eq!(QualityScore::tier_from_value(0.40), QualityTier::Acceptable);
1210        assert_eq!(QualityScore::tier_from_value(0.55), QualityTier::Acceptable);
1211        assert_eq!(QualityScore::tier_from_value(0.69), QualityTier::Acceptable);
1212        assert_eq!(QualityScore::tier_from_value(0.70), QualityTier::Good);
1213        assert_eq!(QualityScore::tier_from_value(0.80), QualityTier::Good);
1214        assert_eq!(QualityScore::tier_from_value(0.89), QualityTier::Good);
1215        // 0.9 is the start of Excellent [0.9, 1.0]
1216        assert_eq!(QualityScore::tier_from_value(0.90), QualityTier::Excellent);
1217        assert_eq!(QualityScore::tier_from_value(0.95), QualityTier::Excellent);
1218        assert_eq!(QualityScore::tier_from_value(1.00), QualityTier::Excellent);
1219    }
1220
1221    // ── Confidence score ─────────────────────────────────────────────────────
1222
1223    #[test]
1224    fn confidence_base_scores_by_source() {
1225        assert_eq!(
1226            ConfidenceScore::base_for_source(&RecordSource::DeveloperManual),
1227            0.80
1228        );
1229        assert_eq!(
1230            ConfidenceScore::base_for_source(&RecordSource::Import),
1231            0.70
1232        );
1233        assert_eq!(
1234            ConfidenceScore::base_for_source(&RecordSource::ClaudeEnrich),
1235            0.60
1236        );
1237        assert_eq!(
1238            ConfidenceScore::base_for_source(&RecordSource::SessionHook),
1239            0.50
1240        );
1241        assert_eq!(
1242            ConfidenceScore::base_for_source(&RecordSource::StaticAnalysis),
1243            0.10
1244        );
1245    }
1246
1247    #[test]
1248    fn confidence_for_new_record_value_matches_base() {
1249        let source = RecordSource::ClaudeEnrich;
1250        let score = ConfidenceScore::for_new_record(&source);
1251        assert_eq!(score.value, ConfidenceScore::base_for_source(&source));
1252        assert_eq!(score.confirmation_count, 0);
1253        assert_eq!(score.contributor_count, 1);
1254        assert!(score.last_challenged.is_none());
1255        assert_eq!(score.challenge_count, 0);
1256    }
1257
1258    // ── Priority ordering ────────────────────────────────────────────────────
1259
1260    #[test]
1261    fn priority_total_ordering() {
1262        assert!(Priority::Critical > Priority::High);
1263        assert!(Priority::High > Priority::Normal);
1264        assert!(Priority::Normal > Priority::Low);
1265        assert!(Priority::Critical > Priority::Low);
1266        assert_eq!(Priority::High, Priority::High);
1267    }
1268
1269    // ── Device ID accessor ───────────────────────────────────────────────────
1270
1271    #[test]
1272    fn record_device_id_accessor_matches_version() {
1273        let rec = sample_record();
1274        assert_eq!(rec.device_id(), rec.version.device_id);
1275    }
1276
1277    // ── Quality tier: out-of-range & non-finite ──────────────────────────────
1278
1279    #[test]
1280    fn quality_tier_non_finite_is_suppressed() {
1281        // NaN, +∞, and -∞ must never reach Excellent — they would satisfy the
1282        // hook injection gate (quality >= 0.4) and inject untrusted records.
1283        assert_eq!(
1284            QualityScore::tier_from_value(f32::NAN),
1285            QualityTier::Suppressed
1286        );
1287        assert_eq!(
1288            QualityScore::tier_from_value(f32::INFINITY),
1289            QualityTier::Suppressed
1290        );
1291        assert_eq!(
1292            QualityScore::tier_from_value(f32::NEG_INFINITY),
1293            QualityTier::Suppressed
1294        );
1295    }
1296
1297    #[test]
1298    fn quality_tier_out_of_range_finite_saturates() {
1299        // Finite values outside [0, 1] saturate without panicking.
1300        assert_eq!(QualityScore::tier_from_value(-1.0), QualityTier::Suppressed);
1301        assert_eq!(
1302            QualityScore::tier_from_value(-0.001),
1303            QualityTier::Suppressed
1304        );
1305        assert_eq!(QualityScore::tier_from_value(1.001), QualityTier::Excellent);
1306        assert_eq!(QualityScore::tier_from_value(100.0), QualityTier::Excellent);
1307    }
1308
1309    #[test]
1310    fn layer0_default_quality_is_suppressed_tier() {
1311        let q = QualityScore::layer0_default();
1312        assert_eq!(q.tier, QualityTier::Suppressed);
1313        assert_eq!(q.value, 0.10);
1314        assert!(q.signals.is_empty());
1315        assert_eq!(q.computed_at, 0, "0 = not yet computed sentinel");
1316    }
1317
1318    // ── Confidence: all sources ───────────────────────────────────────────────
1319
1320    #[test]
1321    fn confidence_for_new_record_all_sources_correct() {
1322        let cases: &[(RecordSource, f32)] = &[
1323            (RecordSource::DeveloperManual, 0.80),
1324            (RecordSource::Import, 0.70),
1325            (RecordSource::ClaudeEnrich, 0.60),
1326            (RecordSource::SessionHook, 0.50),
1327            (RecordSource::StaticAnalysis, 0.10),
1328        ];
1329        for (source, expected) in cases {
1330            let score = ConfidenceScore::for_new_record(source);
1331            assert!(
1332                (score.value - expected).abs() < f32::EPSILON,
1333                "{source:?}: expected {expected}, got {}",
1334                score.value
1335            );
1336            assert_eq!(score.confirmation_count, 0);
1337            assert_eq!(score.contributor_count, 1);
1338            assert!(score.last_challenged.is_none());
1339            assert_eq!(score.challenge_count, 0);
1340        }
1341    }
1342
1343    #[test]
1344    fn confidence_base_scores_are_all_distinct() {
1345        let scores: Vec<f32> = [
1346            RecordSource::DeveloperManual,
1347            RecordSource::Import,
1348            RecordSource::ClaudeEnrich,
1349            RecordSource::SessionHook,
1350            RecordSource::StaticAnalysis,
1351        ]
1352        .iter()
1353        .map(ConfidenceScore::base_for_source)
1354        .collect();
1355
1356        for i in 0..scores.len() {
1357            for j in (i + 1)..scores.len() {
1358                assert!(
1359                    (scores[i] - scores[j]).abs() > f32::EPSILON,
1360                    "sources {i} and {j} have identical base score {}",
1361                    scores[i]
1362                );
1363            }
1364        }
1365    }
1366
1367    // ── Priority: exhaustive ordering ─────────────────────────────────────────
1368
1369    #[test]
1370    fn priority_exhaustive_pairwise_ordering() {
1371        use std::cmp::Ordering::*;
1372        let pairs = [
1373            (Priority::Low, Priority::Normal, Less),
1374            (Priority::Low, Priority::High, Less),
1375            (Priority::Low, Priority::Critical, Less),
1376            (Priority::Normal, Priority::High, Less),
1377            (Priority::Normal, Priority::Critical, Less),
1378            (Priority::High, Priority::Critical, Less),
1379            (Priority::Low, Priority::Low, Equal),
1380            (Priority::Normal, Priority::Normal, Equal),
1381            (Priority::High, Priority::High, Equal),
1382            (Priority::Critical, Priority::Critical, Equal),
1383        ];
1384        for (a, b, expected) in pairs {
1385            assert_eq!(
1386                a.cmp(&b),
1387                expected,
1388                "{a:?}.cmp({b:?}) should be {expected:?}"
1389            );
1390            // Antisymmetry: if a < b then b > a
1391            if expected == Less {
1392                assert_eq!(b.cmp(&a), std::cmp::Ordering::Greater, "{b:?}.cmp({a:?})");
1393            }
1394        }
1395    }
1396
1397    // ── StalenessSignal: all variants round-trip ──────────────────────────────
1398
1399    #[test]
1400    fn staleness_all_signal_variants_serde() {
1401        let signals: Vec<StalenessSignal> = vec![
1402            StalenessSignal::NotAccessedDays(30),
1403            StalenessSignal::LinesChangedPct(0.75),
1404            StalenessSignal::EntryPointsChanged(2),
1405            StalenessSignal::ImportsChanged(5),
1406            StalenessSignal::FileDeleted,
1407            StalenessSignal::FileRenamed {
1408                new_path: "src/foo.rs".to_string(),
1409            },
1410            StalenessSignal::DependencyBumped {
1411                dep: "tokio".to_string(),
1412                old_ver: "1.40".to_string(),
1413                new_ver: "1.50".to_string(),
1414            },
1415            StalenessSignal::LinkedFileChanged {
1416                path: "src/bar.rs".to_string(),
1417            },
1418            StalenessSignal::CascadeFromDecision("decision:arch".to_string()),
1419            StalenessSignal::TodosChanged,
1420            StalenessSignal::UnsafeCountChanged(3),
1421            StalenessSignal::UnwrapCountChanged(-2),
1422            StalenessSignal::GitCommitsSince(7),
1423        ];
1424        for signal in &signals {
1425            let json = serde_json::to_string(signal).expect("serialize");
1426            let restored: StalenessSignal = serde_json::from_str(&json).expect("deserialize");
1427            let json2 = serde_json::to_string(&restored).expect("re-serialize");
1428            assert_eq!(json, json2, "roundtrip failed for: {json}");
1429        }
1430    }
1431
1432    // ── TombstoneReason: all variants ────────────────────────────────────────
1433
1434    #[test]
1435    fn tombstone_reason_all_variants_serde() {
1436        let reasons = vec![
1437            TombstoneReason::FileDeleted,
1438            TombstoneReason::FileRenamed {
1439                new_path: "src/new.rs".to_string(),
1440            },
1441            TombstoneReason::ManualDeletion,
1442            TombstoneReason::Superseded,
1443        ];
1444        for reason in &reasons {
1445            assert_serde_roundtrip(reason);
1446        }
1447    }
1448
1449    // ── Serde snake_case contracts ────────────────────────────────────────────
1450
1451    #[test]
1452    fn category_serializes_as_snake_case() {
1453        let cases = [
1454            (Category::Gotcha, "\"gotcha\""),
1455            (Category::File, "\"file\""),
1456            (Category::Decision, "\"decision\""),
1457            (Category::Stage, "\"stage\""),
1458            (Category::Dependency, "\"dependency\""),
1459            (Category::DevNote, "\"dev_note\""),
1460            (Category::Session, "\"session\""),
1461            (Category::Analytics, "\"analytics\""),
1462        ];
1463        for (cat, expected_json) in cases {
1464            let json = serde_json::to_string(&cat).unwrap();
1465            assert_eq!(json, expected_json, "Category::{cat:?}");
1466        }
1467    }
1468
1469    #[test]
1470    fn record_source_serializes_as_snake_case() {
1471        let cases = [
1472            (RecordSource::StaticAnalysis, "\"static_analysis\""),
1473            (RecordSource::ClaudeEnrich, "\"claude_enrich\""),
1474            (RecordSource::SessionHook, "\"session_hook\""),
1475            (RecordSource::DeveloperManual, "\"developer_manual\""),
1476            (RecordSource::Import, "\"import\""),
1477        ];
1478        for (src, expected_json) in cases {
1479            let json = serde_json::to_string(&src).unwrap();
1480            assert_eq!(json, expected_json, "RecordSource::{src:?}");
1481        }
1482    }
1483
1484    #[test]
1485    fn staleness_tier_serializes_as_snake_case() {
1486        // Sync merge rule depends on the wire format being stable.
1487        let cases = [
1488            (StalenessTier::Fresh, "\"fresh\""),
1489            (StalenessTier::Aging, "\"aging\""),
1490            (StalenessTier::Stale, "\"stale\""),
1491            (StalenessTier::Liability, "\"liability\""),
1492            (StalenessTier::Tombstone, "\"tombstone\""),
1493        ];
1494        for (tier, expected_json) in cases {
1495            let json = serde_json::to_string(&tier).unwrap();
1496            assert_eq!(json, expected_json, "StalenessTier::{tier:?}");
1497        }
1498    }
1499
1500    // ── GotchaRecord: confirmed flag ─────────────────────────────────────────
1501
1502    #[test]
1503    fn gotcha_record_layer0_stub_is_unconfirmed() {
1504        // Layer 0 stubs must start unconfirmed; the hook decision matrix never
1505        // injects confirmed:false records regardless of confidence or quality.
1506        let stub = GotchaRecord {
1507            rule: "Do not call .await inside rayon::spawn.".to_string(),
1508            reason: "rayon threads have no tokio runtime.".to_string(),
1509            severity: Priority::Critical,
1510            affected_files: vec!["src/analysis/walker.rs".to_string()],
1511            ref_url: None,
1512            discovered_session: 0,
1513            confirmed: false,
1514        };
1515        assert!(
1516            !stub.confirmed,
1517            "Layer 0 stubs must be unconfirmed on construction"
1518        );
1519
1520        // Serde roundtrip preserves the flag
1521        let json = serde_json::to_string(&stub).unwrap();
1522        let restored: GotchaRecord = serde_json::from_str(&json).unwrap();
1523        assert!(
1524            !restored.confirmed,
1525            "confirmed flag must survive serde roundtrip"
1526        );
1527        // The JSON wire format must contain "confirmed":false explicitly
1528        assert!(json.contains("\"confirmed\":false"), "wire format: {json}");
1529    }
1530
1531    #[test]
1532    fn gotcha_record_confirmed_true_roundtrips() {
1533        let confirmed = GotchaRecord {
1534            rule: "Use SurrealKV::with_versioning(true, 0) for indefinite retention.".to_string(),
1535            reason: "0 means retain all versions forever, not disabled.".to_string(),
1536            severity: Priority::High,
1537            affected_files: vec!["src/store/db.rs".to_string()],
1538            ref_url: Some("https://github.com/example/issue/5".to_string()),
1539            discovered_session: 1_710_520_800,
1540            confirmed: true,
1541        };
1542        assert_serde_roundtrip(&confirmed);
1543        let json = serde_json::to_string(&confirmed).unwrap();
1544        assert!(json.contains("\"confirmed\":true"));
1545    }
1546
1547    // ─── Complex serde round-trips ────────────────────────────────────────────
1548
1549    #[test]
1550    fn staleness_score_fully_populated_serde() {
1551        let s = StalenessScore {
1552            value: 0.87,
1553            tier: StalenessTier::Liability,
1554            signals: vec![
1555                StalenessSignal::NotAccessedDays(90),
1556                StalenessSignal::LinesChangedPct(0.6),
1557                StalenessSignal::EntryPointsChanged(3),
1558                StalenessSignal::FileRenamed {
1559                    new_path: "src/store/backend.rs".to_string(),
1560                },
1561            ],
1562            computed_at: 1_710_520_800,
1563            last_record_sha: "deadbeefcafe0123".to_string(),
1564        };
1565        assert_serde_roundtrip(&s);
1566        let json = serde_json::to_string(&s).unwrap();
1567        let restored: StalenessScore = serde_json::from_str(&json).unwrap();
1568        assert_eq!(restored.tier, StalenessTier::Liability);
1569        assert_eq!(restored.signals.len(), 4);
1570        assert_eq!(restored.last_record_sha, "deadbeefcafe0123");
1571    }
1572
1573    #[test]
1574    fn quality_score_with_all_positive_signals_serde() {
1575        let q = QualityScore {
1576            value: 0.92,
1577            tier: QualityTier::Excellent,
1578            signals: vec![
1579                QualitySignal::HasImperativeVerb,
1580                QualitySignal::HasCausality,
1581                QualitySignal::HasSeveritySet,
1582                QualitySignal::HasReference,
1583                QualitySignal::RuleLengthAdequate,
1584                QualitySignal::ReasonLengthAdequate,
1585                QualitySignal::AffectedFilesSpecified,
1586                QualitySignal::HasSpecificIdentifier,
1587            ],
1588            computed_at: 1_710_520_800,
1589        };
1590        assert_serde_roundtrip(&q);
1591        let json = serde_json::to_string(&q).unwrap();
1592        let restored: QualityScore = serde_json::from_str(&json).unwrap();
1593        assert_eq!(restored.tier, QualityTier::Excellent);
1594        assert_eq!(restored.signals.len(), 8);
1595    }
1596
1597    #[test]
1598    fn confidence_score_with_challenge_history_serde() {
1599        // last_challenged: Some(u64) — a real production state for a disputed record.
1600        let c = ConfidenceScore {
1601            value: 0.45,
1602            confirmation_count: 1,
1603            contributor_count: 3,
1604            last_challenged: Some(1_710_500_000),
1605            challenge_count: 2,
1606        };
1607        let json = serde_json::to_string(&c).unwrap();
1608        let restored: ConfidenceScore = serde_json::from_str(&json).unwrap();
1609        assert_eq!(restored.last_challenged, Some(1_710_500_000));
1610        assert_eq!(restored.challenge_count, 2);
1611        assert_eq!(restored.contributor_count, 3);
1612        let json2 = serde_json::to_string(&restored).unwrap();
1613        assert_eq!(json, json2);
1614    }
1615
1616    #[test]
1617    fn record_ref_url_none_does_not_become_some() {
1618        // ref_url: None must not silently become Some("") or Some("null").
1619        let mut r = sample_record();
1620        r.ref_url = None;
1621        let json = serde_json::to_string(&r).unwrap();
1622        let restored: Record = serde_json::from_str(&json).unwrap();
1623        assert!(
1624            restored.ref_url.is_none(),
1625            "ref_url: None must not become Some after roundtrip"
1626        );
1627        assert!(
1628            json.contains("\"ref_url\":null"),
1629            "wire format must encode None as null"
1630        );
1631    }
1632
1633    #[test]
1634    fn context_packet_zero_knowledge_case_serde() {
1635        // The "blank slate" scenario: mati installed but nothing indexed yet.
1636        let empty = ContextPacket {
1637            stage: None,
1638            critical_gotchas: vec![],
1639            file_records: vec![],
1640            related_decisions: vec![],
1641            recent_session: None,
1642            token_estimate: 0,
1643            stale_warnings: vec![],
1644            unconfirmed_candidates: vec![],
1645            knowledge_gaps: vec![],
1646            compliance_rate: None,
1647            injection_string: String::new(),
1648        };
1649        assert_serde_roundtrip(&empty);
1650        let json = serde_json::to_string(&empty).unwrap();
1651        let restored: ContextPacket = serde_json::from_str(&json).unwrap();
1652        assert!(restored.critical_gotchas.is_empty());
1653        assert!(restored.file_records.is_empty());
1654        assert!(restored.stage.is_none());
1655        assert_eq!(restored.token_estimate, 0);
1656    }
1657
1658    #[test]
1659    fn record_tags_empty_and_many_both_survive_serde() {
1660        let mut r = sample_record();
1661
1662        r.tags = vec![];
1663        let json_empty = serde_json::to_string(&r).unwrap();
1664        let restored_empty: Record = serde_json::from_str(&json_empty).unwrap();
1665        assert!(
1666            restored_empty.tags.is_empty(),
1667            "empty tags must remain empty"
1668        );
1669
1670        r.tags = (0..50).map(|i| format!("tag-{i:03}")).collect();
1671        let json_many = serde_json::to_string(&r).unwrap();
1672        let restored_many: Record = serde_json::from_str(&json_many).unwrap();
1673        assert_eq!(restored_many.tags.len(), 50);
1674        assert_eq!(restored_many.tags[0], "tag-000");
1675        assert_eq!(restored_many.tags[49], "tag-049");
1676    }
1677
1678    #[test]
1679    fn file_record_layer0_stub_serde() {
1680        // Layer 0: file exists, but purpose and entry_points are empty.
1681        let stub = FileRecord::layer0_stub(
1682            "src/analysis/walker.rs",
1683            vec![],
1684            vec!["ignore".to_string(), "rayon".to_string()],
1685            vec![],
1686            0,
1687            3,
1688            17,
1689            None,
1690            true,
1691            0,
1692            0,
1693        );
1694        assert_serde_roundtrip(&stub);
1695        let json = serde_json::to_string(&stub).unwrap();
1696        let restored: FileRecord = serde_json::from_str(&json).unwrap();
1697        assert!(
1698            restored.purpose.is_empty(),
1699            "empty purpose must remain empty"
1700        );
1701        assert!(restored.entry_points.is_empty());
1702        assert!(restored.last_author.is_none());
1703        assert!(restored.is_hotspot);
1704        assert_eq!(restored.unwrap_count, 3);
1705    }
1706
1707    #[test]
1708    fn layer0_file_record_builder_sets_suppressed_quality() {
1709        let record =
1710            Record::layer0_file_stub("file:src/analysis/walker.rs", device_id(), 7, 1_710_520_800);
1711
1712        assert_eq!(record.key, "file:src/analysis/walker.rs");
1713        assert_eq!(record.category, Category::File);
1714        assert!(record.value.is_empty());
1715        assert_eq!(record.quality.value, 0.10);
1716        assert_eq!(record.quality.tier, QualityTier::Suppressed);
1717        assert_eq!(record.source, RecordSource::StaticAnalysis);
1718        assert_eq!(record.confidence.value, 0.10);
1719        assert_eq!(record.confidence.contributor_count, 1);
1720    }
1721
1722    // ── StaleReviewEntry / StaleReviewPayload serde ──────────────────────────
1723
1724    #[test]
1725    fn stale_review_entry_serde_roundtrip() {
1726        let entry = StaleReviewEntry {
1727            key: "file:src/store/db.rs".to_string(),
1728            staleness_value: 0.72,
1729            tier: StalenessTier::Stale,
1730            last_updated: 1_710_520_800,
1731            signals: vec![
1732                "not accessed for 45 days".to_string(),
1733                "3 entry points changed".to_string(),
1734            ],
1735        };
1736        assert_serde_roundtrip(&entry);
1737    }
1738
1739    #[test]
1740    fn stale_review_payload_serde_roundtrip() {
1741        let payload = StaleReviewPayload {
1742            session_timestamp: 1_710_520_800,
1743            entries: vec![
1744                StaleReviewEntry {
1745                    key: "file:src/store/db.rs".to_string(),
1746                    staleness_value: 0.72,
1747                    tier: StalenessTier::Stale,
1748                    last_updated: 1_710_500_000,
1749                    signals: vec!["not accessed for 45 days".to_string()],
1750                },
1751                StaleReviewEntry {
1752                    key: "gotcha:inference-async".to_string(),
1753                    staleness_value: 0.85,
1754                    tier: StalenessTier::Liability,
1755                    last_updated: 1_710_400_000,
1756                    signals: vec![
1757                        "90 commits since last confirmation".to_string(),
1758                        "75% of lines changed".to_string(),
1759                    ],
1760                },
1761            ],
1762        };
1763        assert_serde_roundtrip(&payload);
1764        let json = serde_json::to_string(&payload).unwrap();
1765        let restored: StaleReviewPayload = serde_json::from_str(&json).unwrap();
1766        assert_eq!(restored.entries.len(), 2);
1767        assert_eq!(restored.session_timestamp, 1_710_520_800);
1768    }
1769
1770    #[test]
1771    fn stale_review_payload_empty_entries_serde() {
1772        let payload = StaleReviewPayload {
1773            session_timestamp: 1_710_520_800,
1774            entries: vec![],
1775        };
1776        assert_serde_roundtrip(&payload);
1777        let json = serde_json::to_string(&payload).unwrap();
1778        let restored: StaleReviewPayload = serde_json::from_str(&json).unwrap();
1779        assert!(restored.entries.is_empty());
1780    }
1781
1782    // ── GitCommitsSince signal ───────────────────────────────────────────────
1783
1784    #[test]
1785    fn staleness_signal_git_commits_since_serde() {
1786        let signal = StalenessSignal::GitCommitsSince(42);
1787        assert_serde_roundtrip(&signal);
1788        let json = serde_json::to_string(&signal).unwrap();
1789        assert!(json.contains("git_commits_since"), "wire format: {json}");
1790    }
1791
1792    #[test]
1793    fn staleness_signal_git_commits_since_display() {
1794        let signal = StalenessSignal::GitCommitsSince(7);
1795        assert_eq!(signal.to_string(), "7 commits since last confirmation");
1796    }
1797
1798    #[test]
1799    fn staleness_signal_display_all_variants() {
1800        // Smoke test: every variant produces a non-empty string.
1801        let signals: Vec<StalenessSignal> = vec![
1802            StalenessSignal::NotAccessedDays(30),
1803            StalenessSignal::LinesChangedPct(0.75),
1804            StalenessSignal::EntryPointsChanged(2),
1805            StalenessSignal::ImportsChanged(5),
1806            StalenessSignal::FileDeleted,
1807            StalenessSignal::FileRenamed {
1808                new_path: "src/foo.rs".to_string(),
1809            },
1810            StalenessSignal::DependencyBumped {
1811                dep: "tokio".to_string(),
1812                old_ver: "1.40".to_string(),
1813                new_ver: "1.50".to_string(),
1814            },
1815            StalenessSignal::LinkedFileChanged {
1816                path: "src/bar.rs".to_string(),
1817            },
1818            StalenessSignal::CascadeFromDecision("decision:arch".to_string()),
1819            StalenessSignal::TodosChanged,
1820            StalenessSignal::UnsafeCountChanged(3),
1821            StalenessSignal::UnwrapCountChanged(-2),
1822            StalenessSignal::GitCommitsSince(7),
1823        ];
1824        for signal in &signals {
1825            let display = signal.to_string();
1826            assert!(
1827                !display.is_empty(),
1828                "Display for {signal:?} should not be empty"
1829            );
1830        }
1831    }
1832}