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