Fas 2 steg 3: Household Trust Score (admin + i18n status i appen)
This commit is contained in:
@@ -19,6 +19,7 @@ export interface InventoryItemLike {
|
||||
lastVerifiedAt: Date | string | null;
|
||||
quantity: number;
|
||||
updatedAt: Date | string;
|
||||
depletedAt?: Date | string | null;
|
||||
}
|
||||
|
||||
export interface TrustResult {
|
||||
@@ -118,6 +119,103 @@ export function itemTrustState(
|
||||
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"];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyDecay, computeTrust, worstTrustState } from "../src/trust.js";
|
||||
import { applyDecay, computeTrust, householdTrustScore, worstTrustState } from "../src/trust.js";
|
||||
|
||||
const PINNED = new Date("2026-08-06T12:00:00.000Z");
|
||||
|
||||
@@ -96,6 +96,62 @@ describe("computeTrust", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("householdTrustScore", () => {
|
||||
it("returns 100 / up_to_date for empty inventory", () => {
|
||||
const result = householdTrustScore({ items: [], correctionCount30d: 0, transactionCount30d: 0 }, PINNED);
|
||||
expect(result.score).toBe(100);
|
||||
expect(result.status).toBe("up_to_date");
|
||||
});
|
||||
|
||||
it("scores high when all items are verified and recent", () => {
|
||||
const result = householdTrustScore(
|
||||
{
|
||||
items: [
|
||||
{ confidence: 1, verifiedByUser: true, lastVerifiedAt: PINNED, quantity: 2, updatedAt: PINNED },
|
||||
{ confidence: 1, verifiedByUser: true, lastVerifiedAt: PINNED, quantity: 3, updatedAt: PINNED },
|
||||
],
|
||||
correctionCount30d: 0,
|
||||
transactionCount30d: 0,
|
||||
},
|
||||
PINNED,
|
||||
);
|
||||
expect(result.score).toBeGreaterThanOrEqual(80);
|
||||
expect(result.status).toBe("up_to_date");
|
||||
});
|
||||
|
||||
it("drops to uncertain when items are old and unverified", () => {
|
||||
const old = new Date(PINNED.getTime() - 60 * 86_400_000);
|
||||
const result = householdTrustScore(
|
||||
{
|
||||
items: [
|
||||
{ confidence: 0.3, verifiedByUser: false, lastVerifiedAt: null, quantity: 0.1, updatedAt: old },
|
||||
],
|
||||
correctionCount30d: 5,
|
||||
transactionCount30d: 10,
|
||||
},
|
||||
PINNED,
|
||||
);
|
||||
expect(result.status).toBe("uncertain");
|
||||
expect(result.score).toBeLessThan(50);
|
||||
});
|
||||
|
||||
it("flags needs_check for partly verified household", () => {
|
||||
const weekAgo = new Date(PINNED.getTime() - 7 * 86_400_000);
|
||||
const result = householdTrustScore(
|
||||
{
|
||||
items: [
|
||||
{ confidence: 0.8, verifiedByUser: true, lastVerifiedAt: weekAgo, quantity: 1, updatedAt: weekAgo },
|
||||
{ confidence: 0.5, verifiedByUser: false, lastVerifiedAt: null, quantity: 1, updatedAt: weekAgo },
|
||||
],
|
||||
correctionCount30d: 1,
|
||||
transactionCount30d: 5,
|
||||
},
|
||||
PINNED,
|
||||
);
|
||||
expect(result.status).toBe("needs_check");
|
||||
});
|
||||
});
|
||||
|
||||
describe("worstTrustState", () => {
|
||||
it("returns stale if present", () => {
|
||||
expect(worstTrustState(["trusted", "stale", "decaying"])).toBe("stale");
|
||||
|
||||
Reference in New Issue
Block a user