/** * @module infra/graph-hebbian * * Hebbian strengthening for the graph: edges that are used together get * heavier, and unused weight melts away. "Fire together, wire together" — * when the koi keeps pulling the same two things into the same moment, the * map should bring them closer next time. * * WHAT LIVES WHERE: the pure math (decay, effective weight, spring bend) lives * here and has no I/O and no clock of its own — callers pass timestamps, so the * tests run without a network or fake timers. The one impure function, * {@link touchEdge}, is thin: it stamps the current time and hands a signed * 'graph.edge_touch' event to the existing outbox pipe. * * HOW IT REACHES THE PICTURE: a 'graph.edge_touch' event lands in the same * signed stream the rest of the graph already uses. A renderer watching that * stream folds touches into a bias map, then feeds the map to {@link bend} to * shorten the spring between things that keep firing together (see * docs/graph-view-contract.md). The renderer still owns the solver — these are * hints, not forces. */ import type { SKYKOIConfig } from "../config/config.js"; import { type GraphIdentity } from "./graph-access.js"; import type { SignedGraphEvent } from "./graph-event-outbox.js"; /** * Default half-life for edge weight: untouched for ~7 days, a touch is worth * half what it was. Injectable per call for tests and tuning. */ export declare const DEFAULT_HALF_LIFE_MS: number; /** The bump one touch adds — normalized so one touch right now reads as weight 1. */ export declare const TOUCH_INCREMENT = 1; /** Key format for bias maps: the pair key a touch writes and {@link bend} reads. */ export declare function pairKey(fromStableId: string, kind: string, toStableId: string): string; /** * Exponential decay toward zero. Pure: pass explicit timestamps so it runs * with no clock. Monotonic (older = lower), bounded by the input weight, and * exactly halves every half-life. * * decayWeight(w, t, t + halfLife) === w / 2 * decayWeight(w, t, t) === w * decayWeight(w, t, t + n·halfLife) === w / 2ⁿ */ export declare function decayWeight(weight: number, lastTouchMs: number, nowMs: number, halfLifeMs?: number): number; /** * The weight of a touched edge right now: what survived of the old weight plus * the new bump. Composes decay + accumulation into the one step consumers need. */ export declare function touchAccumulatedWeight(priorWeight: number, lastTouchMs: number, nowMs: number, halfLifeMs?: number): number; /** * A bias map: pairKey → { weight, lastTouchMs } as a renderer (or an analysis) * folds 'graph.edge_touch' events out of the stream. */ export type BiasMap = Map; /** Fold one touch into a bias map (decay the old + bump). Mutates and returns it. */ export declare function applyTouch(bias: BiasMap, fromStableId: string, kind: string, toStableId: string, nowMs: number): BiasMap; /** * The spring-length multiplier between two nodes given what fired together — * the "wire together" half. A force solver has a rest length for every spring; * this returns how much to shrink it (1 = untouched, → {@link MIN_BEND_FACTOR} * as the pair keeps firing together). * * Pure and strictly pair-wise: only touches between THESE TWO nodes (in either * direction, on any edge kind) pull them closer — a third node firing with one * of them bends nothing here, or everything would collapse into everything * popular. Returns 1 when they never fired together, so untouched springs keep * the solver's natural look, and saturates smoothly at {@link MIN_BEND_FACTOR} * so even a heavily fired pair stays usable and never collapses to a point. * * bend(bias, a, b, base, now) → base × multiplier, never below base × MIN_BEND_FACTOR */ export declare const MIN_BEND_FACTOR = 0.4; /** weight at which the bend saturates — beyond this, more touching changes nothing. */ export declare const BEND_SATURATION = 8; export declare function bendFactor(bias: BiasMap, fromA: string, toB: string, nowMs: number, halfLifeMs?: number): number; /** * The shortened rest length for the spring between two nodes: base length × the * bend factor. The renderer calls this per spring with its own base length. */ export declare function bend(bias: BiasMap, fromA: string, toB: string, baseLength: number, nowMs: number, halfLifeMs?: number): number; /** * Record that two things were just pulled into the same moment: append a signed * 'graph.edge_touch' event and try to flush it now (same pipe as every other * graph event, so a renderer watching the stream sees the touch in real time). * 'kind' is the edge relationship being strengthened (defaults to "references" * for a bare co-occurrence with no declared relationship). * * Accepts an explicit identity for tests; otherwise resolves from config/env — * unlinked returns an error string rather than throwing. */ export declare function touchEdge(from: string, to: string, kind?: string, opts?: { config?: SKYKOIConfig | null; identity?: GraphIdentity; nowMs?: number; }): Promise<{ ok: true; weightHint: number; synced: number; queued: SignedGraphEvent[]; } | { ok: false; error: string; }>;