227 lines
7.0 KiB
TypeScript
227 lines
7.0 KiB
TypeScript
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";
|
||
}
|