Skip to content

@memhtml/domain

@memhtml/domain: the pure math under memhtml’s retrieval and sleep phases: cosine similarity and pairwise neighbor selection, reciprocal-rank fusion and maximal marginal relevance, PageRank and communities over the memory graph, the frame key, the retention scorer with its triage bands and reprieve gate, confidence decay on a fixed-point grid, the reinforcement cooldown, and the near-duplicate merge guards. Every export is synchronous, deterministic and free of I/O, so a full index rebuild or a sleep re-run reproduces the same numbers, and the package’s dist imports nothing but effect; tests/layering.test.ts is the standing proof.

The similarity space starts at cosine, which is total and clamped to [-1, 1]: a zero-magnitude vector, or one carrying a NaN or infinite component, reads 0 rather than NaN, because MMR folds a max over similarities and Math.max(penalty, NaN) disagrees with NaN > penalty. The clamp exists because subnormal magnitudes push the unclamped ratio to 1.000000106821595, mismatched lengths walk the shorter vector, and the argument type is ArrayLike so a Float32Array decoded off a stored blob is accepted as is, since this function is the body of the vector_distance_cos SQL function and runs once per candidate row. cosineDistance is 1 - similarity in [0, 2], the space the vector arm’s SQL works in, and compensatedSum is Neumaier summation, needed because naive addition of the retention weight profiles returns 0.9999999999999999 for two of the six. neighbors.ts is the n-by-n arm of the same space: the SQL boundary is priced per call and re-copies the corpus n times over, so consumers decode each vector once into a KeyedVector and topNeighborPairs computes every unordered pair’s similarity once, offers it to both endpoints’ neighborhoods (so (a, b) and (b, a) can both appear), keeps the per-source top perSourceK above floor, and orders the result sim descending, src ascending, dst ascending before the limit, the three knobs of NeighborOptions. Pair similarities are bit-identical to cosine because the square roots are hoisted but the operations run in the same order. rankCandidatePairs applies the same floor, per-source top-k and ordering to an enumerated CandidatePair set, the shape sharedEntityPairs in packages/sleep/src/sql.ts feeds it with for edge typing’s shared-entity candidates, and float32View views a row’s bytes as a Float32Array in place, copying only when the offset is misaligned and returning undefined for an empty or ragged blob rather than throwing. Each returned NeighborPair carries src, dst and sim.

Fusion is split between the production constants and a reference fold. ranking.ts holds RRF_K (60, the published default and the literal the retrieval SQL inlines), MMR_LAMBDA (0.5) and REINFORCE_COOLDOWN_S (900 seconds), plus rrfContribution, which returns an Option that is None for a zero-weight arm or an out-of-range rank so a disabled arm is absent from the fold rather than a neutral term. rrf.ts is the reference implementation, not the production path: packages/index/src/retrieval-sql.ts inlines the fusion arithmetic in SQL sharing only RRF_K, and this fold is what that SQL is checked against. An ArmHit carries a 1-based rank within its own arm’s list, rrfScore is weight / (rank + k), fuseArms sums contributions into a path -> score map (order-insensitive across arms, and a duplicate path inside one arm accumulates, matching the SQL’s SUM over a UNION ALL), rankFused orders best first with ties broken on path ascending, and fuseLateral folds a graph walk’s best-first order in as one more arm, returning the scores unchanged for an empty order or non-positive weight so the lateral arm is inert when dark; it is defined and tested here and has no production caller yet. applyMmr then reorders MmrCandidate rows greedily by lambda * relevance - (1 - lambda) * maxSimilarityToSelected with no embedding round-trip, since candidates carry their own vectors; at lambda >= 1 it returns the input order truncated, a vectorless candidate takes penalty 0 because it cannot be shown to duplicate anything, and each round folds only the newest selection into a cached per-candidate max, so the cost is O(k times n) dot products rather than O(k squared times n).

graph.ts replaces two networkx calls with deterministic equivalents, because these scores feed the retention pagerank and bridgeImportance signals and a run-to-run reordering would change which memories are evicted on an unchanged corpus: nodes are sorted before iteration, parallel edges fold to their maximum strength, and label propagation uses sorted order with lexicographic tie-breaking instead of a random seed. pagerank is weighted power iteration over GraphEdge rows (strength in [0, 1]) with PAGERANK_DAMPING 0.85 (the networkx default), PAGERANK_MAX_ITERATIONS 100 and PAGERANK_TOLERANCE 1e-6; passing seeds makes it personalized, the form a query-conditioned graph walk would use (the one production caller, the sleep retention pass in packages/sleep/src/retention.ts, passes none), seeds naming absent nodes are dropped, and dangling mass is redistributed over the teleport distribution so the scores sum to 1. labelPropagation reads the edges as undirected, canonicalizes each community to its smallest member path, and collapses communities below MIN_COMMUNITY_SIZE (3) to undefined within LABEL_PROPAGATION_MAX_SWEEPS (100), since a pair passed off as a community would make every cross-pair edge look like a bridge; bridgeCounts then counts each node’s edges that cross a community boundary, with a node in no community counting 0 rather than its full degree. frameKeyOf is the other structural key: it reads a claim as a frame (subject plus relation up to the last linking token among of, is, in, to, by, as) and a value, so “The capital of India is New Delhi” keys on the capital of india is and a later claim writing a different city into the same slot is recognizable without a model or an embedding. It fails closed to null unless the frame is at least three tokens and the value is one to six tokens, is case- and whitespace-insensitive, and is a verbatim port from the evaluation harness’s consolidate adapter, where the rule was measured against the benchmark’s conflict-resolution rows before it was believed; the tokens, thresholds and greedy match are fixed by that measurement, and tests/frame.test.ts carries the reference’s own cases.

retention.ts is the eight-signal scorer ported from the predecessor’s domain/retention.py. scoreRetention takes a RetentionInput of raw per-memory quantities (age in days, access count, confidence, PageRank and the run’s maximum, bridge count, inbound reinforcement count, word count, and authored contradiction count), normalizes them with computeSignals into the Signals named by SIGNAL_NAMES (recency, accessFrequency, confidence, pagerank, bridgeImportance, reinforcementCount, contentDensity, contestedStatus), weights them with compositeScore under the type’s WeightProfile from weightsFor, and bands the result with bandFor into a RetentionScore. WEIGHT_PROFILES has five typed profiles (episodic, semantic, procedural, arc, error_pattern) and every other type takes DEFAULT_WEIGHTS; each profile’s eight weights sum to exactly 1.0 under profileWeightSum, which is what keeps the composite in [0, 1], and SCORE_PRECISION rounds it to 4 places. HALF_LIVES_DAYS gives the recency half-life per type (episodic 10, semantic 90, arc 30, error_pattern 14, procedural and task null for no decay) with DEFAULT_HALF_LIFE_DAYS 30 for an unlisted type, and LN2 is Math.LN2 so the curve is exactly 0.5 at the half-life. The TRIAGE_ACTIONS are keep, compress and evict, with KEEP_THRESHOLD 0.7 and EVICT_THRESHOLD 0.3 and each boundary owned by the lower band, so exactly 0.7 compresses and exactly 0.3 evicts. The reprieve gate sits beside the scorer: reprieveScore sums four terms over a ReprieveInput with REPRIEVE_W_IMPORTANCE 0.4, REPRIEVE_W_ACCESS 0.3, REPRIEVE_W_OUTCOME 0.2 and REPRIEVE_W_RECENCY 0.1, decaying recency at SALIENCE_DECAY_RATE 0.01 per hour; the coefficients sum to 1.0 but the score is not convex because the log1p(accessCount) term is unbounded, and a negative outcome score contributes exactly 0 so a memory is not punished twice for one bad outcome. shouldReprieve is the boolean gate, with no side effect of its own: it is true when the score clears REPRIEVE_FLOOR (0.5) and the memory has been reprieved fewer than MAX_REPRIEVES (3) times, where 0 is the kill switch that restores pure-age TTL, and the sleep reprieve phase (packages/sleep/src/phases/reprieve.ts) is what then extends the memory’s memhtml-valid-until by REPRIEVE_DAYS (14).

decay.ts puts confidence decay and the outcome EWMA on a fixed-point grid of SCALE 10^4 (POS_ONE_FP and NEG_ONE_FP are +1.0 and -1.0 on it, toFp and fromFp convert) so the boundary claims hold exactly rather than within an ulp: ewmaStepFp is one step with floor division, applyOutcomesFp folds signals with one rounding per step so an N-signal batch equals N single calls, and applyNegativeHitsFp decays toward -1.0 over a count of negative corrections and is a no-op for hits <= 0. The confidence half is the production path, called by @memhtml/sleep‘s confidence-decay phase: decayConfidenceFp is an EWMA toward the floor followed by a min with the previous value, so decay is unconditionally non-increasing and never rehabilitates a claim an operator pushed below the floor, decayConfidenceNFp folds it over cycles, and decayConfidence and decayConfidenceN are the [0, 1] float wrappers defaulting to DEFAULT_CONFIDENCE_DECAY_ALPHA (0.1, a tenth of the remaining distance per sleep cycle) and DEFAULT_CONFIDENCE_FLOOR (0.2, because a decayed-to-zero memory would be indistinguishable from a retracted one). The outcome half is the reference implementation: packages/index/src/reinforce.ts computes the EWMA in float SQL with its own OUTCOME_EWMA_ALPHA twin of DEFAULT_EWMA_ALPHA (0.3), and both packages’ tests pin the pair. CONFIDENCE_COMMIT_DELTA (0.005) and isCommittableConfidenceChange drop a sub-threshold change so the widest commit of a sleep run stays reviewable. reinforce.ts is the cooldown predicate whose twin is the salience arm’s SQL guard: shouldBumpAccess is true when no stamp exists or the elapsed time is at least the cooldown (>=, matching the SQL, so a stamp exactly 900 seconds old is bumpable), partitionByCooldown splits paths into bumped and cooledDown in one order-preserving pass, and REINFORCE_SIGNALS (positive, negative, neutral) map through signalValue to 1, -1 and 0, where neutral bumps the access count without moving the outcome score because being read is evidence of relevance, not correctness. merge.ts guards the near-duplicate merge, which keeps the older file and so would fold a newer correction into an older wrong memory if it trusted cosine alone: negationDivergent, numericTokenDivergent and variantQualifierDivergent catch “safe” against “NOT safe”, “retry 3” against “retry 13” and “M1” against “M1 Pro”. Each memory is a MergeText of gist (the one-line claim) and body (the article), and the predicates read different ones: mergeVetoReasons runs the numeric predicate over the gist alone, because a body’s citations (a date, a ticket id, an exit code) differ between two honest tellings of one fact, and runs negation and variant over joinMergeText of both, because a polarity flip stated only in the body still contradicts. mergeVetoed is true when any reason fires. mergeOutcomes classifies oriented MergePair rows into a MergeStage each (cap, threshold, vetoed, self, role-guard, committed) by applying in order the MAX_MERGE_PAIRS cap (100 per sleep cycle), the strict NEAR_DUPLICATE_THRESHOLD (0.92), the veto when both texts are present, a self-merge check, and an in-batch role guard under which a dropped path is never dropped again or kept, and a keeper is never dropped, so a transitive chain cannot supersede content into a file the same batch archives; a keeper may keep again, so one page absorbs several duplicates in one batch. mergeCandidates is the committed subset as MergeDecision rows. The guards are a post-filter over every proposal including a model’s, so binding a model to dedup-merge cannot widen the committable set. connectedComponents is the order-invariant union-find whose components are dedup’s units of work (the smaller root always wins, so the same edge set yields the same partition in any arrival order), and excludeSelfSupersede removes the canonical from a compress batch’s member list so the file just folded into is not archived.

The package publishes one import path, @memhtml/domain, which re-exports the eleven modules below plus a type-only re-export of InvalidMemory from @memhtml/contracts/errors, so the reference on this page is the whole exported surface.

Module What it holds
cosine.ts cosine (total, clamped similarity), cosineDistance (1 - similarity) and compensatedSum (Neumaier summation).
decay.ts The grid (SCALE, POS_ONE_FP, NEG_ONE_FP, toFp, fromFp), the defaults (DEFAULT_EWMA_ALPHA, DEFAULT_CONFIDENCE_FLOOR, DEFAULT_CONFIDENCE_DECAY_ALPHA), the EWMA steps (ewmaStepFp, applyOutcomesFp, applyNegativeHitsFp), confidence decay (decayConfidenceFp, decayConfidenceNFp, decayConfidence, decayConfidenceN), and the commit gate (CONFIDENCE_COMMIT_DELTA, isCommittableConfidenceChange).
frame.ts frameKeyOf, the claim’s slot key or null.
graph.ts GraphEdge, the constants (PAGERANK_DAMPING, PAGERANK_MAX_ITERATIONS, PAGERANK_TOLERANCE, MIN_COMMUNITY_SIZE, LABEL_PROPAGATION_MAX_SWEEPS), pagerank, labelPropagation and bridgeCounts.
merge.ts NEAR_DUPLICATE_THRESHOLD, MAX_MERGE_PAIRS, the shapes MergeText, MergePair, MergeDecision, MergeOutcome and MergeStage, joinMergeText, the guards (negationDivergent, numericTokenDivergent, variantQualifierDivergent, mergeVetoReasons, mergeVetoed), mergeOutcomes, mergeCandidates, connectedComponents and excludeSelfSupersede.
mmr.ts MmrCandidate and applyMmr.
neighbors.ts The shapes KeyedVector, NeighborPair, NeighborOptions and CandidatePair, float32View, topNeighborPairs and rankCandidatePairs.
ranking.ts RRF_K, MMR_LAMBDA, REINFORCE_COOLDOWN_S and rrfContribution.
reinforce.ts shouldBumpAccess, REINFORCE_SIGNALS, ReinforceSignal, signalValue and partitionByCooldown.
retention.ts The signal and profile types (SIGNAL_NAMES, SignalName, Signals, WeightProfile, TRIAGE_ACTIONS, TriageAction), the tables (WEIGHT_PROFILES, DEFAULT_WEIGHTS, HALF_LIVES_DAYS, DEFAULT_HALF_LIFE_DAYS, LN2, KEEP_THRESHOLD, EVICT_THRESHOLD, SCORE_PRECISION), the scorer (RetentionInput, RetentionScore, weightsFor, halfLifeFor, profileWeightSum, computeSignals, compositeScore, bandFor, scoreRetention), and the reprieve gate (REPRIEVE_FLOOR, REPRIEVE_DAYS, MAX_REPRIEVES, SALIENCE_DECAY_RATE, REPRIEVE_W_IMPORTANCE, REPRIEVE_W_ACCESS, REPRIEVE_W_OUTCOME, REPRIEVE_W_RECENCY, ReprieveInput, reprieveScore, shouldReprieve).
rrf.ts ArmHit, rrfScore, fuseArms, rankFused and fuseLateral.
index.ts The eleven re-exports above plus the type-only re-export of InvalidMemory from @memhtml/contracts/errors, erased by verbatimModuleSyntax so dist still imports nothing but effect.

One arm’s hit. rank is a 1-based position within that one arm’s own candidate list, not a global rank and not a score. That is what lets RRF work across arms whose scores are incomparable.

readonly path: string;
readonly rank: number;

One enumerated candidate pair, oriented by the caller. Pairs must be distinct.

readonly dst: string;
readonly src: string;

One directed weighted edge. strength is unitless in [0, 1].

readonly dst: string;
readonly src: string;
readonly strength: number;

A memory that violates the file format or the type/placement vocabulary.

  • InvalidMemory_base
readonly reason: string;
InvalidMemory_base.reason

One decoded vector under the key the ranking reports. Keys must be unique.

readonly key: string;
readonly vec: Float32Array;

A committed merge: fold dropPath into keepPath.

readonly dropPath: string;
readonly keepPath: string;
readonly similarity: number;

One candidate pair’s fate. committed is the only stage that produces a MergeDecision.

readonly pair: MergePair;
readonly stage: MergeStage;

One oriented candidate pair. keepPath is already the canonical (the older file). The orientation is the caller’s, because it depends on commit dates this module does not read. The texts are optional; when either is absent the divergence guards are skipped.

readonly dropPath: string;
readonly optional dropText?: MergeText;
readonly keepPath: string;
readonly optional keepText?: MergeText;
readonly similarity: number;

The two texts the veto reads for one memory: its one-line claim (the <mark> gist) and the whole article’s text. The predicates do not all read the same one, and that is the point of the split.

numericTokenDivergent reads the GIST alone. A body is where a memory cites its evidence — a date, a pull-request or ticket id, an exit code, a cosine — and two honest tellings of one fact almost never cite the same numbers, so a numeric set-equality over the whole article vetoed nearly every true duplicate the store proposed (349 of 349 candidates over two consecutive runs, every review task naming this predicate). A number in the CLAIM is the fact itself: “retry 3” against “retry 13” is two claims, and the gist is where that difference lives.

negationDivergent and variantQualifierDivergent read the JOINED text, gist and body together. Their markers are rare, load-bearing words (“not”, “never”, “pro”, “deprecated”) that a body does not carry incidentally the way it carries a citation, and a polarity flip stated only in the body — the claim “run the cutover during business hours” over a body that says draining does NOT complete — is a contradiction the veto has to see. The two all-veto runs named neither predicate once.

readonly body: string;
readonly gist: string;

A candidate for diversification. score is its fused relevance (unitless, higher is better); vector is its embedding, or undefined when the chunk has no vector yet, which happens for a lexical-floor hit or a deferred embed.

readonly path: string;
readonly score: number;
readonly optional vector?: readonly number[];

The selection knobs every pair consumer states.

readonly floor: number;

Pairs below this similarity never enter ranking.

readonly limit: number;

Global cap, applied after the final sim DESC, src ASC, dst ASC ordering.

readonly perSourceK: number;

Nearest neighbors kept per src, ordered sim DESC then dst ASC.


One selected pair. sim is cosine similarity, unitless in [-1, 1].

readonly dst: string;
readonly sim: number;
readonly src: string;

What the reprieve score reads. Units and scopes:

  • importance: 1-based ordinal in [1, 10], clamped; divided by 10 before use.
  • accessCount: a quantity, per-memory lifetime, 0-based.
  • outcomeScore: unitless in [-1, 1]. A negative value contributes 0.
  • hoursSinceAccess: fractional hours, non-negative.
readonly accessCount: number;
readonly optional decayRate?: number;
readonly hoursSinceAccess: number;
readonly importance: number;
readonly outcomeScore: number;

The raw per-memory inputs the SQL phase gathers. Kept raw rather than pre-normalized so the normalization curves live in exactly one place, computeSignals.

Units and scopes, per field:

  • memoryType selects the weight profile and the half-life.
  • ageDays: fractional days since the memory’s own last update. Non-negative.
  • accessCount: a quantity, per-memory lifetime, 0-based.
  • confidence: unitless in [0, 1]; clamped rather than rejected.
  • graphRank / maxGraphRank: PageRank scores in the same run’s scale; maxGraphRank is that run’s maximum, so the ratio is corpus-relative, not absolute.
  • bridgeCount: a quantity of cross-community memory edges for this memory.
  • reinforcementCount: a quantity of inbound supports/reinforces edges.
  • wordCount: a quantity of words in the memory’s body.
  • contradictionCount: a quantity of authored (derived: false) contradictions; a sleep-mined suspicion is excluded upstream, so a machine guess cannot evict.
readonly accessCount: number;
readonly ageDays: number;
readonly bridgeCount: number;
readonly confidence: number;
readonly contradictionCount: number;
readonly graphRank: number;
readonly maxGraphRank: number;
readonly memoryType: string;
readonly reinforcementCount: number;
readonly wordCount: number;

The scoring result: the composite, its band, and the normalized signals behind it.

readonly action: "keep" | "compress" | "evict";
readonly score: number;
readonly signals: Signals;
type MergeStage = "cap" | "threshold" | "vetoed" | "self" | "role-guard" | "committed";

Where a candidate pair stopped in mergeOutcomes, in the filter’s own order.


type MergeVetoReason = "negation" | "numeric" | "variant";

The three divergence families, in the veto’s own disjunction order.


type ReinforceSignal = typeof REINFORCE_SIGNALS[number];

type SignalName = typeof SIGNAL_NAMES[number];

type Signals = { readonly [K in SignalName]: number };

A full set of normalized signal values, each unitless in [0, 1].


type TriageAction = typeof TRIAGE_ACTIONS[number];

type WeightProfile = { readonly [K in SignalName]: number };

A weight profile: one coefficient per signal, the eight summing to exactly 1.0.

const CONFIDENCE_COMMIT_DELTA: 0.005 = 0.005;

The smallest confidence change worth committing. Confidence decay is the widest commit in a sleep run, one meta line across many files, so a sub-threshold delta is dropped rather than committed, keeping the night’s diff reviewable.


const DEFAULT_CONFIDENCE_DECAY_ALPHA: 0.1 = 0.1;

The per-sleep-cycle confidence decay weight. Gentler than DEFAULT_EWMA_ALPHA on purpose: confidence should erode over many unreinforced nights rather than collapse in one, so a single missed reinforcement is forgiving. At 0.1 a claim closes a tenth of its distance to the floor per cycle.


const DEFAULT_CONFIDENCE_FLOOR: 0.2 = 0.2;

The floor confidence erodes toward but never past. A claim that stops being reinforced loses weight without vanishing: an old uncorroborated claim is weak evidence, not absent evidence, and a memory decayed to 0 would be indistinguishable from a retracted one.


const DEFAULT_EWMA_ALPHA: 0.3 = 0.3;

The outcome EWMA’s weight on an incoming signal. Its production twin is OUTCOME_EWMA_ALPHA in packages/index/src/reinforce.ts, the value the reinforce SQL binds; both packages’ tests pin their constant to 0.3 so a fork fails loudly.


const DEFAULT_HALF_LIFE_DAYS: 30 = 30;

Half-life in days for a type with no listed one.


const DEFAULT_WEIGHTS: WeightProfile;

The profile for a type with no dedicated one. Also sums to exactly 1.0.


const EVICT_THRESHOLD: 0.3 = 0.3;

const HALF_LIVES_DAYS: Readonly<Record<string, number | null>>;

Recency half-lives in days. null means no time decay. An unlisted type takes DEFAULT_HALF_LIFE_DAYS.


const KEEP_THRESHOLD: 0.7 = 0.7;

Band edges. KEEP is > 0.7, EVICT is <= 0.3, and COMPRESS is the open interval between. Each boundary is owned by the lower band: exactly 0.7 compresses, exactly 0.3 evicts. The three bands partition [0, 1] with no gap and no overlap.


const LABEL_PROPAGATION_MAX_SWEEPS: 100 = 100;

Label-propagation sweep cap. Sorted-order propagation settles well inside it.


const LN2: number = Math.LN2;

The recency decay constant. Math.LN2 rather than a 0.693 literal, so exp(-LN2 * age / halfLife) is exactly 0.5 at age == halfLife. The half-life is then the definition of the curve rather than an approximation of it, and a property test can assert the equality instead of a tolerance.


const MAX_MERGE_PAIRS: 100 = 100;

Merge decisions applied per sleep cycle, so a flood cannot fan the phase out unboundedly.


const MAX_REPRIEVES: 3 = 3;

Reprieves a memory may earn before it is forced to expire. 0 is the kill switch that restores pure-age TTL: the floor alone can never force expiry, because the reprieve score is a sum of non-negative terms and is therefore always above 0.


const MIN_COMMUNITY_SIZE: 3 = 3;

Communities smaller than this are not communities; compress batching ignores them.


const MMR_LAMBDA: 0.5 = 0.5;

Maximal-marginal-relevance’s relevance/diversity split.


const NEAR_DUPLICATE_THRESHOLD: 0.92 = 0.92;

Cosine similarity above which two bodies are the same content. Strict.


const NEG_ONE_FP: number = -SCALE;

const PAGERANK_DAMPING: 0.85 = 0.85;

PageRank teleport factor. The networkx default, kept from the predecessor memory system.


const PAGERANK_MAX_ITERATIONS: 100 = 100;

Power-iteration cap. Convergence at this graph’s scale is well inside it.


const PAGERANK_TOLERANCE: 0.000001 = 1e-6;

L1 convergence tolerance per node.


const POS_ONE_FP: 10000 = SCALE;

+1.0 and -1.0 on the grid. The outcome EWMA’s domain is [NEG_ONE_FP, POS_ONE_FP].


const REINFORCE_COOLDOWN_S: 900 = 900;

Seconds an access bump waits before it counts again. Stated once here and once in the salience arm’s SQL; a property test pins the two to agree at the boundary.


const REINFORCE_SIGNALS: readonly ["positive", "negative", "neutral"];

The signal a reinforcement carries. negative is what drives the outcome EWMA down.


const REPRIEVE_DAYS: 14 = 14;

Days a reprieve extends memhtml-valid-until by.


const REPRIEVE_FLOOR: 0.5 = 0.5;

The reprieve gate’s floor. A TTL-passed memory scoring at least this, under the reprieve cap, has its memhtml-valid-until extended instead of being archived.


const REPRIEVE_W_ACCESS: 0.3 = 0.3;

const REPRIEVE_W_IMPORTANCE: 0.4 = 0.4;

The four reprieve coefficients. They sum to 1.0 but the score is NOT convex.


const REPRIEVE_W_OUTCOME: 0.2 = 0.2;

const REPRIEVE_W_RECENCY: 0.1 = 0.1;

const RRF_K: 60 = 60;

Reciprocal-rank-fusion’s rank offset. 60 is the published default and the value the retrieval SQL inlines as a literal, so it lives here once and the assembler reads it rather than restating it.


const SALIENCE_DECAY_RATE: 0.01 = 0.01;

Salience decay rate per hour for the reprieve score’s recency term.


const SCALE: 10000 = 10_000;

The fixed-point scale: 10^4, so the grid step is the 4th decimal place.


const SCORE_PRECISION: 4 = 4;

Decimal places the composite is rounded to, matching the predecessor’s grain.


const SIGNAL_NAMES: readonly ["recency", "accessFrequency", "confidence", "pagerank", "bridgeImportance", "reinforcementCount", "contentDensity", "contestedStatus"];

The eight signals, in the fixed order every weight profile keys on.


const TRIAGE_ACTIONS: readonly ["keep", "compress", "evict"];

The triage verdict for one memory.


const WEIGHT_PROFILES: Readonly<Record<string, WeightProfile>>;

Per-type weight profiles. Every profile’s eight weights sum to exactly 1.0 under compensated summation, which is what makes the composite a convex combination of eight [0, 1] signals and therefore itself in [0, 1].

The five profiles below are the typed ones; every other memory type falls back to DEFAULT_WEIGHTS. Recency carries the most weight for episodic (time is that type’s identity) and zero for procedural (a working procedure does not stale).

function applyMmr(
candidates,
limit,
lambda?
): readonly MmrCandidate[];

Greedily reorder candidates by lambda * relevance - (1 - lambda) * maxSimilarityToSelected.

lambda is unitless in [0, 1]: 1 is pure relevance, 0 is pure diversity. At lambda >= 1 the function short-circuits and returns the input order truncated, because the penalty term is multiplied by zero and the greedy pass would otherwise burn O(n^2) cosines to reproduce the order it was given.

A candidate with no vector takes penalty 0, which is how “unknown similarity” reads here. A vectorless candidate cannot be shown to duplicate anything, so it is not penalized for it. Vectorless candidates therefore keep their relative fusion order among themselves rather than being shuffled by a fabricated distance.

The output is always a duplicate-free subsequence of the input by membership: each candidate is selected at most once and nothing is invented.

readonly MmrCandidate[]

number

number = MMR_LAMBDA

readonly MmrCandidate[]


function applyNegativeHitsFp(
alphaFp,
prevFp,
hits
): number;

Decay a score toward -1.0 over hits negative corrections. hits <= 0 is a no-op, so re-running a phase over an already-drained watermark writes the same value.

number

number

number

number


function applyOutcomesFp(
alphaFp,
prevFp,
signalsFp
): number;

Fold signals through ewmaStepFp, one rounding per step. Rounding inside the fold rather than once at the end is what makes an N-signal batch agree with N single-signal calls. Sleep may process a memory’s corrections in one batch or across several nights, and both paths must reach the same score.

number

number

readonly number[]

number


function bandFor(score): "keep" | "compress" | "evict";

The band a composite falls in. Boundaries belong to the lower band: 0.7 compresses and 0.3 evicts, so the bands partition [0, 1] and no score is ever unbanded.

number

"keep" | "compress" | "evict"


function bridgeCounts(
nodes,
edges,
communities
): ReadonlyMap<string, number>;

Per-node count of incident memory edges that cross a community boundary. This is the bridgeImportance signal’s raw input. A node in no community (below the size floor) has no boundary to cross, so its bridge count is 0 rather than its full degree; counting those would make every isolated pair look maximally structural.

readonly string[]

readonly GraphEdge[]

ReadonlyMap<string, string | undefined>

ReadonlyMap<string, number>


function compensatedSum(values): number;

Neumaier compensated summation. Naive left-to-right addition of the retention weight profiles gives 0.9999999999999999 for two of the six (verified in node), which would make a convexity assertion fail against a profile that is correct by construction. This is the math.fsum the ported scorer relies on.

Iterable<number>

number


function compositeScore(signals, profile): number;

The weighted composite, unitless in [0, 1], rounded to SCORE_PRECISION places. Compensated summation, so the fold does not accumulate the drift that makes a by-construction-convex profile score marginally above 1.

Signals

WeightProfile

number


function computeSignals(input): Signals;

Normalize the raw inputs to their [0, 1] signal values.

RetentionInput

Signals


function connectedComponents(edges): readonly readonly string[][];

Connected components over an undirected edge list, as sorted member lists.

The near-duplicate graph’s components are dedup’s units of work. A component is what “these memories might all be one memory” looks like before anything has judged them, and it is the right unit because near-duplication is transitive in practice: three rewordings of one fact produce three edges, and folding them one pair at a time would ask the same question three times and could answer it three different ways.

The partition is order-INVARIANT, not merely order-stable. A union always keeps the lexicographically smaller root, so every set’s root is the smallest key it holds no matter which order the edges arrive in. Members come back sorted, and components come back ordered by root, which is each component’s own smallest member. So the same edge SET produces the same output whether it arrives mined-first, frame-first, mirrored, or shuffled. That is stronger than sorting the input would be, and it is why no sort happens here: a caller cannot make this disagree with itself by changing how it enumerates.

A “larger root wins” or “first root seen wins” rule would break exactly that, because both make the surviving root a fact about arrival order rather than about the set.

Each pair is normalized before it is unioned, so (a, b) and (b, a) are one edge. A self-edge introduces its key and joins nothing. Cost is near-linear in the edge count, and no step here ever enumerates a pair the caller did not hand over.

readonly readonly [string, string][]

readonly readonly string[][]


function cosine(a, b): number;

Cosine similarity of two vectors, unitless and clamped to [-1, 1].

Total: every input maps to a number in range, and NaN is never returned. A zero-magnitude input yields 0, and so does a vector carrying a NaN or infinite component — the two conditions the arithmetic below cannot answer. 0 is the reading a vectorless candidate already gets in MMR: no usable direction, so no demonstrable duplication, so no penalty.

Returning NaN would be worse than returning a wrong number. MMR folds a max over the similarities to the already-selected set, and the two equivalent ways of writing that fold disagree on NaN — Math.max(penalty, NaN) is NaN while NaN > penalty is false — so a single degenerate embedding would make the result depend on which fold runs, not on the corpus.

The result is clamped because the unclamped ratio does not stay in range. Verified in node 2026-08-02: two vectors whose squared magnitudes fall into the subnormal range return 1.000000106821595. The squares underflow, so sqrt divides by a magnitude smaller than the true one. Similarity is also the input to 1 - similarity distance and to the MMR penalty, both of which state a range, so the clamp belongs here rather than at each reader.

Length mismatch is handled by walking the shorter vector rather than failing. The only way two stored vectors differ in length is a half-migrated embedding model, a condition the index refuses at the embed_model watermark, so it does not reach here.

ArrayLike rather than ReadonlyArray so a Float32Array decoded straight off a stored blob is an argument: this is the vector_distance_cos SQL function’s own body, called once per candidate row, and materialising two 1024-element arrays per call to satisfy a narrower type would allocate more than the arithmetic costs. Every array caller still fits.

ArrayLike<number>

ArrayLike<number>

number


function cosineDistance(a, b): number;

Cosine distance, 1 - similarity, unitless in [0, 2]. This is the space the vector arm’s SQL works in (vector_distance_cos), so a threshold stated as a similarity is converted once here rather than inverted at each call site.

ArrayLike<number>

ArrayLike<number>

number


function decayConfidence(
confidence,
alpha?,
floor?
): number;

One confidence-decay step in the [0, 1] float space the HTML memhtml-confidence meta uses. alpha is the fraction of the remaining distance to floor closed per cycle, both unitless in [0, 1].

number

number = DEFAULT_CONFIDENCE_DECAY_ALPHA

number = DEFAULT_CONFIDENCE_FLOOR

number


function decayConfidenceFp(
alphaFp,
confFp,
floorFp
): number;

One confidence-decay step on the grid: an EWMA toward the floor, then a min with the previous value.

The min is what makes the step unconditionally non-increasing. Without it, a claim already below the floor (one an operator correction pushed down, say) would be pulled back up toward the floor by the same convex combination that erodes a healthy claim, so decay would rehabilitate a discredited memory. The floor is a resting place for a claim that stops being reinforced. A refuted claim is not pulled back up to it.

number

number

number

number


function decayConfidenceN(
confidence,
cycles,
alpha?,
floor?
): number;

decayConfidence folded over cycles unreinforced sleep cycles.

number

number

number = DEFAULT_CONFIDENCE_DECAY_ALPHA

number = DEFAULT_CONFIDENCE_FLOOR

number


function decayConfidenceNFp(
alphaFp,
confFp,
floorFp,
cycles
): number;

Fold cycles decay steps. cycles <= 0 is a no-op, so a phase re-run within one night writes the same value.

number

number

number

number

number


function ewmaStepFp(
alphaFp,
prevFp,
signalFp
): number;

One EWMA step on the grid: alpha * signal + (1 - alpha) * prev, divided back down with floor division. Floor rather than round-half-away because it is the one rounding mode whose N-fold composition is reproducible without a rounding-mode argument, and the residual drift against an unrounded fold is bounded by one grid step.

For alphaFp in [0, SCALE] and both values in [NEG_ONE_FP, POS_ONE_FP] the result stays in that domain and lies between prevFp and signalFp, so a negative signal can never raise the score.

number

number

number

number


function excludeSelfSupersede(canonicalPath, memberPaths): readonly string[];

The compress-path exclusion: the members of a batch to supersede and archive, with the canonical removed and order preserved. When a batch folds into a pre-existing canonical, a member can be that canonical, and archiving it would destroy the file just folded into.

string

readonly string[]

readonly string[]


function float32View(bytes): Float32Array<ArrayBufferLike> | undefined;

A Float32Array over stored bytes, copying only when it must.

Float32Array requires a 4-byte-aligned byteOffset, and a driver row’s Uint8Array may be a view into a pooled buffer at any offset. Viewing in place is the common case and costs nothing. A misaligned or ragged blob is copied rather than rejected, because the vector arm’s job is to rank and a throw here would fail a whole search over one row.

Uint8Array

Float32Array<ArrayBufferLike> | undefined


function frameKeyOf(gist): string | null;

The frame key for one claim, or null when the claim states no frame+value shape this rule trusts. Pure and synchronous, with no clock, no randomness, no model, and no I/O, so a full index rebuild reproduces byte-identical keys by construction.

Case- and whitespace-insensitive, because a restated fact varies in both: "The Capital of India is X" and "the capital of India is Y" collide.

string

The claim text. In memhtml, this is a memory’s <mark> claim.

string | null

The lowercased frame, or null for no-frame-shape (stored as SQL NULL).


function fromFp(valueFp): number;

A grid value back to a float. Exact.

number

number


function fuseArms(
armResults,
weights,
k?
): ReadonlyMap<string, number>;

Fuse per-arm ranked lists into one path -> score map by summing contributions.

armResults[i] pairs with weights[i]; an arm with no weight scores 0 throughout. Addition commutes, so the result is order-insensitive across arms and the arm registry’s order is a presentation detail rather than a ranking input. A duplicate path inside one arm’s list accumulates, matching the SQL’s SUM over a UNION ALL.

readonly readonly ArmHit[][]

readonly number[]

number = RRF_K

ReadonlyMap<string, number>


function fuseLateral(
fused,
order,
weight,
k?
): ReadonlyMap<string, number>;

Fold a graph-walk result into existing fused scores, treating the walk’s best-first order as an arm’s ranked list. A node absent from fused enters at its lateral-only contribution, which is how a memory the lexical and vector arms both missed can still surface.

An empty order or a non-positive weight returns the scores unchanged, so the lateral arm is inert when dark and cannot fail on a cold graph.

ReadonlyMap<string, number>

readonly string[]

number

number = RRF_K

ReadonlyMap<string, number>


function halfLifeFor(memoryType): number | null;

The recency half-life in days for a memory type; null means no time decay.

string

number | null


function isCommittableConfidenceChange(before, after): boolean;

True when a decayed confidence differs enough from the stored one to be worth a commit.

number

number

boolean


function joinMergeText(text): string;

The joined text the negation and variant predicates read: the claim, a newline, the article.

MergeText

string


function labelPropagation(
nodes,
edges,
options?
): ReadonlyMap<string, string | undefined>;

Communities by label propagation over the edge list read as undirected.

Deterministic in place of a seed: nodes start labelled by themselves, sweeps visit them in sorted order, and a node adopts the highest-weighted neighboring label with the lexicographically smallest label breaking a tie. The returned labels are canonicalized to the smallest member path of each community, so the partition itself is reproducible and not only the grouping.

Communities below minCommunitySize collapse to undefined: they only feed compress batching and the bridge count, and a pair passed off as a community would make every cross-pair edge look like a bridge.

readonly string[]

readonly GraphEdge[]

number

number

ReadonlyMap<string, string | undefined>


function mergeCandidates(pairs, options?): readonly MergeDecision[];

Filter oriented candidate pairs into an in-batch-consistent decision list: the committed outcomes of mergeOutcomes, in input order, as decisions.

readonly MergePair[]

number

number

readonly MergeDecision[]


function mergeOutcomes(pairs, options?): readonly MergeOutcome[];

Classify every oriented candidate pair, applying in order: the per-cycle cap, the strict similarity threshold, the divergence veto (only when both texts are present), a self-merge check, and the in-batch role guard. mergeCandidates is the committed subset; this form exists so a caller can COUNT the refusals by cause. A phase that reported candidates - decisions as “vetoed” told an operator that a predicate fired on pairs no predicate had seen, which is how two all-refusing runs read as a divergence problem when half of them were role-guard skips.

The in-batch role guard needs the most explanation of the five. Every committed decision fixes its drop path as DROPPED and its keep path as KEPT for the rest of the batch, and three later uses are refused, each ruling out a distinct corruption on a transitive chain:

  • A path already dropped cannot be dropped again (two keepers would each believe they absorbed it) nor become a keeper (content folded into a file this same batch archives).
  • A path already a keeper cannot later be dropped. Given (gf, a) then (b, gf), both would commit, gf absorbs a and is then archived into b, so a’s content is superseded into a file that no longer exists. That is the loss the guard exists to prevent. Verified against the input [(gf → a), (b → gf)].

A path already a keeper MAY keep again. (k, a) then (k, b) is one page absorbing two duplicates in one batch, and nothing on that chain is archived twice or archived after being folded into: k survives with two supersedes links and both drops point at it. The earlier rule claimed BOTH roles of a committed pair, which made a model group of N members fold at most ONE of them per run — every pair a group implies names the same keeper — and reported the other N-2 as vetoes. The guard’s job is the two corruptions above, not a fold budget per keeper; the per-cycle cap is that.

Fixing roles rather than memberships keeps the output a function of input order alone. The first decision claiming a path in a role wins, matching the SQL result-set iteration upstream, and a later pair contradicting it is skipped rather than reordering anything.

readonly MergePair[]

number

number

readonly MergeOutcome[]


function mergeVetoed(textA, textB): boolean;

The veto: the disjunction of the three divergence predicates, each over its own text. Symmetric, pure, total. A vetoed pair is never a duplicate no matter its cosine.

MergeText

MergeText

boolean


function mergeVetoReasons(textA, textB): readonly MergeVetoReason[];

Which divergence predicates fire on a pair, each over the text it reads (see MergeText): negation and variant over the joined text, numeric over the gist alone. Symmetric, pure, total. Empty exactly when mergeVetoed is false. Exported so a caller that must NAME the refusal (the review task a vetoed pair becomes, the acceptance arm’s per-pair stage) reads the same scoping the filter applied, instead of re-deriving which text each predicate saw.

MergeText

MergeText

readonly MergeVetoReason[]


function negationDivergent(textA, textB): boolean;

True when exactly one side carries a negation marker: “X is safe” against “X is NOT safe”. Symmetric. Both sides negating, or neither, is not divergent.

string

string

boolean


function numericTokenDivergent(textA, textB): boolean;

True when the two texts carry different numeric tokens: “retry 3 times” against “retry 13 times”. Two texts with no numbers at all, or with identical numbers, do not trip it. Symmetric. mergeVetoReasons hands it the GIST of each memory, not the article — see MergeText for why.

string

string

boolean


function pagerank(
nodes,
edges,
options?
): ReadonlyMap<string, number>;

PageRank by power iteration, weighted, with a uniform or personalized teleport prior.

seeds present makes this personalized PageRank: the teleport distribution is the seed weights rather than uniform, so the scores describe reachability from the query’s own hits. That is what keeps the lateral retrieval arm query-conditioned. A uniform prior would inject the same hub memories into every query’s results regardless of what was asked. Seeds naming absent nodes are dropped, and an all-absent seed set falls back to the uniform prior, which the caller reads as “no lateral result”.

Dangling nodes (no outbound edges) have their mass redistributed over the teleport distribution, so the scores sum to 1 rather than leaking.

readonly string[]

readonly GraphEdge[]

number

number

ReadonlyMap<string, number>

number

ReadonlyMap<string, number>


function partitionByCooldown(
entries,
now,
cooldownSeconds?
): object;

Split paths into those whose access stamp may be bumped now and those still cooling down. One pass, order-preserving, so memory_reinforce can answer both lists from one call.

readonly object[]

Date

number = REINFORCE_COOLDOWN_S

object

readonly bumped: readonly string[];
readonly cooledDown: readonly string[];

function profileWeightSum(profile): number;

A profile’s weight sum under compensated summation. Every shipped profile returns exactly 1; this is the convexity fact the composite’s [0, 1] range rests on.

WeightProfile

number


function rankCandidatePairs(
pairs,
vectors,
options
): readonly NeighborPair[];

Rank an ENUMERATED pair set: similarity, floor, per-source top-k, final ordering, cap.

This is the shape for a consumer whose candidate pairs come from a selective predicate — the conflict scan’s shared-entity join — rather than from the whole pair space. The predicate runs BEFORE ranking, exactly as a WHERE inside the ranking CTE would, so per-source top-k is computed over passing pairs only. A pair naming a key with no vector contributes nothing, the same outcome the SQL join’s missing-embedding row produces.

readonly CandidatePair[]

readonly KeyedVector[]

NeighborOptions

readonly NeighborPair[]


function rankFused(fused): readonly object[];

Fused scores as a ranked list, best first. Ties break on path ascending, so the ordering is total and reproducible. Two runs over the same corpus produce the same list, which is what the discrimination gate compares against.

ReadonlyMap<string, number>

readonly object[]


function reprieveScore(input): number;

The four-term reprieve score. Deliberately not convex: the log1p(accessCount) term is unbounded, so the score can exceed 1. It is proven only monotone and sign-clamped.

A negative outcomeScore contributes exactly 0 and does not subtract, mirroring the salience arm’s max(coalesce(outcome_score, 0.0), 0.0). Without that clamp a memory that once produced a bad outcome would be punished twice, once by the outcome EWMA that already lowered its salience, and again here by having its reprieve pushed below the floor.

ReprieveInput

number


function rrfContribution(rank, weight): Option<number>;

One arm’s contribution to a fused score. rank is 1-based within that arm’s candidate list; weight is the arm’s configured multiplier.

A zero-weight arm and an out-of-range rank both yield None rather than 0, so a disabled arm is structurally absent from the fold instead of silently adding a neutral term that later arithmetic could mistake for a real score.

number

number

Option<number>


function rrfScore(
rank,
weight,
k?
): number;

One arm’s contribution: weight / (rank + k), unitless.

Strictly decreasing in rank and linear in weight, so a weight-0 arm contributes exactly 0 and is inert in the fold. k damps the difference between the top ranks, which is what stops one arm’s confident first place from dominating four other arms’ consensus.

number

number

number = RRF_K

number


function scoreRetention(input): RetentionScore;

Score one memory: normalize, weight by its type’s profile, band the composite.

RetentionInput

RetentionScore


function shouldBumpAccess(
lastAccessedAt,
now,
cooldownSeconds?
): boolean;

The reinforcement cooldown predicate. Its twin is the salience arm’s SQL guard:

WHERE last_accessed_at IS NULL
OR unixepoch('now') - unixepoch(last_accessed_at) >= 900

SQL cannot call this function, so the shared source of truth is the window constant REINFORCE_COOLDOWN_S and the boundary behavior is pinned by a property test on both sides. The >= here matches the SQL’s >=: a stamp exactly cooldownSeconds old is bumpable.

The cooldown exists because access_count feeds the salience RRF arm. Without it, replaying one query ten times would inflate that memory’s salience tenfold and let a loop in an agent rewrite the corpus’s ranking.

Date | undefined

Date

number = REINFORCE_COOLDOWN_S

boolean


function shouldReprieve(input): boolean;

The bounded reprieve gate. A TTL-passed memory is reprieved iff its score clears floor AND it has been reprieved fewer than maxReprieves times. maxReprieves: 0 forces every TTL-passed memory to expire regardless of score.

number

number

number

number

boolean


function signalValue(signal): number;

The outcome-EWMA signal value for a reinforcement, unitless in [-1, 1]. neutral is 0, so a neutral reinforcement bumps the access count without moving the outcome score. A memory being read is evidence of relevance, not of correctness.

"positive" | "negative" | "neutral"

number


function toFp(value): number;

A float in [-1, 1] onto the grid, rounded to the nearest grid point.

number

number


function topNeighborPairs(vectors, options): readonly NeighborPair[];

Per-source top-k nearest neighbors above a similarity floor, over every unordered pair.

Each pair’s similarity is computed ONCE and offered to BOTH endpoints’ neighborhoods, so the output can hold (a, b) and (b, a) — each is a fact about a different source’s neighborhood, and a consumer folding pairs must dedup the mirror itself (dedup-merge does, with its seen set). A floor comparison a NaN similarity cannot pass keeps a vector carrying NaN bytes out of every neighborhood rather than poisoning an ordering.

readonly KeyedVector[]

NeighborOptions

readonly NeighborPair[]


function variantQualifierDivergent(textA, textB): boolean;

True when the two bodies carry different variant qualifiers: “M1” against “M1 Pro”. Symmetric.

string

string

boolean


function weightsFor(memoryType): WeightProfile;

The weight profile for a memory type.

string

WeightProfile