agent-working-memory 0.8.7 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +207 -46
- package/dist/api/routes.js +1 -1
- package/dist/cli/migrate.js +29 -29
- package/dist/cli.js +1 -1
- package/dist/coordination/circuit-breaker.js +23 -23
- package/dist/core/write-pipeline.d.ts.map +1 -1
- package/dist/core/write-pipeline.js +17 -0
- package/dist/core/write-pipeline.js.map +1 -1
- package/dist/engine/activation.d.ts +28 -0
- package/dist/engine/activation.d.ts.map +1 -1
- package/dist/engine/activation.js +341 -11
- package/dist/engine/activation.js.map +1 -1
- package/dist/engine/connections.d.ts +12 -0
- package/dist/engine/connections.d.ts.map +1 -1
- package/dist/engine/connections.js +95 -0
- package/dist/engine/connections.js.map +1 -1
- package/dist/mcp.js +2 -2
- package/dist/storage/pglite-schema.js +143 -143
- package/dist/storage/pglite.js +138 -138
- package/dist/types/engram.d.ts +1 -0
- package/dist/types/engram.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/api/index.ts +3 -3
- package/src/api/routes.ts +1 -1
- package/src/cli/migrate.ts +307 -307
- package/src/cli.ts +1 -1
- package/src/coordination/circuit-breaker.ts +83 -83
- package/src/coordination/failure-modes.ts +50 -50
- package/src/core/decay.ts +63 -63
- package/src/core/embeddings.ts +110 -110
- package/src/core/index.ts +5 -5
- package/src/core/logger.ts +36 -36
- package/src/core/ml-worker-entry.ts +194 -194
- package/src/core/ml-worker.ts +281 -281
- package/src/core/query-expander.ts +122 -122
- package/src/core/reranker.ts +119 -119
- package/src/core/write-pipeline.ts +15 -0
- package/src/engine/activation.ts +328 -11
- package/src/engine/confidence.ts +120 -120
- package/src/engine/connections.ts +94 -0
- package/src/engine/consolidation-scheduler.ts +242 -242
- package/src/engine/eval.ts +102 -102
- package/src/engine/eviction.ts +101 -101
- package/src/engine/index.ts +8 -8
- package/src/engine/retraction.ts +366 -366
- package/src/engine/staging.ts +74 -74
- package/src/mcp.ts +2 -2
- package/src/storage/factory.ts +147 -147
- package/src/storage/index.ts +3 -3
- package/src/storage/pglite-schema.ts +166 -166
- package/src/storage/pglite.ts +1363 -1363
- package/src/storage/store.ts +80 -80
- package/src/types/agent.ts +67 -67
- package/src/types/checkpoint.ts +46 -46
- package/src/types/engram.ts +1 -0
- package/src/types/eval.ts +100 -100
- package/src/types/index.ts +6 -6
package/src/engine/activation.ts
CHANGED
|
@@ -180,7 +180,12 @@ export class ActivationEngine {
|
|
|
180
180
|
const limit = query.limit ?? 10;
|
|
181
181
|
const minScore = query.minScore ?? 0.01; // Default: filter out zero-relevance results
|
|
182
182
|
const useReranker = query.useReranker ?? true;
|
|
183
|
-
|
|
183
|
+
// Default OFF (rerank-only): query expansion ~doubles recall latency (it inflates the rerank
|
|
184
|
+
// candidate pool) for no measured accuracy gain — validated no-regression on LoCoMo
|
|
185
|
+
// (overall 22.8→22.7, adversarial 73.5→73.4), the 4-suite eval (identical), and the MWA
|
|
186
|
+
// gauntlet. Callers can still opt in per-query (useExpansion:true); AWM_DEFAULT_EXPANSION=1
|
|
187
|
+
// restores expansion-by-default globally as an escape hatch.
|
|
188
|
+
const useExpansion = query.useExpansion ?? process.env.AWM_DEFAULT_EXPANSION === '1';
|
|
184
189
|
const abstentionThreshold = query.abstentionThreshold ?? 0;
|
|
185
190
|
const requireConfidence = query.requireConfidence ?? 0;
|
|
186
191
|
const adaptive = resolveAdaptiveParams(query);
|
|
@@ -333,6 +338,54 @@ export class ActivationEngine {
|
|
|
333
338
|
candidates = candidates.filter(e => e.memoryType === query.memoryType);
|
|
334
339
|
}
|
|
335
340
|
|
|
341
|
+
// ── ENTITY-AWARE CANDIDATE FETCH (2026-06, fixes the buried 2-hop / sparse-cue gap) ──
|
|
342
|
+
// A query like "codename for my main project" strongly recalls "main project = Atlas" but the
|
|
343
|
+
// ANSWER ("Atlas codename = Magpie") is a different-vocabulary attribute fact that falls out
|
|
344
|
+
// of the candidate pool — and rerank can't rescue what isn't in the pool. AWM doesn't form
|
|
345
|
+
// entity-co-occurrence edges, so graph-walk can't bridge it either. Fix: from the strongest
|
|
346
|
+
// seeds, pull the proper-noun ENTITIES that aren't already in the query, run ONE cheap local
|
|
347
|
+
// BM25 pass on them, and add the hits to the candidate pool. RECALL-ONLY: these only become
|
|
348
|
+
// candidates the reranker can consider — the final top-K stays rerank-gated, so this never
|
|
349
|
+
// surfaces facts you don't need (preserves AWM's precision-first design). Fast (one BM25
|
|
350
|
+
// call), local (no network/LLM), capped. DEFAULT-OFF (opt-in AWM_ENTITY_FETCH=1): verified
|
|
351
|
+
// recall-only pool-injection is INSUFFICIENT at scale — the buried fact enters the pool but
|
|
352
|
+
// still ranks below distractors against the vocab-mismatched original query, and a ranking
|
|
353
|
+
// boost would trade precision (against AWM's precision-first design). The design-aligned fix
|
|
354
|
+
// is HARNESS-side multi-hop decomposition (LLM chains sequential single-hop recalls). Kept
|
|
355
|
+
// opt-in for future spreading-activation experiments.
|
|
356
|
+
if (process.env.AWM_ENTITY_FETCH === '1' && candidates.length > 0) {
|
|
357
|
+
const ENT_SEEDS = Number(process.env.AWM_ENTITY_FETCH_SEEDS ?? 5);
|
|
358
|
+
const ENT_CAP = Number(process.env.AWM_ENTITY_FETCH_CAP ?? 30);
|
|
359
|
+
const STOPCAPS = new Set(['my', 'the', 'a', 'an', 'i', 'we', 'you', 'he', 'she', 'it', 'they', 'this', 'that', 'what', 'when', 'where', 'who', 'why', 'how', 'is', 'are', 'was', 'were', 'do', 'does', 'project', 'account', 'codename', 'name', 'team', 'main', 'internal']);
|
|
360
|
+
const seeds = candidates
|
|
361
|
+
.map(e => ({ e, s: Math.max(bm25ScoreMap.get(e.id) ?? 0, rawCosineSims.get(e.id) ?? 0) }))
|
|
362
|
+
.sort((a, b) => b.s - a.s).slice(0, ENT_SEEDS);
|
|
363
|
+
const ents = new Set<string>();
|
|
364
|
+
for (const { e } of seeds) {
|
|
365
|
+
for (const match of `${e.concept} ${e.content}`.matchAll(/\b[A-Z][A-Za-z]{2,}\b/g)) {
|
|
366
|
+
const w = match[0]; const lw = w.toLowerCase();
|
|
367
|
+
if (!queryTokens.has(lw) && !STOPCAPS.has(lw)) ents.add(w);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (ents.size > 0) {
|
|
371
|
+
try {
|
|
372
|
+
const entHits = await this.store.searchBM25WithRankMultiAgent(agentIds, Array.from(ents).slice(0, 8).join(' '), ENT_CAP);
|
|
373
|
+
let added = 0;
|
|
374
|
+
for (const h of entHits) {
|
|
375
|
+
if (added >= ENT_CAP) break;
|
|
376
|
+
const n = h.engram;
|
|
377
|
+
if (candidateMap.has(n.id)) continue;
|
|
378
|
+
if (n.stage !== 'active' || (n as any).retracted || n.supersededBy) continue;
|
|
379
|
+
if (query.memoryType && n.memoryType !== query.memoryType) continue;
|
|
380
|
+
candidateMap.set(n.id, n);
|
|
381
|
+
bm25ScoreMap.set(n.id, Math.max(bm25ScoreMap.get(n.id) ?? 0, h.bm25Score)); // scoring sees it
|
|
382
|
+
added++;
|
|
383
|
+
}
|
|
384
|
+
if (added > 0) candidates = Array.from(candidateMap.values());
|
|
385
|
+
} catch { /* best-effort: entity fetch never breaks recall */ }
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
336
389
|
if (candidates.length === 0) return [];
|
|
337
390
|
|
|
338
391
|
// Phase 3b: Score each candidate with per-phase breakdown
|
|
@@ -378,7 +431,9 @@ export class ActivationEngine {
|
|
|
378
431
|
let vectorMatch = 0;
|
|
379
432
|
const rawSim = rawCosineSims.get(engram.id);
|
|
380
433
|
if (rawSim !== undefined && rawSim > 0) {
|
|
381
|
-
const SIM_FLOOR = adaptive.zScoreGate > 0.5
|
|
434
|
+
const SIM_FLOOR = adaptive.zScoreGate > 0.5
|
|
435
|
+
? Number(process.env.AWM_SIM_FLOOR_TARGETED ?? 0.50)
|
|
436
|
+
: Number(process.env.AWM_SIM_FLOOR_EXPLORATORY ?? 0.35);
|
|
382
437
|
if (rawSim > SIM_FLOOR) {
|
|
383
438
|
// Map [SIM_FLOOR, 1.0] → [0, 1] linearly with cap at 1.0.
|
|
384
439
|
vectorMatch = Math.min(1, (rawSim - SIM_FLOOR) / (0.95 - SIM_FLOOR));
|
|
@@ -479,7 +534,9 @@ export class ActivationEngine {
|
|
|
479
534
|
let vm = 0;
|
|
480
535
|
const rs = rawCosineSims.get(engram.id) ?? (queryEmbedding && engram.embedding ? cosineSimilarity(queryEmbedding, engram.embedding) : 0);
|
|
481
536
|
if (rs > 0) {
|
|
482
|
-
const SIM_FLOOR = adaptive.zScoreGate > 0.5
|
|
537
|
+
const SIM_FLOOR = adaptive.zScoreGate > 0.5
|
|
538
|
+
? Number(process.env.AWM_SIM_FLOOR_TARGETED ?? 0.50)
|
|
539
|
+
: Number(process.env.AWM_SIM_FLOOR_EXPLORATORY ?? 0.35);
|
|
483
540
|
if (rs > SIM_FLOOR) vm = Math.min(1, (rs - SIM_FLOOR) / (0.95 - SIM_FLOOR));
|
|
484
541
|
}
|
|
485
542
|
const tm = km > 0 && vm > 0
|
|
@@ -532,6 +589,10 @@ export class ActivationEngine {
|
|
|
532
589
|
// Skip non-entity tags: turn IDs, session tags, dialogue IDs, generic speaker labels
|
|
533
590
|
if (/^t\d+$/.test(t) || t.startsWith('session-') || t.startsWith('dia_') || t.length < 3) continue;
|
|
534
591
|
if (/^speaker\d*$/.test(t)) continue; // Generic speaker labels are too broad
|
|
592
|
+
// Auto-tagger `cat:` category tags are too broad to bridge on (they'd link
|
|
593
|
+
// every "cat:work" memory laterally); they stay for BM25 recall only. The
|
|
594
|
+
// precise `entity:` proper-noun tags are kept as bridges.
|
|
595
|
+
if (t.startsWith('cat:')) continue;
|
|
535
596
|
entityTags.add(t);
|
|
536
597
|
}
|
|
537
598
|
}
|
|
@@ -595,11 +656,74 @@ export class ActivationEngine {
|
|
|
595
656
|
}
|
|
596
657
|
}
|
|
597
658
|
|
|
659
|
+
// Phase 3.75: Query-conditioned entity bridge (default-OFF, AWM_QUERY_BRIDGE=1).
|
|
660
|
+
//
|
|
661
|
+
// The anchor-based bridge above (Phase 3.7) is query-BLIND: it bridges from the
|
|
662
|
+
// top text-match result's tags and a document-frequency filter DELETES common
|
|
663
|
+
// tags (e.g. a speaker present in >30% of turns). That is exactly backwards for
|
|
664
|
+
// attribution / entity-named queries: if the user asks "what does Caroline think
|
|
665
|
+
// about the trip" or "who said the trip moved to Saturday", the speaker/entity the
|
|
666
|
+
// query NAMES is the single most valuable bridge — its corpus frequency is
|
|
667
|
+
// irrelevant. This phase extracts proper-noun entities from the QUERY and boosts
|
|
668
|
+
// candidates whose tags match them, regardless of frequency, gated by topical
|
|
669
|
+
// relevance (textMatch floor) so it surfaces "Caroline's turns ABOUT the trip"
|
|
670
|
+
// rather than every Caroline turn. Boost folds into composite → survives rerank.
|
|
671
|
+
// Recall-only re-ranking of in-pool candidates (no injection) → low precision risk.
|
|
672
|
+
if (process.env.AWM_QUERY_BRIDGE === '1') {
|
|
673
|
+
const QSTOP = new Set(['what', 'who', 'when', 'where', 'why', 'how', 'which', 'whose', 'whom',
|
|
674
|
+
'the', 'this', 'that', 'these', 'those', 'and', 'but', 'for', 'did', 'does', 'is', 'are',
|
|
675
|
+
'was', 'were', 'how', 'tell', 'about', 'they', 'them']);
|
|
676
|
+
const qEnts = new Set<string>();
|
|
677
|
+
for (const m of query.context.matchAll(/\b[A-Z][a-zA-Z]{2,}\b/g)) {
|
|
678
|
+
const w = m[0].toLowerCase();
|
|
679
|
+
if (!QSTOP.has(w)) qEnts.add(w);
|
|
680
|
+
}
|
|
681
|
+
if (qEnts.size > 0) {
|
|
682
|
+
const QC_WEIGHT = Number(process.env.AWM_QUERY_BRIDGE_WEIGHT ?? 0.4);
|
|
683
|
+
const QC_CAP = Number(process.env.AWM_QUERY_BRIDGE_CAP ?? 0.4);
|
|
684
|
+
const QC_FLOOR = Number(process.env.AWM_QUERY_BRIDGE_FLOOR ?? 0.1);
|
|
685
|
+
for (const item of scored) {
|
|
686
|
+
if (item.phaseScores.textMatch < QC_FLOOR) continue; // only re-rank topically-relevant candidates
|
|
687
|
+
let matches = 0;
|
|
688
|
+
for (const tag of item.engram.tags) {
|
|
689
|
+
const t = tag.toLowerCase();
|
|
690
|
+
const val = t.startsWith('entity:') ? t.slice(7) : t;
|
|
691
|
+
// match whole-tag or any word of a multi-word entity ("marcus lee" ← "Marcus")
|
|
692
|
+
if (qEnts.has(val) || val.split(/\s+/).some(w => qEnts.has(w))) { matches++; }
|
|
693
|
+
}
|
|
694
|
+
if (matches > 0) {
|
|
695
|
+
// Relevance-modulated: scale by the candidate's topical relevance so
|
|
696
|
+
// "named-entity AND on-topic" wins big while "named-entity but off-topic
|
|
697
|
+
// chatter" (a common speaker tag on an irrelevant turn) gets almost
|
|
698
|
+
// nothing. Without this, a broad speaker tag floods the top with the
|
|
699
|
+
// person's unrelated turns (verified 2026-06-16 _query-bridge-verify).
|
|
700
|
+
const boost = Math.min(matches * QC_WEIGHT * item.phaseScores.textMatch, QC_CAP);
|
|
701
|
+
item.score += boost;
|
|
702
|
+
item.phaseScores.composite += boost;
|
|
703
|
+
item.phaseScores.graphBoost += boost;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
598
709
|
// Phase 4+5: Graph walk — boost engrams connected to high-scoring ones
|
|
599
710
|
// Only walk from engrams that had text relevance (composite > 0 pre-walk)
|
|
600
711
|
const sorted = scored.sort((a, b) => b.score - a.score);
|
|
601
|
-
|
|
602
|
-
|
|
712
|
+
// Candidate breadth carried into graph-walk + rerank. Default 8×limit (was 3×).
|
|
713
|
+
// WHY 8× (2026-06-16): the pipeline-attribution trace showed ~50% of answerable LoCoMo
|
|
714
|
+
// queries had gold that CLEARED the floor (89%) but was squeezed out HERE by the
|
|
715
|
+
// decay-compressed composite before the (high-lift, +3.29) reranker saw it — the
|
|
716
|
+
// dominant loss. Widening this + the rerank pool (below) lifted official LoCoMo
|
|
717
|
+
// 22.7→25.1 (every recall category up), 4-suite unchanged, recall 35→77ms; small
|
|
718
|
+
// adversarial cost 73.4→71.0 (a fixed step, recoverable on the abstention gate).
|
|
719
|
+
// Tunable via AWM_TOPN_MULT.
|
|
720
|
+
const topNMult = Number(process.env.AWM_TOPN_MULT ?? 8);
|
|
721
|
+
const topN = sorted.slice(0, limit * topNMult);
|
|
722
|
+
if (process.env.AWM_SPREAD === '1' && query.spread !== false) {
|
|
723
|
+
await this.spreadActivation(topN);
|
|
724
|
+
} else {
|
|
725
|
+
await this.graphWalk(topN, 2, adaptive.hopPenalty, adaptive.beamWidth);
|
|
726
|
+
}
|
|
603
727
|
|
|
604
728
|
// Phase 6: Initial filter and sort for re-ranking pool
|
|
605
729
|
const pool = topN
|
|
@@ -608,8 +732,15 @@ export class ActivationEngine {
|
|
|
608
732
|
|
|
609
733
|
// Phase 7: Cross-encoder re-ranking — scores (query, passage) pairs directly
|
|
610
734
|
// Widens the pool to find relevant results that keyword matching missed.
|
|
611
|
-
//
|
|
612
|
-
|
|
735
|
+
// How many candidates reach the cross-encoder. Default max(limit*4, 40) — widened
|
|
736
|
+
// from max(limit*2, 15) on 2026-06-16. WHY: the reranker rarely loses gold (0.5%) and
|
|
737
|
+
// lifts it +3.29, but the weak composite was only passing it ~35% of retrievable gold;
|
|
738
|
+
// feeding it more recovered the dominant lost@pool/scoring bucket. Validated knee on
|
|
739
|
+
// recall × precision × latency (pool 40 ≈ 25.1% LoCoMo / 71.0% adv / 77ms; pool 60 adds
|
|
740
|
+
// only +0.6pp for +33ms). The composite is now a CHEAP WIDE PRE-FILTER, not the ranker —
|
|
741
|
+
// the reranker does discrimination on a wide pool. Tunable via AWM_RERANK_POOL.
|
|
742
|
+
const rerankPoolSize = Number(process.env.AWM_RERANK_POOL ?? Math.max(limit * 4, 40));
|
|
743
|
+
const rerankPool = pool.slice(0, rerankPoolSize);
|
|
613
744
|
|
|
614
745
|
// Reranker skip heuristic (0.7.10+): if BM25 already has a clear winner with
|
|
615
746
|
// strong absolute score AND a meaningful gap to the runner-up, the cross-encoder
|
|
@@ -677,18 +808,37 @@ export class ActivationEngine {
|
|
|
677
808
|
// Phase 8: Multi-channel OOD detection + agreement gate
|
|
678
809
|
// Requires at least 2 of 3 retrieval channels to agree the query is in-domain.
|
|
679
810
|
if (rerankPool.length >= 3) {
|
|
680
|
-
|
|
811
|
+
// Abstention gate scope (2026-06-16): the in-domain channel maxes used to be taken
|
|
812
|
+
// over the ENTIRE rerankPool. Once that pool was widened for recall (pool 40), a lone
|
|
813
|
+
// high-scoring distractor inflated the maxes and defeated abstention on adversarial
|
|
814
|
+
// queries (adversarial 73.4→71.0). Fix: judge in-domain on the post-rerank TOP-K —
|
|
815
|
+
// the items we'd actually return — so pool width (recall) is decoupled from the
|
|
816
|
+
// abstention decision (precision). AWM_ABSTAIN_GATE_K controls K (0 = legacy
|
|
817
|
+
// whole-pool behavior). Answerable queries are unaffected: the gold is in the top-K
|
|
818
|
+
// and supplies the in-domain signal; only borderline distractors deep in a wide pool
|
|
819
|
+
// stop counting.
|
|
820
|
+
// Default 5 (2026-06-16): judge in-domain on the post-rerank top-5. With the widened
|
|
821
|
+
// rerank pool, basing it on the whole pool (legacy AWM_ABSTAIN_GATE_K=0) let a lone
|
|
822
|
+
// deep distractor defeat abstention; top-5 restored adversarial 71.0→74.9 (ABOVE the
|
|
823
|
+
// pre-widening 73.4) at ZERO recall cost (answerable categories unchanged) — the
|
|
824
|
+
// precision half of the two-dial pool-widening win.
|
|
825
|
+
const gateK = Number(process.env.AWM_ABSTAIN_GATE_K ?? 5);
|
|
826
|
+
const gatePool = gateK > 0
|
|
827
|
+
? [...rerankPool].sort((a, b) => b.score - a.score).slice(0, gateK)
|
|
828
|
+
: rerankPool;
|
|
829
|
+
|
|
830
|
+
const topBM25 = Math.max(...gatePool.map(r => bm25ScoreMap.get(r.engram.id) ?? 0));
|
|
681
831
|
const topVector = queryEmbedding
|
|
682
|
-
? Math.max(...
|
|
832
|
+
? Math.max(...gatePool.map(r => r.phaseScores.vectorMatch))
|
|
683
833
|
: 0;
|
|
684
|
-
const topReranker = Math.max(...
|
|
834
|
+
const topReranker = Math.max(...gatePool.map(r => r.phaseScores.rerankerScore));
|
|
685
835
|
|
|
686
836
|
const bm25Ok = topBM25 > 0.3;
|
|
687
837
|
const vectorOk = topVector > 0.05;
|
|
688
838
|
const rerankerOk = topReranker > 0.25;
|
|
689
839
|
const channelsAgreeing = (bm25Ok ? 1 : 0) + (vectorOk ? 1 : 0) + (rerankerOk ? 1 : 0);
|
|
690
840
|
|
|
691
|
-
const rerankerScores =
|
|
841
|
+
const rerankerScores = gatePool
|
|
692
842
|
.map(r => r.phaseScores.rerankerScore)
|
|
693
843
|
.sort((a, b) => b - a);
|
|
694
844
|
const margin = rerankerScores.length >= 2
|
|
@@ -1006,6 +1156,173 @@ export class ActivationEngine {
|
|
|
1006
1156
|
}
|
|
1007
1157
|
}
|
|
1008
1158
|
|
|
1159
|
+
/**
|
|
1160
|
+
* R2 — bounded iterative spreading activation (PPR / SYNAPSE-style).
|
|
1161
|
+
*
|
|
1162
|
+
* Default-OFF (`AWM_SPREAD=1`). The principled, in-AWM successor to the
|
|
1163
|
+
* fixed depth-2 beam `graphWalk` and the MWA harness bridge: it runs T
|
|
1164
|
+
* iterations of **fan-normalized** spreading with **lateral inhibition** and
|
|
1165
|
+
* a **restart** term (Personalized PageRank) over the association graph —
|
|
1166
|
+
* richest when R1's `AWM_BROAD_EDGES` entity edges are present.
|
|
1167
|
+
*
|
|
1168
|
+
* Two effects, both precision-guarded:
|
|
1169
|
+
* - **Boost** existing pool candidates by the *graph evidence* they receive
|
|
1170
|
+
* (convergent multi-path activation, not a single spurious hop).
|
|
1171
|
+
* - **Inject** (`AWM_SPREAD_INJECT=1`) strongly-reached *out-of-pool*
|
|
1172
|
+
* engrams as recall-only candidates so the reranker can see true
|
|
1173
|
+
* multi-hop bridges that BM25/vector missed. Their composite carries the
|
|
1174
|
+
* graph-activation signal (blended with rerank), which is what lets a
|
|
1175
|
+
* vocab-mismatched bridge surface where the prior `AWM_ENTITY_FETCH`
|
|
1176
|
+
* recall-only injection (rerank-only) could not.
|
|
1177
|
+
*
|
|
1178
|
+
* Precision is preserved because spreading is **seeded by the initial
|
|
1179
|
+
* retrieval**: adversarial "is this even in memory?" queries have weak/empty
|
|
1180
|
+
* seeds, so nothing meaningful propagates and abstention is unaffected.
|
|
1181
|
+
* Fan-normalization stops hubs from flooding; lateral inhibition keeps only
|
|
1182
|
+
* the top-M activated nodes per step; a node budget bounds cost; injected
|
|
1183
|
+
* candidates stay rerank-gated and the pool-level OOD agreement gate is
|
|
1184
|
+
* unaffected (seeds still supply the BM25/vector channels).
|
|
1185
|
+
*/
|
|
1186
|
+
private async spreadActivation(
|
|
1187
|
+
topN: { engram: Engram; score: number; phaseScores: PhaseScores; associations: Association[] }[],
|
|
1188
|
+
): Promise<void> {
|
|
1189
|
+
const T = Number(process.env.AWM_SPREAD_ITERS ?? 3);
|
|
1190
|
+
const delta = Number(process.env.AWM_SPREAD_DAMPING ?? 0.5);
|
|
1191
|
+
const NODE_BUDGET = Number(process.env.AWM_SPREAD_BUDGET ?? 64);
|
|
1192
|
+
const BOOST_SCALE = Number(process.env.AWM_SPREAD_BOOST ?? 0.4);
|
|
1193
|
+
const PER_NODE_CAP = 0.15;
|
|
1194
|
+
const MAX_TOTAL_BOOST = 0.25;
|
|
1195
|
+
const inject = process.env.AWM_SPREAD_INJECT === '1';
|
|
1196
|
+
const INJECT_THRESHOLD = Number(process.env.AWM_SPREAD_INJECT_MIN ?? 0.08);
|
|
1197
|
+
const INJECT_BUDGET = Number(process.env.AWM_SPREAD_INJECT_CAP ?? 8);
|
|
1198
|
+
const INJECT_SCALE = Number(process.env.AWM_SPREAD_INJECT_SCALE ?? 1.0);
|
|
1199
|
+
const EPS = 0.01;
|
|
1200
|
+
// 'invalidation' edges link superseded→replacement; excluded so spreading
|
|
1201
|
+
// never pulls stale facts back in.
|
|
1202
|
+
const allowed = new Set(['connection', 'hebbian', 'temporal', 'causal', 'bridge']);
|
|
1203
|
+
|
|
1204
|
+
const scoreMap = new Map(topN.map(s => [s.engram.id, s]));
|
|
1205
|
+
|
|
1206
|
+
// Seed activation from query-relevant candidates (textMatch gate), normalized to [0,1].
|
|
1207
|
+
const seed = new Map<string, number>();
|
|
1208
|
+
let maxSeed = 0;
|
|
1209
|
+
for (const item of topN) {
|
|
1210
|
+
if (item.phaseScores.textMatch >= 0.15) {
|
|
1211
|
+
const v = Math.max(0, item.score);
|
|
1212
|
+
seed.set(item.engram.id, v);
|
|
1213
|
+
if (v > maxSeed) maxSeed = v;
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
if (seed.size === 0 || maxSeed <= 0) return;
|
|
1217
|
+
for (const [k, v] of seed) seed.set(k, v / maxSeed);
|
|
1218
|
+
|
|
1219
|
+
const edgeCache = new Map<string, Association[]>();
|
|
1220
|
+
const getEdges = async (id: string): Promise<Association[]> => {
|
|
1221
|
+
let e = edgeCache.get(id);
|
|
1222
|
+
if (!e) {
|
|
1223
|
+
e = (await this.store.getAssociationsFor(id)).filter(a => allowed.has(a.type));
|
|
1224
|
+
edgeCache.set(id, e);
|
|
1225
|
+
}
|
|
1226
|
+
return e;
|
|
1227
|
+
};
|
|
1228
|
+
|
|
1229
|
+
let act = new Map(seed);
|
|
1230
|
+
// Cumulative inflow received from the graph (excludes a node's own seed) —
|
|
1231
|
+
// this is the multi-hop "evidence" signal used for boost + injection.
|
|
1232
|
+
const graphActivation = new Map<string, number>();
|
|
1233
|
+
|
|
1234
|
+
for (let t = 0; t < T; t++) {
|
|
1235
|
+
const inflow = new Map<string, number>();
|
|
1236
|
+
for (const [u, au] of act) {
|
|
1237
|
+
if (au <= EPS) continue;
|
|
1238
|
+
const edges = await getEdges(u);
|
|
1239
|
+
if (edges.length === 0) continue;
|
|
1240
|
+
let fan = 0;
|
|
1241
|
+
for (const e of edges) fan += Math.max(0, e.weight);
|
|
1242
|
+
if (fan <= 0) continue;
|
|
1243
|
+
for (const e of edges) {
|
|
1244
|
+
const v = e.fromEngramId === u ? e.toEngramId : e.fromEngramId;
|
|
1245
|
+
const share = Math.max(0, e.weight) / fan; // fan-effect normalization
|
|
1246
|
+
inflow.set(v, (inflow.get(v) ?? 0) + au * share);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
for (const [v, f] of inflow) graphActivation.set(v, (graphActivation.get(v) ?? 0) + f);
|
|
1250
|
+
|
|
1251
|
+
// Restart (PPR): blend propagated inflow with the original seed vector.
|
|
1252
|
+
const newAct = new Map<string, number>();
|
|
1253
|
+
const keys = new Set<string>([...act.keys(), ...inflow.keys()]);
|
|
1254
|
+
for (const v of keys) {
|
|
1255
|
+
const val = (1 - delta) * (seed.get(v) ?? 0) + delta * (inflow.get(v) ?? 0);
|
|
1256
|
+
if (val > EPS) newAct.set(v, val);
|
|
1257
|
+
}
|
|
1258
|
+
// Lateral inhibition: keep only the top-M activated nodes (competition + cost bound).
|
|
1259
|
+
if (newAct.size > NODE_BUDGET) {
|
|
1260
|
+
act = new Map([...newAct.entries()].sort((a, b) => b[1] - a[1]).slice(0, NODE_BUDGET));
|
|
1261
|
+
} else {
|
|
1262
|
+
act = newAct;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// Normalize graph evidence to [0,1] so the boost magnitude is scale-stable
|
|
1267
|
+
// (raw `ga` accumulates across iterations + bidirectional edges, so its
|
|
1268
|
+
// absolute scale varies with graph density). The top-reached node maps to 1.0.
|
|
1269
|
+
let maxGa = 0;
|
|
1270
|
+
for (const ga of graphActivation.values()) if (ga > maxGa) maxGa = ga;
|
|
1271
|
+
const normGa = (id: string): number => (maxGa > 0 ? (graphActivation.get(id) ?? 0) / maxGa : 0);
|
|
1272
|
+
|
|
1273
|
+
if (process.env.AWM_SPREAD_DEBUG === '1') {
|
|
1274
|
+
const top = [...graphActivation.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
|
|
1275
|
+
process.stderr.write(`[spread] seeds=${seed.size} reached=${graphActivation.size} inPool=${[...graphActivation.keys()].filter(id => scoreMap.has(id)).length} maxGa=${maxGa.toFixed(3)}\n`);
|
|
1276
|
+
for (const [id, ga] of top) {
|
|
1277
|
+
const e = scoreMap.get(id)?.engram;
|
|
1278
|
+
process.stderr.write(`[spread] ga=${ga.toFixed(3)} norm=${normGa(id).toFixed(2)} pool=${scoreMap.has(id)} ${e ? e.concept.slice(0, 40) : '(out-of-pool ' + id.slice(0, 8) + ')'}\n`);
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// Boost existing candidates by the (normalized) graph evidence they received.
|
|
1283
|
+
// Folded into `composite` (NOT just `score`) so it survives the rerank blend
|
|
1284
|
+
// — the reranker recomputes score from composite, so a score-only boost would
|
|
1285
|
+
// be discarded. This makes spreading a first-class multi-hop ranking signal.
|
|
1286
|
+
for (const [id] of graphActivation) {
|
|
1287
|
+
const item = scoreMap.get(id);
|
|
1288
|
+
if (!item) continue;
|
|
1289
|
+
const boost = Math.min(normGa(id) * BOOST_SCALE, PER_NODE_CAP);
|
|
1290
|
+
const capped = Math.min(boost, MAX_TOTAL_BOOST - item.phaseScores.graphBoost);
|
|
1291
|
+
if (capped > 0.001) {
|
|
1292
|
+
item.phaseScores.composite += capped;
|
|
1293
|
+
item.score += capped;
|
|
1294
|
+
item.phaseScores.graphBoost += capped;
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// Inject strongly-reached out-of-pool engrams as recall-only candidates.
|
|
1299
|
+
if (inject) {
|
|
1300
|
+
const reached = [...graphActivation.entries()]
|
|
1301
|
+
.filter(([id]) => !scoreMap.has(id) && normGa(id) >= INJECT_THRESHOLD)
|
|
1302
|
+
.sort((a, b) => b[1] - a[1])
|
|
1303
|
+
.slice(0, INJECT_BUDGET);
|
|
1304
|
+
for (const [id] of reached) {
|
|
1305
|
+
const engram = await this.store.getEngram(id);
|
|
1306
|
+
if (!engram || engram.stage !== 'active') continue;
|
|
1307
|
+
if ((engram as unknown as { retracted?: boolean }).retracted || engram.supersededBy) continue;
|
|
1308
|
+
const composite = Math.min(0.6, normGa(id) * INJECT_SCALE);
|
|
1309
|
+
const phaseScores: PhaseScores = {
|
|
1310
|
+
textMatch: 0,
|
|
1311
|
+
vectorMatch: 0,
|
|
1312
|
+
decayScore: 0,
|
|
1313
|
+
hebbianBoost: 0,
|
|
1314
|
+
graphBoost: composite,
|
|
1315
|
+
confidenceGate: engram.confidence,
|
|
1316
|
+
composite,
|
|
1317
|
+
rerankerScore: 0,
|
|
1318
|
+
};
|
|
1319
|
+
const injected = { engram, score: composite, phaseScores, associations: [] as Association[] };
|
|
1320
|
+
topN.push(injected);
|
|
1321
|
+
scoreMap.set(id, injected);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1009
1326
|
/**
|
|
1010
1327
|
* Resolve validation-gated Hebbian update for a specific engram.
|
|
1011
1328
|
* Called by memory_feedback — only strengthens when retrieval was useful.
|
package/src/engine/confidence.ts
CHANGED
|
@@ -1,120 +1,120 @@
|
|
|
1
|
-
// Copyright 2026 Robert Winter / Complete Ideas
|
|
2
|
-
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
-
/**
|
|
4
|
-
* Retrieval confidence — score-distribution-aware signal that complements
|
|
5
|
-
* the per-result `score`. The shape of the result set carries information
|
|
6
|
-
* the raw scores do not:
|
|
7
|
-
*
|
|
8
|
-
* - Confident recall: top-1 dominates, sharp cliff, non-trivial floor.
|
|
9
|
-
* - Noisy recall: many similar scores, flat distribution, weak floor.
|
|
10
|
-
* - "Best of bad bunch": sharp cliff but the cliff sits below a usable
|
|
11
|
-
* floor — the system found a winner among uninteresting candidates.
|
|
12
|
-
*
|
|
13
|
-
* Research grounding:
|
|
14
|
-
* - Geifman & El-Yaniv, "Selective Classification for Deep Neural
|
|
15
|
-
* Networks" (NeurIPS 2017): abstaining improves precision on confused
|
|
16
|
-
* inputs more than recalibrating thresholds.
|
|
17
|
-
* - Roitero et al, "Predictive Confidence in Retrieval" (SIGIR 2022):
|
|
18
|
-
* score-distribution shape predicts retrieval quality better than
|
|
19
|
-
* top-1 score in isolation.
|
|
20
|
-
* - Carmel & Yom-Tov, "Estimating Query Difficulty for IR" (Synthesis
|
|
21
|
-
* Lectures, 2010): post-retrieval predictors — sharpness, depth of
|
|
22
|
-
* score drop — correlate with TREC topic difficulty.
|
|
23
|
-
*
|
|
24
|
-
* AWM 0.8.5 integration: confidence is computed once per recall after
|
|
25
|
-
* final scoring and attached to every `ActivationResult`. Consumers may
|
|
26
|
-
* use it however they like (display, abstention, paired retrieval).
|
|
27
|
-
* Default behavior of recall is unchanged — confidence is data, not a
|
|
28
|
-
* gate, in PR-1.
|
|
29
|
-
*
|
|
30
|
-
* Configurable via env vars (initial weights tuned to favour sharpness):
|
|
31
|
-
* AWM_CONF_SHARPNESS_W (default 0.4) — weight of top1/mean(top5) signal
|
|
32
|
-
* AWM_CONF_CLIFF_W (default 0.3) — weight of (top1 - top10) / top1
|
|
33
|
-
* AWM_CONF_FLOOR_W (default 0.3) — weight of top1 absolute score
|
|
34
|
-
*/
|
|
35
|
-
|
|
36
|
-
export interface RecallConfidence {
|
|
37
|
-
/** Composite confidence in [0, 1]. Higher = recall result is more trustworthy. */
|
|
38
|
-
confidence: number;
|
|
39
|
-
/** top1 / mean(top5), mapped to [0, 1] via (s-1)/(s+1). High = clear winner. */
|
|
40
|
-
sharpness: number;
|
|
41
|
-
/** (top1 - top10) / top1 in [0, 1]. High = sharp dropoff after winner. */
|
|
42
|
-
cliff: number;
|
|
43
|
-
/** top1 raw score, clamped to [0, 1]. Low = "best of bad bunch" risk. */
|
|
44
|
-
floor: number;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const SHARPNESS_W = parseFloat(process.env.AWM_CONF_SHARPNESS_W ?? '0.4');
|
|
48
|
-
const CLIFF_W = parseFloat(process.env.AWM_CONF_CLIFF_W ?? '0.3');
|
|
49
|
-
const FLOOR_W = parseFloat(process.env.AWM_CONF_FLOOR_W ?? '0.3');
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Compute recall confidence from an ordered (descending) array of result scores.
|
|
53
|
-
*
|
|
54
|
-
* Returns a confidence near 0 when:
|
|
55
|
-
* - Empty result set (no winner)
|
|
56
|
-
* - Flat distribution (sharpness ~1, cliff ~0)
|
|
57
|
-
* - Low absolute scores (floor low — "best of bad bunch")
|
|
58
|
-
*
|
|
59
|
-
* Returns a confidence near 1 when:
|
|
60
|
-
* - top-1 dominates (sharpness >> 1)
|
|
61
|
-
* - Sharp cliff after top-1 (cliff close to 1)
|
|
62
|
-
* - top-1 is itself a strong absolute match (floor close to 1)
|
|
63
|
-
*
|
|
64
|
-
* Edge cases:
|
|
65
|
-
* - 1 result: cliff is 0 (no runner-up). Sharpness defaults to 1 (no peers
|
|
66
|
-
* to dominate). Confidence anchored entirely by floor.
|
|
67
|
-
* - 0 results: all zero, confidence = 0.
|
|
68
|
-
*/
|
|
69
|
-
export function computeRecallConfidence(scoresDesc: number[]): RecallConfidence {
|
|
70
|
-
if (scoresDesc.length === 0) {
|
|
71
|
-
return { confidence: 0, sharpness: 0, cliff: 0, floor: 0 };
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const top1 = scoresDesc[0];
|
|
75
|
-
|
|
76
|
-
// Floor: clamp top1 into [0, 1]. AWM composite scores already lie in this
|
|
77
|
-
// range under normal use, but be defensive.
|
|
78
|
-
const floor = Math.max(0, Math.min(1, top1));
|
|
79
|
-
|
|
80
|
-
// Sharpness: top1 / mean(top-5). Skip if only 1 result (no peers).
|
|
81
|
-
let sharpness = 0;
|
|
82
|
-
if (scoresDesc.length >= 2) {
|
|
83
|
-
const window = scoresDesc.slice(0, Math.min(5, scoresDesc.length));
|
|
84
|
-
const mean = window.reduce((s, v) => s + v, 0) / window.length;
|
|
85
|
-
if (mean > 0) {
|
|
86
|
-
const ratio = top1 / mean; // typically in [1, K]
|
|
87
|
-
sharpness = (ratio - 1) / (ratio + 1); // maps [1, ∞) → [0, 1)
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// Cliff: how steep is the drop from top-1 to the K-th candidate?
|
|
92
|
-
// Use top-10 (or last available). If only 1 result, no cliff to measure.
|
|
93
|
-
let cliff = 0;
|
|
94
|
-
if (scoresDesc.length >= 2 && top1 > 0) {
|
|
95
|
-
const tail = scoresDesc[Math.min(9, scoresDesc.length - 1)];
|
|
96
|
-
cliff = Math.max(0, Math.min(1, (top1 - tail) / top1));
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Geometric blend — any near-zero component pulls confidence down.
|
|
100
|
-
// Add a tiny epsilon so log/zero doesn't collapse the whole signal when
|
|
101
|
-
// a result is genuinely sharp but the cliff is computed off only 2-3
|
|
102
|
-
// candidates (cliff small even for confident recalls).
|
|
103
|
-
const EPS = 0.05;
|
|
104
|
-
const s = sharpness + EPS;
|
|
105
|
-
const c = cliff + EPS;
|
|
106
|
-
const f = floor + EPS;
|
|
107
|
-
|
|
108
|
-
// Weighted geometric mean: prod(x_i ^ w_i)
|
|
109
|
-
const logConf =
|
|
110
|
-
SHARPNESS_W * Math.log(s)
|
|
111
|
-
+ CLIFF_W * Math.log(c)
|
|
112
|
-
+ FLOOR_W * Math.log(f);
|
|
113
|
-
const totalW = SHARPNESS_W + CLIFF_W + FLOOR_W;
|
|
114
|
-
// Subtract epsilon contribution so the floor of confidence is ~0 when all
|
|
115
|
-
// signals are zero (rather than the value of EPS).
|
|
116
|
-
const rawConf = Math.exp(logConf / totalW) - EPS;
|
|
117
|
-
const confidence = Math.max(0, Math.min(1, rawConf));
|
|
118
|
-
|
|
119
|
-
return { confidence, sharpness, cliff, floor };
|
|
120
|
-
}
|
|
1
|
+
// Copyright 2026 Robert Winter / Complete Ideas
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* Retrieval confidence — score-distribution-aware signal that complements
|
|
5
|
+
* the per-result `score`. The shape of the result set carries information
|
|
6
|
+
* the raw scores do not:
|
|
7
|
+
*
|
|
8
|
+
* - Confident recall: top-1 dominates, sharp cliff, non-trivial floor.
|
|
9
|
+
* - Noisy recall: many similar scores, flat distribution, weak floor.
|
|
10
|
+
* - "Best of bad bunch": sharp cliff but the cliff sits below a usable
|
|
11
|
+
* floor — the system found a winner among uninteresting candidates.
|
|
12
|
+
*
|
|
13
|
+
* Research grounding:
|
|
14
|
+
* - Geifman & El-Yaniv, "Selective Classification for Deep Neural
|
|
15
|
+
* Networks" (NeurIPS 2017): abstaining improves precision on confused
|
|
16
|
+
* inputs more than recalibrating thresholds.
|
|
17
|
+
* - Roitero et al, "Predictive Confidence in Retrieval" (SIGIR 2022):
|
|
18
|
+
* score-distribution shape predicts retrieval quality better than
|
|
19
|
+
* top-1 score in isolation.
|
|
20
|
+
* - Carmel & Yom-Tov, "Estimating Query Difficulty for IR" (Synthesis
|
|
21
|
+
* Lectures, 2010): post-retrieval predictors — sharpness, depth of
|
|
22
|
+
* score drop — correlate with TREC topic difficulty.
|
|
23
|
+
*
|
|
24
|
+
* AWM 0.8.5 integration: confidence is computed once per recall after
|
|
25
|
+
* final scoring and attached to every `ActivationResult`. Consumers may
|
|
26
|
+
* use it however they like (display, abstention, paired retrieval).
|
|
27
|
+
* Default behavior of recall is unchanged — confidence is data, not a
|
|
28
|
+
* gate, in PR-1.
|
|
29
|
+
*
|
|
30
|
+
* Configurable via env vars (initial weights tuned to favour sharpness):
|
|
31
|
+
* AWM_CONF_SHARPNESS_W (default 0.4) — weight of top1/mean(top5) signal
|
|
32
|
+
* AWM_CONF_CLIFF_W (default 0.3) — weight of (top1 - top10) / top1
|
|
33
|
+
* AWM_CONF_FLOOR_W (default 0.3) — weight of top1 absolute score
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
export interface RecallConfidence {
|
|
37
|
+
/** Composite confidence in [0, 1]. Higher = recall result is more trustworthy. */
|
|
38
|
+
confidence: number;
|
|
39
|
+
/** top1 / mean(top5), mapped to [0, 1] via (s-1)/(s+1). High = clear winner. */
|
|
40
|
+
sharpness: number;
|
|
41
|
+
/** (top1 - top10) / top1 in [0, 1]. High = sharp dropoff after winner. */
|
|
42
|
+
cliff: number;
|
|
43
|
+
/** top1 raw score, clamped to [0, 1]. Low = "best of bad bunch" risk. */
|
|
44
|
+
floor: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SHARPNESS_W = parseFloat(process.env.AWM_CONF_SHARPNESS_W ?? '0.4');
|
|
48
|
+
const CLIFF_W = parseFloat(process.env.AWM_CONF_CLIFF_W ?? '0.3');
|
|
49
|
+
const FLOOR_W = parseFloat(process.env.AWM_CONF_FLOOR_W ?? '0.3');
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Compute recall confidence from an ordered (descending) array of result scores.
|
|
53
|
+
*
|
|
54
|
+
* Returns a confidence near 0 when:
|
|
55
|
+
* - Empty result set (no winner)
|
|
56
|
+
* - Flat distribution (sharpness ~1, cliff ~0)
|
|
57
|
+
* - Low absolute scores (floor low — "best of bad bunch")
|
|
58
|
+
*
|
|
59
|
+
* Returns a confidence near 1 when:
|
|
60
|
+
* - top-1 dominates (sharpness >> 1)
|
|
61
|
+
* - Sharp cliff after top-1 (cliff close to 1)
|
|
62
|
+
* - top-1 is itself a strong absolute match (floor close to 1)
|
|
63
|
+
*
|
|
64
|
+
* Edge cases:
|
|
65
|
+
* - 1 result: cliff is 0 (no runner-up). Sharpness defaults to 1 (no peers
|
|
66
|
+
* to dominate). Confidence anchored entirely by floor.
|
|
67
|
+
* - 0 results: all zero, confidence = 0.
|
|
68
|
+
*/
|
|
69
|
+
export function computeRecallConfidence(scoresDesc: number[]): RecallConfidence {
|
|
70
|
+
if (scoresDesc.length === 0) {
|
|
71
|
+
return { confidence: 0, sharpness: 0, cliff: 0, floor: 0 };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const top1 = scoresDesc[0];
|
|
75
|
+
|
|
76
|
+
// Floor: clamp top1 into [0, 1]. AWM composite scores already lie in this
|
|
77
|
+
// range under normal use, but be defensive.
|
|
78
|
+
const floor = Math.max(0, Math.min(1, top1));
|
|
79
|
+
|
|
80
|
+
// Sharpness: top1 / mean(top-5). Skip if only 1 result (no peers).
|
|
81
|
+
let sharpness = 0;
|
|
82
|
+
if (scoresDesc.length >= 2) {
|
|
83
|
+
const window = scoresDesc.slice(0, Math.min(5, scoresDesc.length));
|
|
84
|
+
const mean = window.reduce((s, v) => s + v, 0) / window.length;
|
|
85
|
+
if (mean > 0) {
|
|
86
|
+
const ratio = top1 / mean; // typically in [1, K]
|
|
87
|
+
sharpness = (ratio - 1) / (ratio + 1); // maps [1, ∞) → [0, 1)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Cliff: how steep is the drop from top-1 to the K-th candidate?
|
|
92
|
+
// Use top-10 (or last available). If only 1 result, no cliff to measure.
|
|
93
|
+
let cliff = 0;
|
|
94
|
+
if (scoresDesc.length >= 2 && top1 > 0) {
|
|
95
|
+
const tail = scoresDesc[Math.min(9, scoresDesc.length - 1)];
|
|
96
|
+
cliff = Math.max(0, Math.min(1, (top1 - tail) / top1));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Geometric blend — any near-zero component pulls confidence down.
|
|
100
|
+
// Add a tiny epsilon so log/zero doesn't collapse the whole signal when
|
|
101
|
+
// a result is genuinely sharp but the cliff is computed off only 2-3
|
|
102
|
+
// candidates (cliff small even for confident recalls).
|
|
103
|
+
const EPS = 0.05;
|
|
104
|
+
const s = sharpness + EPS;
|
|
105
|
+
const c = cliff + EPS;
|
|
106
|
+
const f = floor + EPS;
|
|
107
|
+
|
|
108
|
+
// Weighted geometric mean: prod(x_i ^ w_i)
|
|
109
|
+
const logConf =
|
|
110
|
+
SHARPNESS_W * Math.log(s)
|
|
111
|
+
+ CLIFF_W * Math.log(c)
|
|
112
|
+
+ FLOOR_W * Math.log(f);
|
|
113
|
+
const totalW = SHARPNESS_W + CLIFF_W + FLOOR_W;
|
|
114
|
+
// Subtract epsilon contribution so the floor of confidence is ~0 when all
|
|
115
|
+
// signals are zero (rather than the value of EPS).
|
|
116
|
+
const rawConf = Math.exp(logConf / totalW) - EPS;
|
|
117
|
+
const confidence = Math.max(0, Math.min(1, rawConf));
|
|
118
|
+
|
|
119
|
+
return { confidence, sharpness, cliff, floor };
|
|
120
|
+
}
|