@tpsdev-ai/flair 0.24.0 → 0.25.1

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.
@@ -17,7 +17,9 @@ import { hybridEnabled } from "./bm25.js";
17
17
  // extraction) — MemoryBootstrap.ts calls the SAME core bare, without
18
18
  // tripping this file's rate-limit/reranker/hit-tracking side effects. See
19
19
  // resources/semantic-retrieval-core.ts's module doc for the full boundary.
20
- import { retrieveCandidates } from "./semantic-retrieval-core.js";
20
+ import { retrieveCandidates, DEFAULT_SELECT } from "./semantic-retrieval-core.js";
21
+ import { attachTrust } from "./trust-block.js";
22
+ import { bestSemanticSimilarity, evaluateAbstention } from "./abstention.js";
21
23
  // Candidate multiplier: fetch more candidates than needed from the HNSW index
22
24
  // so composite re-ranking has enough headroom to reorder results.
23
25
  const CANDIDATE_MULTIPLIER = 5;
@@ -60,7 +62,7 @@ export class SemanticSearch extends Resource {
60
62
  // recall-harness (test/bench/recall-harness/run.ts) and `recall-eval.mjs`
61
63
  // before reconsidering this default if the compositeScore formula or
62
64
  // corpus changes.
63
- const { agentId: bodyAgentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "raw", minScore = 0, since, asOf } = data || {};
65
+ const { agentId: bodyAgentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "raw", minScore = 0, since, asOf, includeTrust = false, abstain = false } = data || {};
64
66
  // Authenticated identity lives on the Harper Resource context (getContext().request).
65
67
  // `this.request` is NOT populated on Harper v5 Resources — prior reads here
66
68
  // silently returned undefined and the defense-in-depth scope check below
@@ -216,7 +218,41 @@ export class SemanticSearch extends Resource {
216
218
  isAllowed: scope?.isAllowed,
217
219
  hybrid,
218
220
  ctx,
221
+ // flair#744 slice 1: the trust block needs `provenance`, which the
222
+ // default projection omits. Widen the select ONLY when the caller opts
223
+ // in — passing undefined otherwise keeps the default (no `provenance`)
224
+ // so a non-trust recall response stays byte-identical.
225
+ select: includeTrust ? [...DEFAULT_SELECT, "provenance"] : undefined,
226
+ // flair#744 slice 2 + confidence-band refinement: attach the absolute
227
+ // per-result cosine confidence when the caller opts into abstention OR
228
+ // the trust block — abstention reads the best of it for its verdict, and
229
+ // the trust block classifies each result's into a `matchQuality` band
230
+ // (Kern BINDING condition 2: matchQuality needs `_semSimilarity`, so
231
+ // includeTrust must also turn this on). Neither flag ⇒ result objects stay
232
+ // byte-identical (no `_semSimilarity` attached).
233
+ withSemSimilarity: abstain || includeTrust,
219
234
  });
235
+ // ─── flair#744 slice 2 — first-class abstention ("no memory covers this")
236
+ // Opt-in only. Evaluated on the RETRIEVED candidate pool, BEFORE the
237
+ // reranker / final slice / hit-tracking, so an abstaining recall does no
238
+ // rerank work and never bumps retrievalCount for memories it declines to
239
+ // surface. The decision reads ONLY the best absolute semantic similarity
240
+ // (never any principal/authority signal — abstention.ts is pure and
241
+ // authority-free), against the single GLOBAL threshold. Default OFF ⇒ this
242
+ // whole block is skipped and the response is byte-identical to pre-slice-2.
243
+ let abstention = null;
244
+ if (abstain) {
245
+ abstention = evaluateAbstention(bestSemanticSimilarity(filteredResults));
246
+ if (abstention.abstained) {
247
+ return {
248
+ abstained: true,
249
+ reason: abstention.reason,
250
+ bestScore: abstention.bestScore,
251
+ threshold: abstention.threshold,
252
+ results: [],
253
+ };
254
+ }
255
+ }
220
256
  // ─── Cross-encoder rerank (best-effort, fail-open to vector order) ───────
221
257
  // Re-scores query+candidate TOGETHER (cross-attention the pooled embedding
222
258
  // can't do) and reorders before the final slice. Reorders whatever
@@ -248,8 +284,40 @@ export class SemanticSearch extends Resource {
248
284
  lastRetrieved: now,
249
285
  }).catch(() => { });
250
286
  }
287
+ // flair#744 slice 1 — opt-in inline trust-evidence block. Assembled HERE,
288
+ // in the response tail, strictly AFTER read-scope resolution
289
+ // (retrieveCandidates + scope.isAllowed already ran) and purely for the
290
+ // response — it never feeds back into any authority/scope/attribution/dedup
291
+ // decision (the #735-spirit zero-authority invariant; structurally guarded
292
+ // by test/unit/trust-block-zero-authority-tripwire.test.ts). Default OFF ⇒
293
+ // `results` is the untouched `topResults`, byte-identical to pre-slice-1.
294
+ //
295
+ // Order matters (confidence-band refinement): attachTrust must run BEFORE
296
+ // the `_semSimilarity` strip below, because buildTrustBlock reads that field
297
+ // off the record to classify `matchQuality`. attachTrust returns a shallow
298
+ // copy carrying `trust` (and still-present `_semSimilarity`); the strip then
299
+ // drops the internal field from the copy.
300
+ const trusted = includeTrust ? topResults.map((r) => attachTrust(r, true)) : topResults;
301
+ // flair#744 slice 2 + refinement: strip the internal `_semSimilarity`
302
+ // confidence field from the consumer-facing results — it exists ONLY to feed
303
+ // the abstention decision and the matchQuality classification, never the
304
+ // response shape. Attached whenever abstain OR includeTrust turned
305
+ // withSemSimilarity on, so strip in both cases. A no-op (and byte-identical)
306
+ // when neither flag is set (the field was never attached).
307
+ const results = (abstain || includeTrust)
308
+ ? trusted.map(({ _semSimilarity, ...r }) => r)
309
+ : trusted;
251
310
  // Surface degradation warning when semantic search was unavailable
252
- const response = { results: topResults };
311
+ const response = { results };
312
+ // flair#744 slice 2: in opt-in abstain mode that did NOT abstain, carry the
313
+ // (negative) verdict so a consumer building against the abstention shape
314
+ // always reads a stable `abstained`/`bestScore`/`threshold`. Absent when
315
+ // abstain is off ⇒ byte-identical to pre-slice-2.
316
+ if (abstention) {
317
+ response.abstained = abstention.abstained;
318
+ response.bestScore = abstention.bestScore;
319
+ response.threshold = abstention.threshold;
320
+ }
253
321
  if (!qEmb && q && getMode() === "none") {
254
322
  response._warning = "semantic search unavailable — results are keyword-only";
255
323
  }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * abstention.ts — first-class recall abstention ("no memory covers this")
3
+ * (flair#744 slice 2).
4
+ *
5
+ * ─── What this is ────────────────────────────────────────────────────────────
6
+ * Weak matches presented as answers are how a memory system *causes*
7
+ * confabulation instead of preventing it. When the best retrieval match is
8
+ * below a confidence floor, the honest response is a first-class "no memory
9
+ * covers this" verdict — NOT the N weakest matches dressed up as an answer.
10
+ * This module is the PURE, Harper-free decision core the recall wrappers
11
+ * (SemanticSearch.post, BootstrapMemories.post) consult in their response tail,
12
+ * strictly downstream of retrieval + read-scope resolution.
13
+ *
14
+ * ─── Opt-in, conservative, calibration is a SEPARATE follow-up ──────────────
15
+ * Slice 2 ships the abstention *response shape*, decoupled from threshold
16
+ * *calibration* (the design round's explicit sharpening). Consumers build
17
+ * against the API shape now; the promote-to-default calibration on the
18
+ * recall-bench corpus proceeds independently (NOT this slice). Until then the
19
+ * mode is opt-in (`abstain` request flag, default OFF ⇒ recall is byte-identical
20
+ * to today) and the threshold is a deliberately CONSERVATIVE hand-set constant
21
+ * that errs toward returning results rather than over-abstaining.
22
+ *
23
+ * ─── The GLOBAL-threshold invariant (flair#744 Sherlock BINDING condition 2) ─
24
+ * The abstention threshold MUST be GLOBAL (or scope-wide) and NEVER
25
+ * per-principal. A per-principal threshold ("this principal's memories need
26
+ * higher confidence to surface") would be an authority lever and would violate
27
+ * the zero-authority trust spine — the same discipline as the `claimed.*`
28
+ * guard (flair#735). This module makes that STRUCTURAL, not just documented:
29
+ * 1. ABSTENTION_THRESHOLD is a single module-level constant. There is no
30
+ * per-principal (or any) threshold parameter — `evaluateAbstention` reads
31
+ * the constant directly, so there is no lever to vary it per principal.
32
+ * 2. The decision consults ONLY a retrieval-confidence number. Neither
33
+ * function below takes — or this module anywhere imports — an agentId,
34
+ * principal, trust tier, or any authority signal. Enforced structurally by
35
+ * test/unit/abstention-no-per-principal-tripwire.test.ts (the module is
36
+ * scanned for authority tokens, and the decision function's arity is
37
+ * pinned to its single numeric input).
38
+ */
39
+ /**
40
+ * ABSTENTION_THRESHOLD — the single GLOBAL retrieval-confidence floor.
41
+ *
42
+ * When the best-match absolute semantic similarity (cosine, [0,1] — see
43
+ * `bestSemanticSimilarity`) is below this value, opt-in recall abstains
44
+ * ("no memory covers this") instead of returning the N weak matches.
45
+ *
46
+ * GLOBAL / data-driven, NEVER per-principal (Sherlock binding condition 2).
47
+ *
48
+ * CONSERVATIVE hand-set value (0.15): well below the strong-match band real
49
+ * embeddings produce for genuinely relevant memories, and below bootstrap's
50
+ * own long-standing task-relevance floor (0.3, resources/MemoryBootstrap.ts's
51
+ * TASK_RELEVANCE_FLOOR) — so abstention fires only when there is essentially
52
+ * nothing semantically near the query, erring toward returning results rather
53
+ * than over-abstaining. Promoting abstention to the DEFAULT recall mode, and
54
+ * tuning this value on the recall-bench corpus, is a SEPARATE follow-up (see
55
+ * flair#744) — this slice ships the response shape at a safe opt-in floor, not
56
+ * the calibrated default.
57
+ */
58
+ export const ABSTENTION_THRESHOLD = 0.15;
59
+ /**
60
+ * Confidence-band cut-points for `matchQuality` (flair#744 confidence-band
61
+ * refinement — "breadcrumbs, labeled"). Recall shouldn't be binary
62
+ * confident-match / nothing: a weak-but-present match is valuable if the agent
63
+ * KNOWS it's weak. The band classifier (resources/trust-block.ts's
64
+ * `classifyMatchQuality`) labels each result's absolute `_semSimilarity` as
65
+ * strong / moderate / breadcrumb against these two floors plus the abstention
66
+ * floor below:
67
+ *
68
+ * sim >= STRONG_BAND (0.55) → "strong"
69
+ * MODERATE_BAND (0.35) <= sim < STRONG_BAND → "moderate"
70
+ * ABSTENTION_THRESHOLD (0.15) <= sim < 0.35 → "breadcrumb"
71
+ * sim < ABSTENTION_THRESHOLD (present anyway, → "breadcrumb" (weakest present
72
+ * abstention off) band — NO 4th band)
73
+ *
74
+ * ─── SINGLE SOURCE OF TRUTH (Kern BINDING condition 1) ──────────────────────
75
+ * These two band floors live HERE, in the SAME module as ABSTENTION_THRESHOLD,
76
+ * so the band cut-points and the abstention floor are one source of truth and
77
+ * cannot drift. The classifier's breadcrumb floor is ABSTENTION_THRESHOLD
78
+ * ITSELF (imported, never a duplicate `0.15` literal) — the bottom of the
79
+ * breadcrumb band is exactly the top of abstention: below it, opt-in abstention
80
+ * returns "no memory covers this" instead. If recall-bench moves
81
+ * ABSTENTION_THRESHOLD, breadcrumb's floor moves with it, automatically.
82
+ *
83
+ * ─── CONSERVATIVE hand-set placeholders (same posture as ABSTENTION_THRESHOLD)
84
+ * 0.35 / 0.55 are reasonable hand-set cuts for cosine similarity on common
85
+ * embedding models, but the point is they're REPLACEABLE without an API change.
86
+ * The real cut-points come from recall-bench (which similarity band correlates
87
+ * with "the agent found this useful") — that calibration is a SEPARATE
88
+ * follow-up (see flair#744), exactly like promoting abstention to the default.
89
+ * The field and its API shape ship now; the numbers get tuned independently.
90
+ *
91
+ * GLOBAL constants, NEVER per-principal (Sherlock): the classifier takes exactly
92
+ * one numeric input and references no principal/tier — structurally guarded by
93
+ * test/unit/abstention-no-per-principal-tripwire.test.ts.
94
+ */
95
+ export const MODERATE_BAND = 0.35;
96
+ export const STRONG_BAND = 0.55;
97
+ /**
98
+ * Pick the best absolute semantic similarity across a retrieval candidate pool.
99
+ *
100
+ * Reads ONLY the `_semSimilarity` number the retrieval core
101
+ * (resources/semantic-retrieval-core.ts) attaches to each semantic-leg result
102
+ * WHEN abstention is requested — an absolute cosine similarity in [0,1],
103
+ * independent of the RRF normalization / rerank that make the ranking `_score`
104
+ * a *relative* signal (the top RRF-fused result is normalized to 1.0 regardless
105
+ * of how weak the actual match is, so `_score` is unusable as a confidence
106
+ * floor — this is why abstention reads the absolute similarity instead).
107
+ *
108
+ * Returns null when NO candidate carries a `_semSimilarity` (no embedding-based
109
+ * match at all — e.g. a keyword-only degraded search, or an empty pool). Per
110
+ * `evaluateAbstention`, null ⇒ never abstain (conservative: a degraded recall
111
+ * that couldn't even judge confidence should return what it found, not a
112
+ * confident "nothing covers this").
113
+ *
114
+ * PURE and authority-free: reads a single numeric field off each candidate,
115
+ * never any principal / agentId / tier / scope. It cannot make the decision
116
+ * per-principal because it never sees a principal.
117
+ */
118
+ export function bestSemanticSimilarity(candidates) {
119
+ let best = null;
120
+ for (const c of candidates) {
121
+ const s = c?._semSimilarity;
122
+ if (typeof s === "number" && Number.isFinite(s) && (best === null || s > best)) {
123
+ best = s;
124
+ }
125
+ }
126
+ return best;
127
+ }
128
+ /**
129
+ * The abstention decision. PURE, and its ONLY input is a single confidence
130
+ * number (or null) — there is deliberately no threshold parameter and no
131
+ * principal parameter, so the outcome cannot be varied per principal (Sherlock
132
+ * binding condition 2): the threshold is always the module-global
133
+ * ABSTENTION_THRESHOLD.
134
+ *
135
+ * - bestScore === null ⇒ never abstain (no embedding-based match to judge;
136
+ * conservative — return what was found).
137
+ * - bestScore < floor ⇒ abstain ("no memory covers this").
138
+ * - bestScore >= floor ⇒ do not abstain (return normal results).
139
+ */
140
+ export function evaluateAbstention(bestScore) {
141
+ const threshold = ABSTENTION_THRESHOLD;
142
+ const abstained = bestScore !== null && bestScore < threshold;
143
+ return abstained
144
+ ? { abstained: true, bestScore, threshold, reason: "no memory above confidence threshold" }
145
+ : { abstained: false, bestScore, threshold };
146
+ }
@@ -114,7 +114,16 @@ async function unwrap(value) {
114
114
  async function memorySearch(agent, args) {
115
115
  const Cls = await handler("SemanticSearch");
116
116
  const h = new Cls(undefined, delegationContext(agent));
117
- return unwrap(await h.post({ q: args?.query, limit: args?.limit ?? 5 }));
117
+ const body = { q: args?.query, limit: args?.limit ?? 5 };
118
+ // flair#744 slice 1 — opt-in inline trust block per result. Forwarded ONLY
119
+ // when requested so a plain search delegates a byte-identical body.
120
+ if (args?.includeTrust === true)
121
+ body.includeTrust = true;
122
+ // flair#744 slice 2 — opt-in abstention verdict. Forwarded ONLY when
123
+ // requested so a plain search delegates a byte-identical body.
124
+ if (args?.abstain === true)
125
+ body.abstain = true;
126
+ return unwrap(await h.post(body));
118
127
  }
119
128
  async function memoryStore(agent, args) {
120
129
  const Cls = await handler("Memory");
@@ -136,6 +145,13 @@ async function memoryStore(agent, args) {
136
145
  // Omitted entirely when the token carried no client_id.
137
146
  if (agent.clientId)
138
147
  body.claimedClient = agent.clientId;
148
+ // flair#744 slice A: citation-on-write — forward the optional
149
+ // usedMemoryIds array only when the caller actually supplied it, so an
150
+ // omitted citation list delegates a byte-identical body (Memory.post()
151
+ // consumes-and-strips this before the row is written, then credits each
152
+ // id post-commit through the shared usage ledger).
153
+ if (Array.isArray(args?.usedMemoryIds))
154
+ body.usedMemoryIds = args.usedMemoryIds;
139
155
  return unwrap(await h.post(body));
140
156
  }
141
157
  /**
@@ -201,7 +217,11 @@ async function memoryUpdate(agent, args) {
201
217
  async function memoryGet(agent, args) {
202
218
  const Cls = await handler("Memory");
203
219
  const h = new Cls(undefined, delegationContext(agent));
204
- return unwrap(await h.get(args?.id));
220
+ // flair#744 slice 1 — opt-in inline trust block on the returned record.
221
+ // Pass the opts arg ONLY when requested so a plain get() call is unchanged.
222
+ return unwrap(args?.includeTrust === true
223
+ ? await h.get(args?.id, { includeTrust: true })
224
+ : await h.get(args?.id));
205
225
  }
206
226
  async function memoryDelete(agent, args) {
207
227
  const Cls = await handler("Memory");
@@ -211,7 +231,7 @@ async function memoryDelete(agent, args) {
211
231
  async function bootstrap(agent, args) {
212
232
  const Cls = await handler("BootstrapMemories");
213
233
  const h = new Cls(undefined, delegationContext(agent));
214
- return unwrap(await h.post({
234
+ const body = {
215
235
  agentId: agent.agentId,
216
236
  maxTokens: args?.maxTokens ?? 4000,
217
237
  currentTask: args?.currentTask,
@@ -219,7 +239,16 @@ async function bootstrap(agent, args) {
219
239
  surface: args?.surface,
220
240
  subjects: args?.subjects,
221
241
  entities: args?.entities,
222
- }));
242
+ };
243
+ // flair#744 slice 1 — opt-in per-memory trust block array. Forwarded ONLY
244
+ // when requested so a plain bootstrap delegates a byte-identical body.
245
+ if (args?.includeTrust === true)
246
+ body.includeTrust = true;
247
+ // flair#744 slice 2 — opt-in task-relevance abstention verdict. Forwarded
248
+ // ONLY when requested so a plain bootstrap delegates a byte-identical body.
249
+ if (args?.abstain === true)
250
+ body.abstain = true;
251
+ return unwrap(await h.post(body));
223
252
  }
224
253
  async function soulSet(agent, args) {
225
254
  const Cls = await handler("Soul");
@@ -345,6 +374,8 @@ export const TOOLS = {
345
374
  properties: {
346
375
  query: { type: "string", description: "Search query — natural language, semantic matching" },
347
376
  limit: { type: "number", description: "Max results (default 5)" },
377
+ includeTrust: { type: "boolean", description: "Attach a per-result trust-evidence block (provenance, author, usage, freshness, supersession). Default false." },
378
+ abstain: { type: "boolean", description: "Opt into first-class abstention: when the best match is below a global confidence threshold, return { abstained: true, reason, bestScore } with no weak matches instead of the N weakest results. Default false." },
348
379
  },
349
380
  required: ["query"],
350
381
  },
@@ -362,6 +393,7 @@ export const TOOLS = {
362
393
  type: { type: "string", enum: ["session", "lesson", "decision", "preference", "fact", "goal"], description: "Memory type (default session)" },
363
394
  durability: { type: "string", enum: ["permanent", "persistent", "standard", "ephemeral"], description: "permanent > persistent > standard > ephemeral (default standard)" },
364
395
  tags: { type: "array", items: { type: "string" }, description: "Tag strings" },
396
+ usedMemoryIds: { type: "array", items: { type: "string" }, description: "IDs of memories that informed this write (citation-on-write). Credited via the same deduped usage ledger as record_usage. Optional." },
365
397
  },
366
398
  required: ["content"],
367
399
  },
@@ -393,7 +425,10 @@ export const TOOLS = {
393
425
  annotations: { readOnlyHint: true },
394
426
  inputSchema: {
395
427
  type: "object",
396
- properties: { id: { type: "string", description: "Memory ID" } },
428
+ properties: {
429
+ id: { type: "string", description: "Memory ID" },
430
+ includeTrust: { type: "boolean", description: "Attach a trust-evidence block (provenance, author, usage, freshness, supersession) to the record. Default false." },
431
+ },
397
432
  required: ["id"],
398
433
  },
399
434
  },
@@ -430,6 +465,8 @@ export const TOOLS = {
430
465
  items: { type: "string" },
431
466
  description: "Your declared attention-plane vocabulary strings (e.g. \"issue:owner/repo#123\") for collision surfacing's 'Others in the room' block — teammates with overlapping active work. Falls back to your own most-recent workspace-state entities when omitted.",
432
467
  },
468
+ includeTrust: { type: "boolean", description: "Also return a `trust` array with a per-included-memory trust-evidence block (provenance, author, usage, freshness, supersession). Default false." },
469
+ abstain: { type: "boolean", description: "Opt into a task-relevance abstention verdict: also return an `abstention` object ({ abstained, bestScore, threshold }) reporting whether any memory covered `currentTask` above a global confidence threshold. Default false." },
433
470
  },
434
471
  },
435
472
  },
@@ -58,13 +58,18 @@ function distanceToSimilarity(distance) {
58
58
  // m.content`, so dropping it here would silently regress bootstrap's
59
59
  // "Others in the room" surface even though SemanticSearch never asserts on
60
60
  // its absence.
61
- const DEFAULT_SELECT = ["id", "agentId", "content", "contentHash", "visibility", "tags", "durability",
61
+ // Exported so the opt-in trust-block path (flair#744 slice 1) can widen it with
62
+ // `provenance` ONLY when a caller requests the block — the default projection
63
+ // deliberately omits `provenance` (it's only needed for the trust block), and
64
+ // adding it unconditionally would change every existing recall response's
65
+ // bytes. See SemanticSearch.ts / MemoryBootstrap.ts's `includeTrust` handling.
66
+ export const DEFAULT_SELECT = ["id", "agentId", "content", "contentHash", "visibility", "tags", "durability",
62
67
  "source", "createdAt", "updatedAt", "expiresAt", "retrievalCount", "usageCount", "lastRetrieved",
63
68
  "promotionStatus", "promotedAt", "promotedBy", "archived", "archivedAt", "archivedBy",
64
69
  "parentId", "derivedFrom", "sessionId", "lastReflected", "supersedes", "subject", "summary",
65
70
  "validFrom", "validTo", "_safetyFlags"];
66
71
  export async function retrieveCandidates(params) {
67
- const { queryEmbedding: qEmb, q, conditions, limit, select = DEFAULT_SELECT, includeSuperseded = false, scoring = "raw", temporalBoost = 1.0, sinceDate = null, asOf, minScore = 0, agentId, isAllowed, hybrid, ctx, } = params;
72
+ const { queryEmbedding: qEmb, q, conditions, limit, select = DEFAULT_SELECT, includeSuperseded = false, scoring = "raw", temporalBoost = 1.0, sinceDate = null, asOf, minScore = 0, agentId, isAllowed, hybrid, ctx, withSemSimilarity = false, } = params;
68
73
  const passesAllowed = (record) => !isAllowed || isAllowed(record);
69
74
  const hnswSelect = [...select, "$distance"];
70
75
  const results = [];
@@ -79,6 +84,11 @@ export async function retrieveCandidates(params) {
79
84
  // ── (a) Semantic candidate records (best-first) ──────────────────────
80
85
  const semRecords = [];
81
86
  const semIds = [];
87
+ // flair#744 slice 2: absolute cosine similarity per semantic candidate
88
+ // (from the HNSW `$distance`), captured HERE before `$distance` is stripped
89
+ // downstream — the confidence signal the abstention decision reads (only
90
+ // when `withSemSimilarity`; empty/unused otherwise).
91
+ const semSimById = new Map();
82
92
  if (qEmb) {
83
93
  const semQuery = {
84
94
  sort: { attribute: "embedding", target: qEmb, distance: "cosine" },
@@ -108,6 +118,9 @@ export async function retrieveCandidates(params) {
108
118
  continue;
109
119
  if (!passesAllowed(record))
110
120
  continue;
121
+ if (withSemSimilarity && record.$distance !== undefined) {
122
+ semSimById.set(record.id, distanceToSimilarity(record.$distance));
123
+ }
111
124
  semRecords.push(record);
112
125
  semIds.push(record.id);
113
126
  }
@@ -177,12 +190,17 @@ export async function retrieveCandidates(params) {
177
190
  finalScore *= temporalBoost;
178
191
  const isFlagged = record._safetyFlags && Array.isArray(record._safetyFlags) && record._safetyFlags.length > 0;
179
192
  const source = record.agentId !== agentId ? record.agentId : undefined;
193
+ // flair#744 slice 2: absolute cosine confidence for the abstention
194
+ // decision — only for records that had a semantic (HNSW) candidate; a
195
+ // BM25-lexical-only match carries no cosine and contributes none.
196
+ const semSim = withSemSimilarity ? semSimById.get(id) : undefined;
180
197
  results.push({
181
198
  ...record,
182
199
  content: isFlagged ? wrapUntrusted(record.content, source) : record.content,
183
200
  _score: Math.round(finalScore * 1000) / 1000,
184
201
  _rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
185
202
  _source: source,
203
+ ...(semSim !== undefined ? { _semSimilarity: semSim } : {}),
186
204
  });
187
205
  }
188
206
  }
@@ -241,12 +259,16 @@ export async function retrieveCandidates(params) {
241
259
  const { $distance, ...rest } = record;
242
260
  const isFlagged = rest._safetyFlags && Array.isArray(rest._safetyFlags) && rest._safetyFlags.length > 0;
243
261
  const source = record.agentId !== agentId ? record.agentId : undefined;
262
+ // flair#744 slice 2: the absolute cosine (`semanticScore`, pre keyword
263
+ // bump) is the abstention confidence signal on this legacy/bootstrap
264
+ // (HNSW-leg-only) path.
244
265
  results.push({
245
266
  ...rest,
246
267
  content: isFlagged ? wrapUntrusted(rest.content, source) : rest.content,
247
268
  _score: Math.round(finalScore * 1000) / 1000,
248
269
  _rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
249
270
  _source: source,
271
+ ...(withSemSimilarity ? { _semSimilarity: semanticScore } : {}),
250
272
  });
251
273
  }
252
274
  }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * trust-block.ts — the opt-in, inline trust-evidence block surfaced on recall
3
+ * results (flair#744 slice 1: "surface what we already record at the point of
4
+ * decision").
5
+ *
6
+ * ─── What this is ────────────────────────────────────────────────────────────
7
+ * A PURE, Harper-free assembler: `buildTrustBlock(record)` maps a single
8
+ * Memory record's ALREADY-STORED fields into a compact, self-contained trust
9
+ * block that `search` (SemanticSearch), `get` (Memory.get), and `bootstrap`
10
+ * (BootstrapMemories) attach to each result WHEN THE CALLER OPTS IN. No new
11
+ * computation, no cross-record lookups, no DB access — every field below is
12
+ * read straight off the record the recall path already resolved. That is the
13
+ * whole point of slice 1: the memory layer already records per-fact trust
14
+ * evidence at write time; this surfaces it at read time, where the consuming
15
+ * agent actually decides what to repeat.
16
+ *
17
+ * ─── The zero-authority invariant (flair#744 Sherlock condition 2 / #735) ────
18
+ * The trust block INFORMS THE READER ONLY. It is assembled AFTER read-scope
19
+ * resolution, purely for the response, and MUST NEVER enter an authority /
20
+ * scope / attribution / dedup decision anywhere — the same discipline as the
21
+ * `claimed.*` zero-authority guard (flair#735). Two things keep that true:
22
+ * 1. This module is pure and side-effect-free: buildTrustBlock NEVER mutates
23
+ * its input record (it only reads), so assembling a block can never alter
24
+ * a record a later decision reads.
25
+ * 2. No authority-decision module imports it. That is structurally enforced
26
+ * by test/unit/trust-block-zero-authority-tripwire.test.ts (the #735-style
27
+ * source scan): record-type-kit.ts, memory-read-scope.ts, Memory.ts's
28
+ * dedup gates, RecordUsage.ts, mcp-handler.ts, and the retrieval/scoping
29
+ * core (semantic-retrieval-core.ts) never reference the trust block — it
30
+ * is assembled strictly DOWNSTREAM of scope resolution, in the response
31
+ * tail of each recall wrapper.
32
+ *
33
+ * ─── `claimed.*` is surfaced as a BOOLEAN only ──────────────────────────────
34
+ * `hasClaimedProvenance` reports only WHETHER the record carries a self-
35
+ * reported `claimed` sub-object — never its content. Raw `claimed.model` /
36
+ * `claimed.client` values are self-reported and unverified (resources/
37
+ * provenance.ts); exposing them as authoritative trust evidence would defeat
38
+ * the verified-vs-claimed distinction the block exists to draw. The advisory
39
+ * "there is a self-report here" bit is enough for the reader to weight it.
40
+ *
41
+ * ─── Tier is DEFERRED to a later slice (flair#744 Sherlock condition 1) ─────
42
+ * The block deliberately does NOT carry a derived trust `tier`. A tier is not
43
+ * a field on the Memory record — it lives on the AUTHOR's Agent/OAuthClient
44
+ * principal record (`defaultTrustTier`), so surfacing it would require a
45
+ * per-author lookup on the hot recall path (exactly the read-path cost the
46
+ * flair#744 design round ruled out) AND the scope-gate Sherlock's condition 1
47
+ * mandates ("include tier only when reader.scope == author.scope"). That
48
+ * scope-gate needs an org/scope boundary primitive that does not yet exist in
49
+ * flair's single-tenant "open-within-org" read model. Both are more than
50
+ * trivial for slice 1, so per the spec's explicit allowance the tier field is
51
+ * deferred whole — the scope-gate ships WITH it, when it ships. Everything
52
+ * else in the block (provenance, author principal, usage, freshness,
53
+ * supersession) is on the Memory record itself and ships now.
54
+ *
55
+ * ─── `matchQuality` confidence band (flair#744 refinement) ──────────────────
56
+ * When the recall path attached an absolute semantic similarity to the result
57
+ * (`_semSimilarity`, cosine in [0,1] — attached only on the retrieval surface,
58
+ * when the caller opts into the block or into abstention), the block labels it
59
+ * with a confidence band — strong / moderate / breadcrumb — so a weak-but-
60
+ * present match is TAKEN FOR WHAT IT IS, not mistaken for a confident one (the
61
+ * hallucination risk is undifferentiated weak matches, not weak matches per se).
62
+ * Derived PURELY from that one number against the global band cut-points in
63
+ * resources/abstention.ts (single source of truth with the abstention floor —
64
+ * see classifyMatchQuality). When there is no similarity to judge (a by-id
65
+ * `get`, or a keyword-only degraded search — no `_semSimilarity`), the band is
66
+ * `null`: an honest "we couldn't classify this one", never a false label.
67
+ */
68
+ import { ABSTENTION_THRESHOLD, MODERATE_BAND, STRONG_BAND } from "./abstention.js";
69
+ const MS_PER_DAY = 86_400_000;
70
+ /**
71
+ * Classify a result's absolute semantic similarity (`_semSimilarity`, cosine in
72
+ * [0,1]) into a confidence band. PURE: its ONLY input is that one number — no
73
+ * principal / agentId / tier / scope — so the band is GLOBAL and can never be
74
+ * varied per principal (Sherlock; structurally guarded by
75
+ * test/unit/abstention-no-per-principal-tripwire.test.ts, same spine as the
76
+ * abstention decision).
77
+ *
78
+ * sim >= STRONG_BAND → "strong"
79
+ * MODERATE_BAND <= sim < STRONG → "moderate"
80
+ * ABSTENTION_THRESHOLD <= sim → "breadcrumb" (bottom of breadcrumb IS the
81
+ * abstention floor — one
82
+ * shared constant, no dup)
83
+ * sim < ABSTENTION_THRESHOLD → "breadcrumb" (a result present below the
84
+ * abstention floor — abstention
85
+ * off, or it slipped in — is
86
+ * still the WEAKEST present
87
+ * band; there is NO 4th band)
88
+ * not a finite number (null/undefined/NaN) → null (no signal to classify;
89
+ * never a false label)
90
+ *
91
+ * The breadcrumb floor references ABSTENTION_THRESHOLD directly (Kern BINDING
92
+ * condition 1): the band cut-points and the abstention floor are one source of
93
+ * truth (resources/abstention.ts) and cannot drift — if recall-bench moves the
94
+ * abstention floor, breadcrumb's floor moves with it.
95
+ */
96
+ export function classifyMatchQuality(semSimilarity) {
97
+ if (typeof semSimilarity !== "number" || !Number.isFinite(semSimilarity))
98
+ return null;
99
+ if (semSimilarity >= STRONG_BAND)
100
+ return "strong";
101
+ if (semSimilarity >= MODERATE_BAND)
102
+ return "moderate";
103
+ if (semSimilarity >= ABSTENTION_THRESHOLD)
104
+ return "breadcrumb";
105
+ // Present below the abstention floor (abstention off, or a straggler): still
106
+ // the weakest present band — breadcrumb, never a 4th band.
107
+ return "breadcrumb";
108
+ }
109
+ function parseTime(value) {
110
+ if (typeof value !== "string" || value.length === 0)
111
+ return NaN;
112
+ return Date.parse(value);
113
+ }
114
+ /**
115
+ * Assemble the trust block for one Memory record. Pure: reads only, NEVER
116
+ * mutates `record`. `now` is injectable for deterministic tests (defaults to
117
+ * the current wall clock).
118
+ */
119
+ export function buildTrustBlock(record, now = Date.now()) {
120
+ // ── Provenance (verified vs claimed) ──────────────────────────────────────
121
+ let verifiedAuthor = null;
122
+ let verifiedAt = null;
123
+ let hasClaimedProvenance = false;
124
+ if (typeof record.provenance === "string" && record.provenance.length > 0) {
125
+ try {
126
+ const p = JSON.parse(record.provenance);
127
+ if (p && typeof p === "object") {
128
+ if (p.verified && typeof p.verified === "object") {
129
+ verifiedAuthor = typeof p.verified.agentId === "string" ? p.verified.agentId : null;
130
+ verifiedAt = typeof p.verified.timestamp === "string" ? p.verified.timestamp : null;
131
+ }
132
+ // Boolean only — the self-reported content is deliberately NOT surfaced.
133
+ hasClaimedProvenance = p.claimed != null && typeof p.claimed === "object";
134
+ }
135
+ }
136
+ catch {
137
+ // Malformed provenance → treated as unattributed, never throws.
138
+ }
139
+ }
140
+ // ── Freshness / validity ──────────────────────────────────────────────────
141
+ const validFrom = typeof record.validFrom === "string" ? record.validFrom : null;
142
+ const validTo = typeof record.validTo === "string" ? record.validTo : null;
143
+ const createdAt = typeof record.createdAt === "string" ? record.createdAt : null;
144
+ const validToMs = parseTime(validTo);
145
+ const validFromMs = parseTime(validFrom);
146
+ let validityStatus = "valid";
147
+ if (Number.isFinite(validToMs) && validToMs <= now) {
148
+ validityStatus = "expired";
149
+ }
150
+ else if (Number.isFinite(validFromMs) && validFromMs > now) {
151
+ validityStatus = "future";
152
+ }
153
+ const createdMs = parseTime(createdAt);
154
+ const ageDays = Number.isFinite(createdMs)
155
+ ? Math.max(0, Math.floor((now - createdMs) / MS_PER_DAY))
156
+ : null;
157
+ return {
158
+ author: typeof record.agentId === "string" ? record.agentId : null,
159
+ provenanceStatus: verifiedAuthor ? "verified" : "unattributed",
160
+ verifiedAuthor,
161
+ verifiedAt,
162
+ hasClaimedProvenance,
163
+ usageCount: typeof record.usageCount === "number" ? record.usageCount : null,
164
+ validityStatus,
165
+ validFrom,
166
+ validTo,
167
+ createdAt,
168
+ ageDays,
169
+ supersedes: typeof record.supersedes === "string" ? record.supersedes : null,
170
+ // flair#744 refinement — confidence band from the result's absolute
171
+ // similarity (null when there is no signal to judge). Pure, global,
172
+ // score-only: classifyMatchQuality sees ONLY the number.
173
+ matchQuality: classifyMatchQuality(record._semSimilarity),
174
+ };
175
+ }
176
+ /**
177
+ * Opt-in attach: the single primitive `search`/`get` use to layer the trust
178
+ * block onto a result. `includeTrust === false` (the default) returns the
179
+ * EXACT SAME record reference untouched — the clean-migration guarantee that a
180
+ * consumer who doesn't request the block sees a byte-identical response. When
181
+ * true, returns a shallow copy with `trust` added (never mutates the input).
182
+ */
183
+ export function attachTrust(record, includeTrust, now) {
184
+ if (!includeTrust)
185
+ return record;
186
+ return { ...record, trust: buildTrustBlock(record, now) };
187
+ }