/
GreyCat
/
openhuman
Обзор
Документация
Войти
/
GreyCat
/
openhuman
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
app/src/lib/memory/memoryFreshness.ts
174 строки
6 KB
Aashir Athar
feat(intelligence): add Knowledge Freshness (#2932)
30 май 2026, 17:43
Не верифицирован
30 май 2026, 17:43
26f418a
Код
Авторство
О чём код?
/** * Knowledge Freshness — pure decay-scoring engine. * * Every fact the assistant remembers is a (subject)-[predicate]->(object) triple * that was last reinforced at `updatedAt`. Human memory of an un-rehearsed fact * decays along a forgetting curve; this engine applies the same idea to the * assistant's stored facts so the UI can surface what is going STALE and should * be re-confirmed, rather than treating every stored fact as equally certain. * * recall(t) = 2 ^ (-ageDays / halfLifeDays) * - ageDays = days since the fact was last reinforced (updatedAt) * - halfLifeDays = DEFAULT_HALF_LIFE_DAYS * (1 + log2(max(1, evidenceCount))) * * A fact corroborated by more evidence decays more slowly (a longer half-life), * with diminishing returns (log2). recall is 1.0 the moment a fact is recorded * and approaches 0 as it ages without reinforcement. * * Everything here is PURE and DETERMINISTIC. The engine never reads the clock: * the reference time `nowSeconds` is injected by the caller, so the same inputs * always yield the same report and every branch is unit-testable. */ import type { GraphRelation } from '../../utils/tauriCommands/memory'; export type FreshnessStatus = 'fresh' | 'fading' | 'stale'; export interface FactFreshness { id: string; // stable composite key (subject/predicate/object), JSON-encoded subject: string; predicate: string; object: string; evidenceCount: number; updatedAt: number; // epoch seconds the fact was last reinforced ageDays: number; // days since updatedAt (>= 0) halfLifeDays: number; // evidence-scaled half-life recall: number; // 0..1 recall probability now status: FreshnessStatus; } export interface FreshnessReport { facts: FactFreshness[]; // all facts, most stale first (recall asc, id asc) staleQueue: FactFreshness[]; // non-fresh facts only, same order (re-confirm queue) freshCount: number; fadingCount: number; staleCount: number; total: number; averageRecall: number; // mean recall across all facts (0 when none) } export interface FreshnessOptions { halfLifeDays?: number; // base half-life for an evidenceCount of 1 freshThreshold?: number; // recall >= this => 'fresh' fadingThreshold?: number; // recall >= this (and < fresh) => 'fading', else 'stale' } export const DEFAULT_HALF_LIFE_DAYS = 30; export const FRESH_THRESHOLD = 0.7; export const FADING_THRESHOLD = 0.3; const SECONDS_PER_DAY = 86400; /** Evidence multiplier on the half-life: more corroboration decays slower. */ export function strengthFactor(evidenceCount: number): number { const ec = Number.isFinite(evidenceCount) && evidenceCount > 1 ? evidenceCount : 1; return 1 + Math.log2(ec); } /** Recall probability for a given age and half-life; clamped to [0, 1]. */ export function recallProbability(ageDays: number, halfLifeDays: number): number { if (!(halfLifeDays > 0)) return ageDays <= 0 ? 1 : 0; const age = ageDays > 0 ? ageDays : 0; const recall = 2 ** (-age / halfLifeDays); if (recall > 1) return 1; if (recall < 0) return 0; return recall; } /** Classify a recall probability into a freshness band. */ export function classify( recall: number, freshThreshold = FRESH_THRESHOLD, fadingThreshold = FADING_THRESHOLD ): FreshnessStatus { if (recall >= freshThreshold) return 'fresh'; if (recall >= fadingThreshold) return 'fading'; return 'stale'; } /** Stable, collision-free key for a triple (no raw separators). */ function factKey(subject: string, predicate: string, object: string): string { return JSON.stringify([subject, predicate, object]); } /** * Compute the freshness report. Pure function of (relations, nowSeconds). * Duplicate triples are collapsed to the freshest occurrence (max updatedAt, * then max evidenceCount) so a fact is scored once at its strongest signal. */ export function computeFreshness( relations: GraphRelation[], nowSeconds: number, options: FreshnessOptions = {} ): FreshnessReport { const baseHalfLife = options.halfLifeDays ?? DEFAULT_HALF_LIFE_DAYS; const freshThreshold = options.freshThreshold ?? FRESH_THRESHOLD; const fadingThreshold = options.fadingThreshold ?? FADING_THRESHOLD; // 1. Collapse duplicate triples to their freshest, strongest occurrence. const bestByKey = new Map<string, GraphRelation>(); for (const relation of relations) { const { subject, predicate, object } = relation; if ( typeof subject !== 'string' || typeof predicate !== 'string' || typeof object !== 'string' ) { continue; } const key = factKey(subject, predicate, object); const existing = bestByKey.get(key); if ( !existing || relation.updatedAt > existing.updatedAt || (relation.updatedAt === existing.updatedAt && relation.evidenceCount > existing.evidenceCount) ) { bestByKey.set(key, relation); } } // 2. Score each fact. const facts: FactFreshness[] = []; let recallSum = 0; let freshCount = 0; let fadingCount = 0; let staleCount = 0; for (const [key, relation] of bestByKey) { const evidenceCount = Number.isFinite(relation.evidenceCount) && relation.evidenceCount > 0 ? relation.evidenceCount : 1; const halfLifeDays = baseHalfLife * strengthFactor(evidenceCount); const ageDays = Math.max(0, (nowSeconds - relation.updatedAt) / SECONDS_PER_DAY); const recall = recallProbability(ageDays, halfLifeDays); const status = classify(recall, freshThreshold, fadingThreshold); recallSum += recall; if (status === 'fresh') freshCount += 1; else if (status === 'fading') fadingCount += 1; else staleCount += 1; facts.push({ id: key, subject: relation.subject, predicate: relation.predicate, object: relation.object, evidenceCount, updatedAt: relation.updatedAt, ageDays, halfLifeDays, recall, status, }); } // 3. Sort most-stale-first (recall asc), stable id tie-break. facts.sort((a, b) => a.recall - b.recall || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); const total = facts.length; return { facts, staleQueue: facts.filter(f => f.status !== 'fresh'), freshCount, fadingCount, staleCount, total, averageRecall: total === 0 ? 0 : recallSum / total, }; }