Fas 2 steg 2: Inventory Trust Engine - decay-profiler och cache-jobb

This commit is contained in:
Sven (AAMOS AI)
2026-08-06 23:25:32 +07:00
parent 8ae4bf6ec3
commit bcf932d2ff
17 changed files with 18296 additions and 61 deletions
+52 -18
View File
@@ -1,5 +1,10 @@
import type { TrustState } from "@app/shared-types";
export interface DecayProfile {
halfLifeDays: number;
staleAfterDays: number;
}
export interface TrustInput {
confidence: number;
verifiedByUser: boolean;
@@ -21,6 +26,23 @@ export interface TrustResult {
score: number; // 0100
}
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.
*
@@ -29,32 +51,40 @@ export interface TrustResult {
* - decaying: confidence is dropping / not verified for a while.
* - stale: very old, unverified, or confidence critically low.
*
* Decay affects TRUST, not edibility (mjölkprincipen spec D-035).
* Decay affects TRUST, not edibility (mjölkprincipen D-035).
*/
export function computeTrust(input: TrustInput): TrustResult {
const now = Date.now();
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 daysSinceVerification = lastVerified ? (now - lastVerified) / 86_400_000 : null;
const daysSinceUpdate = (now - updated) / 86_400_000;
const referenceMs = lastVerified ?? updated;
const elapsedDays = (nowMs - referenceMs) / 86_400_000;
const daysSinceUpdate = (nowMs - updated) / 86_400_000;
// Base score from confidence (080 points)
let score = Math.round(input.confidence * 80);
// Decay confidence deterministically from the profile.
const effectiveConfidence = applyDecay(input.confidence, elapsedDays, profile);
// Verification bonus (020 points)
if (input.verifiedByUser && daysSinceVerification !== null && daysSinceVerification <= 7) {
// Base score from effective confidence (080 points)
let score = Math.round(effectiveConfidence * 80);
// Verification bonus (020 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 && daysSinceVerification !== null && daysSinceVerification <= 30) {
} else if (input.verifiedByUser && elapsedDays <= 30) {
score += 10;
} else if (input.verifiedByUser) {
score += 5;
}
// Decay penalties (only affect trust score)
if (daysSinceVerification !== null && daysSinceVerification > 7) {
score -= Math.min(20, Math.floor((daysSinceVerification - 7) / 7) * 5);
}
if (daysSinceUpdate > 14) {
// 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) {
@@ -64,7 +94,7 @@ export function computeTrust(input: TrustInput): TrustResult {
score = Math.max(0, Math.min(100, score));
let state: TrustState;
if (input.verifiedByUser && score >= 80) {
if (score >= 80) {
state = "trusted";
} else if (score >= 60) {
state = "trusted";
@@ -80,8 +110,12 @@ export function computeTrust(input: TrustInput): TrustResult {
}
/** Convenience wrapper that returns just the state for an inventory row. */
export function itemTrustState(item: InventoryItemLike): TrustState {
return computeTrust(item).state;
export function itemTrustState(
item: InventoryItemLike,
now?: Date,
profile?: DecayProfile,
): TrustState {
return computeTrust(item, now, profile).state;
}
/** Pick the most urgent trust state from a list (used for household summary). */
+77 -32
View File
@@ -1,54 +1,99 @@
import { describe, expect, it } from "vitest";
import { computeTrust, worstTrustState } from "../src/trust.js";
import { applyDecay, computeTrust, worstTrustState } from "../src/trust.js";
const PINNED = new Date("2026-08-06T12:00:00.000Z");
describe("applyDecay", () => {
it("returns original confidence when elapsed days is zero", () => {
expect(applyDecay(0.8, 0)).toBe(0.8);
});
it("halves confidence after halfLifeDays", () => {
expect(applyDecay(1.0, 7, { halfLifeDays: 7, staleAfterDays: 30 })).toBeCloseTo(0.5, 3);
});
it("returns zero when elapsed days reaches staleAfterDays", () => {
expect(applyDecay(0.8, 30, { halfLifeDays: 7, staleAfterDays: 30 })).toBe(0);
});
});
describe("computeTrust", () => {
it("returns trusted for a fresh high-confidence item even without user verification", () => {
const result = computeTrust({
confidence: 0.8,
verifiedByUser: false,
lastVerifiedAt: null,
quantity: 2,
updatedAt: new Date(),
});
it("returns trusted for a fresh high-confidence item", () => {
const result = computeTrust(
{
confidence: 0.8,
verifiedByUser: false,
lastVerifiedAt: null,
quantity: 2,
updatedAt: PINNED,
},
PINNED,
);
expect(result.state).toBe("trusted");
expect(result.score).toBeGreaterThanOrEqual(60);
});
it("decays when last verification is old", () => {
const thirtyDaysAgo = new Date(Date.now() - 30 * 86_400_000);
const result = computeTrust({
confidence: 0.6,
verifiedByUser: true,
lastVerifiedAt: thirtyDaysAgo,
quantity: 1,
updatedAt: thirtyDaysAgo,
});
const sevenDaysAgo = new Date(PINNED.getTime() - 7 * 86_400_000);
const result = computeTrust(
{
confidence: 0.8,
verifiedByUser: true,
lastVerifiedAt: sevenDaysAgo,
quantity: 1,
updatedAt: sevenDaysAgo,
},
PINNED,
);
expect(result.state).toBe("decaying");
});
it("is stale for very old unverified low-confidence items", () => {
const sixtyDaysAgo = new Date(Date.now() - 60 * 86_400_000);
const result = computeTrust({
confidence: 0.2,
verifiedByUser: false,
lastVerifiedAt: null,
quantity: 1,
updatedAt: sixtyDaysAgo,
});
const sixtyDaysAgo = new Date(PINNED.getTime() - 60 * 86_400_000);
const result = computeTrust(
{
confidence: 0.2,
verifiedByUser: false,
lastVerifiedAt: null,
quantity: 1,
updatedAt: sixtyDaysAgo,
},
PINNED,
);
expect(result.state).toBe("stale");
});
it("is trusted when user verified recently", () => {
const result = computeTrust({
confidence: 0.7,
verifiedByUser: true,
lastVerifiedAt: new Date(),
quantity: 1,
updatedAt: new Date(),
});
const result = computeTrust(
{
confidence: 0.7,
verifiedByUser: true,
lastVerifiedAt: PINNED,
quantity: 1,
updatedAt: PINNED,
},
PINNED,
);
expect(result.state).toBe("trusted");
expect(result.score).toBeGreaterThanOrEqual(70);
});
it("uses custom decay profile to accelerate staleness", () => {
const tenDaysAgo = new Date(PINNED.getTime() - 10 * 86_400_000);
const fast = { halfLifeDays: 3, staleAfterDays: 10 };
const result = computeTrust(
{
confidence: 0.9,
verifiedByUser: true,
lastVerifiedAt: tenDaysAgo,
quantity: 1,
updatedAt: tenDaysAgo,
},
PINNED,
fast,
);
expect(result.state).toBe("stale");
});
});
describe("worstTrustState", () => {