Fas 2 steg 2: Inventory Trust Engine - decay-profiler och cache-jobb
This commit is contained in:
@@ -46,7 +46,6 @@ import {
|
||||
UNITS,
|
||||
USER_ROLES,
|
||||
VERIFICATION_STATUSES,
|
||||
TRUST_STATES,
|
||||
tuple,
|
||||
} from "@app/shared-types";
|
||||
|
||||
@@ -113,4 +112,3 @@ export const creatorLevelEnum = pgEnum("creator_level", tuple(CREATOR_LEVELS));
|
||||
export const profileVisibilityEnum = pgEnum("profile_visibility", tuple(PROFILE_VISIBILITIES));
|
||||
export const notificationTypeEnum = pgEnum("notification_type", tuple(NOTIFICATION_TYPES));
|
||||
export const verificationStatusEnum = pgEnum("verification_status", tuple(VERIFICATION_STATUSES));
|
||||
export const trustStateEnum = pgEnum("trust_state", tuple(TRUST_STATES));
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { boolean, doublePrecision, integer, pgTable, text, uuid, varchar } from "drizzle-orm/pg-core";
|
||||
import { createdAt, updatedAt } from "./_shared.js";
|
||||
import { inventoryItems } from "./inventory.js";
|
||||
|
||||
/**
|
||||
* Inventory Trust Engine (Fas 2 §5.2): deterministiska decay-profiler.
|
||||
* En aktiv profil styr hur snabbt confidence sjunker med tiden.
|
||||
* Decay påverkar endast FÖRTROENDE, inte ätbarhet (mjölkprincipen D-035).
|
||||
*/
|
||||
export const inventoryDecayProfiles = pgTable("inventory_decay_profiles", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
name: varchar("name", { length: 64 }).notNull().unique(),
|
||||
description: text("description"),
|
||||
/** Antal dagar för confidence att halveras. */
|
||||
halfLifeDays: doublePrecision("half_life_days").notNull().default(7),
|
||||
/** Efter detta antal dagar anses posten vara stale oavsett ursprunglig confidence. */
|
||||
staleAfterDays: integer("stale_after_days").notNull().default(30),
|
||||
/** Sätt till false för att arkivera en profil utan att ta bort historiken. */
|
||||
active: boolean("active").notNull().default(true),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
});
|
||||
|
||||
/** Valbar FK från inventory_items till den profil som användes när posten skapades. */
|
||||
export const inventoryItemDecayProfile = pgTable("inventory_item_decay_profile", {
|
||||
inventoryItemId: uuid("inventory_item_id")
|
||||
.primaryKey()
|
||||
.references(() => inventoryItems.id, { onDelete: "cascade" }),
|
||||
decayProfileId: uuid("decay_profile_id")
|
||||
.notNull()
|
||||
.references(() => inventoryDecayProfiles.id, { onDelete: "restrict" }),
|
||||
});
|
||||
@@ -5,6 +5,7 @@ export * from "./households.js";
|
||||
export * from "./ingredients.js";
|
||||
export * from "./products.js";
|
||||
export * from "./inventory.js";
|
||||
export * from "./decay.js";
|
||||
export * from "./recipes.js";
|
||||
export * from "./translations.js";
|
||||
export * from "./markets.js";
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
timestamp,
|
||||
uuid,
|
||||
integer,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import type { NutritionDeclaration } from "@app/shared-types";
|
||||
import {
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
expiryStatusEnum,
|
||||
inventorySourceEnum,
|
||||
inventoryTransactionTypeEnum,
|
||||
trustStateEnum,
|
||||
unitEnum,
|
||||
updatedAt,
|
||||
} from "./_shared.js";
|
||||
@@ -64,7 +64,7 @@ export const inventoryItems = pgTable(
|
||||
lastVerifiedAt: timestamp("last_verified_at", { withTimezone: true }),
|
||||
expiryStatus: expiryStatusEnum("expiry_status").notNull().default("unknown"),
|
||||
/** Inventory Trust Engine state (Fas 2): computed from confidence, decay and verification. */
|
||||
trustState: trustStateEnum("trust_state").notNull().default("unverified"),
|
||||
trustState: varchar("trust_state", { length: 16 }).notNull().default("unverified"),
|
||||
/** Sätts när saldot nått 0 och posten arkiverats. */
|
||||
depletedAt: timestamp("depleted_at", { withTimezone: true }),
|
||||
modelVersion: text("model_version"),
|
||||
|
||||
@@ -590,6 +590,19 @@ async function main() {
|
||||
}
|
||||
console.log(`[seed] ${flags.length} feature flags`);
|
||||
|
||||
// 7. Default trust decay profile (Fas 2 §5.2)
|
||||
await db
|
||||
.insert(schema.inventoryDecayProfiles)
|
||||
.values({
|
||||
name: "default",
|
||||
description: "Standardprofil: confidence halveras efter 7 dagar, stale efter 30 dagar.",
|
||||
halfLifeDays: 7,
|
||||
staleAfterDays: 30,
|
||||
active: true,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
console.log("[seed] 1 default decay profile");
|
||||
|
||||
console.log("[seed] Klart.");
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
@@ -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; // 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.
|
||||
*
|
||||
@@ -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 (0–80 points)
|
||||
let score = Math.round(input.confidence * 80);
|
||||
// Decay confidence deterministically from the profile.
|
||||
const effectiveConfidence = applyDecay(input.confidence, elapsedDays, profile);
|
||||
|
||||
// Verification bonus (0–20 points)
|
||||
if (input.verifiedByUser && daysSinceVerification !== null && daysSinceVerification <= 7) {
|
||||
// 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 && 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). */
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user