import type { TrustState } from "@app/shared-types"; export interface DecayProfile { halfLifeDays: number; staleAfterDays: number; } export interface TrustInput { confidence: number; verifiedByUser: boolean; lastVerifiedAt: Date | string | null; quantity: number; updatedAt: Date | string; } export interface InventoryItemLike { confidence: number; verifiedByUser: boolean; lastVerifiedAt: Date | string | null; quantity: number; updatedAt: Date | string; depletedAt?: Date | string | null; } export interface TrustResult { state: TrustState; score: number; // 0–100 } const DEFAULT_DECAY: DecayProfile = { halfLifeDays: 7, staleAfterDays: 30 }; /** * Deterministic exponential decay of confidence. * Decay affects TRUST only, never edibility (mjölkprincipen D-035). */ export function applyDecay( confidence: number, elapsedDays: number, profile: DecayProfile = DEFAULT_DECAY, ): number { if (elapsedDays <= 0) return confidence; if (elapsedDays >= profile.staleAfterDays) return 0; const factor = Math.pow(0.5, elapsedDays / profile.halfLifeDays); return Math.max(0, confidence * factor); } /** * Compute the trust state of an inventory item. * * - unverified: quantity is present but never verified. * - trusted: user verified or high confidence and recently touched. * - decaying: confidence is dropping / not verified for a while. * - stale: very old, unverified, or confidence critically low. * * Decay affects TRUST, not edibility (mjölkprincipen D-035). */ export function computeTrust( input: TrustInput, now: Date = new Date(), profile: DecayProfile = DEFAULT_DECAY, ): TrustResult { const nowMs = now.getTime(); const lastVerified = input.lastVerifiedAt ? new Date(input.lastVerifiedAt).getTime() : null; const updated = new Date(input.updatedAt).getTime(); const referenceMs = lastVerified ?? updated; const elapsedDays = (nowMs - referenceMs) / 86_400_000; const daysSinceUpdate = (nowMs - updated) / 86_400_000; // Decay confidence deterministically from the profile. const effectiveConfidence = applyDecay(input.confidence, elapsedDays, profile); // Base score from effective confidence (0–80 points) let score = Math.round(effectiveConfidence * 80); // Verification bonus (0–20 points) – verification age is already reflected by decay, // but a known user-verified item retains a small pedigree bonus. if (input.verifiedByUser && elapsedDays <= 7) { score += 20; } else if (input.verifiedByUser && elapsedDays <= 30) { score += 10; } else if (input.verifiedByUser) { score += 5; } // Additional staleness penalty when update is old. if (daysSinceUpdate > profile.staleAfterDays) { score -= 20; } else if (daysSinceUpdate > 14) { score -= Math.min(15, Math.floor((daysSinceUpdate - 14) / 7) * 5); } if (input.quantity <= 0) { score -= 10; // depleted items are less interesting for trust } score = Math.max(0, Math.min(100, score)); let state: TrustState; if (score >= 80) { state = "trusted"; } else if (score >= 60) { state = "trusted"; } else if (score >= 30) { state = "decaying"; } else if (lastVerified === null && daysSinceUpdate <= 1) { state = "unverified"; } else { state = "stale"; } return { state, score }; } /** Convenience wrapper that returns just the state for an inventory row. */ export function itemTrustState( item: InventoryItemLike, now?: Date, profile?: DecayProfile, ): TrustState { return computeTrust(item, now, profile).state; } export interface HouseholdTrustInput { items: InventoryItemLike[]; /** Antal justeringstransaktioner senaste 30 dagarna (korrigeringsfrekvens). */ correctionCount30d: number; /** Totalt antal transaktioner senaste 30 dagarna (används för korrigeringsfrekvens). */ transactionCount30d: number; } export interface HouseholdTrustResult { score: number; // 0–100 status: "up_to_date" | "needs_check" | "uncertain"; } export const TRUST_STATUS_THRESHOLDS = { upToDate: 80, needsCheck: 50, } as const; /** * Deterministisk household trust score 0–100. * * Viktade komponenter (Fas 2 §5.3): * - 25% verifierade poster (verifiedByUser) * - 25% ålder på senaste kontroll (nyare = bättre) * - 20% andel uppskattade mängder (lägre confidence ger mer osäkerhet) * - 15% poster som borde vara slut (låg quantity utan depletedAt straffar) * - 15% korrigeringsfrekvens (fler korrigeringar = lägre förtroende) * * Decay påverkar FORTROENDE, inte ätbarhet. */ export function householdTrustScore( input: HouseholdTrustInput, now: Date = new Date(), ): HouseholdTrustResult { const items = input.items.filter((i) => i.quantity > 0 && !i.depletedAt); if (items.length === 0) { return { score: 100, status: "up_to_date" }; } const nowMs = now.getTime(); // 1. Andel verifierade (0–100) const verifiedRatio = items.filter((i) => i.verifiedByUser).length / items.length; const verifiedScore = verifiedRatio * 100; // 2. Ålder på senaste kontroll (0–100, 100 = alla kontrollerade inom 7 dagar) const ageScore = items.reduce((sum, i) => { const verifiedMs = i.lastVerifiedAt ? new Date(i.lastVerifiedAt).getTime() : null; const referenceMs = verifiedMs ?? new Date(i.updatedAt).getTime(); const days = Math.max(0, (nowMs - referenceMs) / 86_400_000); return sum + Math.max(0, 100 - (days / 30) * 100); }, 0) / items.length; // 3. Andel uppskattade mängder (0–100, 100 = alla har confidence 1) const confidenceScore = items.reduce((sum, i) => sum + Math.min(1, Math.max(0, i.confidence)), 0) / items.length * 100; // 4. Poster som borde vara slut (0–100, 100 = inga låga kvantiteter) const depletionScore = items.reduce((sum, i) => { const qty = i.quantity; if (qty <= 0) return sum + 0; if (qty < 0.2) return sum + 40; if (qty < 0.5) return sum + 70; return sum + 100; }, 0) / items.length; // 5. Korrigeringsfrekvens (0–100, 100 = inga korrigeringar) const correctionRatio = input.transactionCount30d > 0 ? input.correctionCount30d / input.transactionCount30d : 0; const correctionScore = Math.max(0, 100 - correctionRatio * 200); const score = Math.round( verifiedScore * 0.25 + ageScore * 0.25 + confidenceScore * 0.20 + depletionScore * 0.15 + correctionScore * 0.15, ); const clamped = Math.max(0, Math.min(100, score)); let status: HouseholdTrustResult["status"]; if (clamped >= TRUST_STATUS_THRESHOLDS.upToDate) { status = "up_to_date"; } else if (clamped >= TRUST_STATUS_THRESHOLDS.needsCheck) { status = "needs_check"; } else { status = "uncertain"; } return { score: clamped, status }; } /** Pick the most urgent trust state from a list (used for household summary). */ export function worstTrustState(states: TrustState[]): TrustState { const order: TrustState[] = ["stale", "decaying", "unverified", "trusted"]; for (const s of order) { if (states.includes(s)) return s; } return "unverified"; }