Graph Decay
graph_decayApply time-based decay to node confidence and edge weights using per-type half-lives to maintain knowledge graph relevance. Preview changes with dry_run before irreversible decay.
Instructions
Apply time-based decay to every node confidence and edge weight using per-type half-lives (preferences ~693d, events ~99d, etc.). Called by the dream process during maintenance. Always preview with dry_run=true first — decay is irreversible without restoring from a graph_export backup. Returns counts of nodes/edges modified per type.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| dry_run | No | Preview only, don't apply changes (default: false) |
Implementation Reference
- src/mcp-server/index.ts:555-572 (registration)Registration of the graph_decay tool via server.registerTool('graph_decay', ...). Defines input schema (dry_run boolean), description, and the handler that calls client.applyDecay().
// ─── Tool: graph_decay ─── server.registerTool("graph_decay", { title: "Graph Decay", description: "Apply time-based decay to every node confidence and edge weight using per-type half-lives (preferences ~693d, events ~99d, etc.). Called by the dream process during maintenance. Always preview with dry_run=true first — decay is irreversible without restoring from a graph_export backup. Returns counts of nodes/edges modified per type.", inputSchema: { dry_run: z.boolean().optional().default(false).describe("Preview only, don't apply changes (default: false)"), }, annotations: { destructiveHint: true }, }, async (args) => { try { const result = await client.applyDecay(currentTenant(), args.dry_run ?? false); return toolResult(result); } catch (err) { return toolError(`graph_decay failed: ${err instanceof Error ? err.message : String(err)}`); } }); - src/shared/neo4j-client.ts:834-911 (handler)Core implementation of applyDecay() in Neo4jClient. Iterates per-type decay rates, applies exponential decay formula to node confidence and edge weights based on time since last_seen/last_confirmed. Also counts nodes flagged for pruning. Supports dry_run mode.
async applyDecay(tenantId: string, dryRun = false): Promise<{ nodes_decayed: number; edges_decayed: number; nodes_flagged_for_pruning: number; }> { const config = getConfig(); let totalNodesDecayed = 0; let totalEdgesDecayed = 0; if (dryRun) { for (const [type] of Object.entries(config.decay.rates)) { const rows = await this.run( ` MATCH (n:\`${type}\` {tenant_id: $tenantId}) WHERE n.last_seen < datetime() - duration('P1D') RETURN count(n) AS count `, { tenantId }, ); totalNodesDecayed += Number(rows[0]?.["count"] ?? 0); } } else { for (const [type, rate] of Object.entries(config.decay.rates)) { const rows = await this.run( ` MATCH (n:\`${type}\` {tenant_id: $tenantId}) WHERE n.last_seen < datetime() - duration('P1D') // duration.inDays() forces an all-days representation; using .days // on the normalized duration.between() would drop the months // component (30 days back → "1 month + 0 days" → 0-day decay). WITH n, n.confidence * ($rate ^ duration.inDays(n.last_seen, datetime()).days) AS new_conf SET n.confidence = CASE WHEN new_conf < 0.01 THEN 0.01 ELSE new_conf END RETURN count(n) AS decayed `, { tenantId, rate }, ); totalNodesDecayed += Number(rows[0]?.["decayed"] ?? 0); } // Decay edges (both endpoints must be in tenant) const edgeRows = await this.run( ` MATCH (a:Entity {tenant_id: $tenantId})-[r]->(b:Entity {tenant_id: $tenantId}) WHERE r.last_confirmed < datetime() - duration('P1D') AND r.weight IS NOT NULL WITH r, r.weight * ($rate ^ duration.inDays(r.last_confirmed, datetime()).days) AS new_weight SET r.weight = CASE WHEN new_weight < 0.01 THEN 0.01 ELSE new_weight END RETURN count(r) AS decayed `, { tenantId, rate: config.decay.edge_rate }, ); totalEdgesDecayed = Number(edgeRows[0]?.["decayed"] ?? 0); } // Count nodes flagged for pruning (tenant-scoped) const pruneRows = await this.run( ` MATCH (n:Entity {tenant_id: $tenantId}) WHERE n.confidence < $threshold OPTIONAL MATCH (n)-[r]-(other:Entity {tenant_id: $tenantId}) WITH n, max(r.weight) AS max_edge_weight WHERE max_edge_weight IS NULL OR max_edge_weight < $edgeThreshold RETURN count(n) AS flagged `, { tenantId, threshold: config.decay.prune_node_threshold, edgeThreshold: config.decay.prune_edge_threshold, }, ); const nodesFlagged = Number(pruneRows[0]?.["flagged"] ?? 0); return { nodes_decayed: totalNodesDecayed, edges_decayed: totalEdgesDecayed, nodes_flagged_for_pruning: nodesFlagged, }; } - src/shared/config.ts:23-29 (schema)Decay configuration shape: per-type rates (Person, Project, Preference, Concept, Decision, Fact, Event, Object), edge_rate, and pruning thresholds.
decay: { rates: Record<string, number>; edge_rate: number; prune_node_threshold: number; prune_edge_threshold: number; prune_orphan_days: number; }; - src/shared/config.ts:63-78 (helper)Default decay rates used by the decay algorithm — exponential decay factors per entity type and for edges.
decay: { rates: { Person: 0.998, Project: 0.995, Preference: 0.999, Concept: 0.999, Decision: 0.997, Fact: 0.996, Event: 0.993, Object: 0.996, }, edge_rate: 0.997, prune_node_threshold: 0.1, prune_edge_threshold: 0.05, prune_orphan_days: 30, }, - src/shared/dream-audit.ts:118-122 (helper)Audit event type definition for 'decay_applied' events, logged when decay executes.
| (BaseEvent & { event: "decay_applied"; nodes_affected: number; edges_affected: number; })