| 1 | // Freshness classification for saved facts: how a fact ages (volatility or |
| 2 | // the legacy type default), when it hard-expires, and what renews its clock. |
| 3 | package memory |
| 4 | |
| 5 | import ( |
| 6 | "strings" |
| 7 | "time" |
| 8 | ) |
| 9 | |
| 10 | const ( |
| 11 | FreshnessFresh = "fresh" |
| 12 | FreshnessCurrent = "current" |
| 13 | FreshnessStale = "stale" |
| 14 | FreshnessExpired = "expired" // past an explicit expires_at; excluded from automatic recall |
| 15 | ) |
| 16 | |
| 17 | // Volatility is how fast a fact ages, orthogonal to Type: a project fact may |
| 18 | // be a release branch that dies in days or a README location that holds for |
| 19 | // years. Unset falls back to the legacy type-based windows. |
| 20 | type Volatility string |
| 21 | |
| 22 | const ( |
| 23 | VolatilityEvergreen Volatility = "evergreen" // never ages |
| 24 | VolatilityStable Volatility = "stable" // 90 days fresh / 365 current |
| 25 | VolatilityVolatile Volatility = "volatile" // 7 days fresh / 30 current |
| 26 | ) |
| 27 | |
| 28 | // NormalizeVolatility validates a persisted or requested volatility. Empty and |
| 29 | // unknown values return "" (unset) so the type-based default applies. |
| 30 | func NormalizeVolatility(s string) Volatility { |
| 31 | switch Volatility(strings.ToLower(strings.TrimSpace(s))) { |
| 32 | case VolatilityEvergreen: |
| 33 | return VolatilityEvergreen |
| 34 | case VolatilityStable: |
| 35 | return VolatilityStable |
| 36 | case VolatilityVolatile: |
| 37 | return VolatilityVolatile |
| 38 | } |
| 39 | return "" |
| 40 | } |
| 41 | |
| 42 | // freshnessWindows resolves the (fresh, current) aging windows: an explicit |
| 43 | // volatility is self-describing and wins; unset falls back to the type |
| 44 | // defaults that predate the field. |
| 45 | func freshnessWindows(m Memory) (fresh, current time.Duration, evergreen bool) { |
| 46 | switch NormalizeVolatility(string(m.Volatility)) { |
| 47 | case VolatilityEvergreen: |
| 48 | return 0, 0, true |
| 49 | case VolatilityStable: |
| 50 | return 90 * 24 * time.Hour, 365 * 24 * time.Hour, false |
| 51 | case VolatilityVolatile: |
| 52 | return 7 * 24 * time.Hour, 30 * 24 * time.Hour, false |
| 53 | } |
| 54 | switch NormalizeType(string(m.Type)) { |
| 55 | case TypeReference: |
| 56 | return 14 * 24 * time.Hour, 45 * 24 * time.Hour, false |
| 57 | case TypeUser, TypeFeedback: |
| 58 | return 90 * 24 * time.Hour, 365 * 24 * time.Hour, false |
| 59 | default: |
| 60 | return 30 * 24 * time.Hour, 180 * 24 * time.Hour, false |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | // FreshnessFor exposes the freshness classification shared by automatic |
| 65 | // recall, /memory, and diagnostic surfaces. An explicit expiry is a hard |
| 66 | // boundary; otherwise the clock runs from the last content change or the last |
| 67 | // explicit verification, whichever is newer. |
| 68 | func FreshnessFor(fact Memory, now time.Time) string { |
| 69 | return memoryFreshness(fact, now) |
| 70 | } |
| 71 | |
| 72 | func memoryFreshness(m Memory, now time.Time) string { |
| 73 | if !m.ExpiresAt.IsZero() && now.After(m.ExpiresAt) { |
| 74 | return FreshnessExpired |
| 75 | } |
| 76 | updated := m.UpdatedAt |
| 77 | if updated.IsZero() { |
| 78 | updated = m.CreatedAt |
| 79 | } |
| 80 | if m.LastVerifiedAt.After(updated) { |
| 81 | updated = m.LastVerifiedAt |
| 82 | } |
| 83 | if updated.IsZero() || updated.After(now) { |
| 84 | return FreshnessCurrent |
| 85 | } |
| 86 | fresh, current, evergreen := freshnessWindows(m) |
| 87 | if evergreen { |
| 88 | return FreshnessFresh |
| 89 | } |
| 90 | age := now.Sub(updated) |
| 91 | if age <= fresh { |
| 92 | return FreshnessFresh |
| 93 | } |
| 94 | if age <= current { |
| 95 | return FreshnessCurrent |
| 96 | } |
| 97 | return FreshnessStale |
| 98 | } |
| 99 |