@evomap/evolver-adapter-public 2.0.0-beta.9 → 2.0.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/dist/hubReuse.js CHANGED
@@ -12,7 +12,8 @@
12
12
  // - search cache: signal-fingerprint → phase-1 metadata (short TTL). Repeat signal set → ZERO hub calls.
13
13
  // - payload cache: assetId → phase-3 payload (content-addressed, long/permanent, bounded LRU). A cached
14
14
  // payload → ZERO fetch. Both clocks are injected so TTL/eviction is deterministic and testable.
15
- import { hub, algo } from '@evomap/evolver-core';
15
+ import { createHash } from 'node:crypto';
16
+ import { hub, algo, signals as signalNs, wire } from '@evomap/evolver-core';
16
17
  const { scoreSearchResults, decideReuse, DEFAULT_MIN_REUSE_SCORE, } = hub;
17
18
  const GENE_WIRE_KEYS = new Set([
18
19
  'type',
@@ -33,10 +34,52 @@ const GENE_WIRE_KEYS = new Set([
33
34
  'generation_meta',
34
35
  'asset_id',
35
36
  ]);
37
+ const HUB_DELIVERY_METADATA_KEYS = new Set([
38
+ 'status',
39
+ 'success_streak',
40
+ 'reputation_score',
41
+ 'gdi_score',
42
+ 'gdi_score_mean',
43
+ 'success_rate',
44
+ 'reuse_count',
45
+ 'ranking_score',
46
+ 'credit_cost',
47
+ 'source_node_id',
48
+ 'fetched_at',
49
+ 'receipt',
50
+ 'hub_receipt',
51
+ 'already_purchased',
52
+ '_semantic_similarity',
53
+ 'semantic_similarity',
54
+ 'similarity',
55
+ 'semanticSimilarity',
56
+ '_search_score',
57
+ 'search_score',
58
+ 'payload_backfill_reason',
59
+ 'asset_type',
60
+ 'bundle_id',
61
+ 'callable',
62
+ 'payload_ready',
63
+ 'bundle_capsule',
64
+ 'bundle_events',
65
+ ]);
36
66
  // ── Cache config (ported from v1 hubSearch.js) ───────────────────────────────
37
67
  export const SEARCH_CACHE_TTL_MS = 5 * 60 * 1000; // metadata is hot but staleable — short TTL
38
68
  export const SEARCH_CACHE_MAX = 200;
39
69
  export const PAYLOAD_CACHE_MAX = 100;
70
+ export const SEMANTIC_SEARCH_LIMIT = 10;
71
+ export const SEMANTIC_QUERY_MAX_TERMS = 12;
72
+ export const SEMANTIC_QUERY_MAX_CHARS = 512;
73
+ // Namespace filtering removes obviously private signal classes. The term allowlist below is still mandatory:
74
+ // even a public namespace can contain an arbitrary user-controlled value that must not enter a logged GET URL.
75
+ const PUBLIC_SEMANTIC_NAMESPACES = new Set(['area', 'cap', 'capability_gap', 'risk']);
76
+ const PUBLIC_SEMANTIC_TERMS = new Set([
77
+ '401', '403', '404', '409', '429', '500', '502', '503', '504',
78
+ 'auth', 'cache', 'capability_gap', 'code_review', 'concurrency', 'database', 'debugging', 'go',
79
+ 'javascript', 'latency', 'memory', 'network', 'performance', 'python',
80
+ 'rate_limit', 'reliability', 'retry', 'rust', 'security', 'testing', 'timeout',
81
+ 'typescript',
82
+ ]);
40
83
  export const DEFAULT_REUSE_MODE = 'reference';
41
84
  /** Reads EVOLVER_MIN_REUSE_SCORE here (env is an ADAPTER concern — core never reads it). */
42
85
  export function getMinReuseScore(env = process.env) {
@@ -47,10 +90,74 @@ export function getMinReuseScore(env = process.env) {
47
90
  export function getReuseMode(env = process.env) {
48
91
  return String(env['EVOLVER_REUSE_MODE'] ?? DEFAULT_REUSE_MODE).toLowerCase() === 'direct' ? 'direct' : 'reference';
49
92
  }
93
+ /** V1-compatible kill-switch. Semantic recall is on unless explicitly disabled. */
94
+ export function isSemanticSearchEnabled(env = process.env) {
95
+ const value = String(env['HUBSEARCH_SEMANTIC'] ?? '').trim().toLowerCase();
96
+ return value !== '0' && value !== 'false';
97
+ }
98
+ /**
99
+ * Derive a bounded public semantic query from structured signal tags. Error signatures, paths, prose, and other
100
+ * unstructured values are excluded so the vector-search leg cannot become a side channel for local diagnostics.
101
+ */
102
+ export function buildSemanticQuery(signals) {
103
+ const terms = [];
104
+ const seen = new Set();
105
+ for (const raw of signals) {
106
+ const signal = String(raw).trim();
107
+ const lower = signal.toLowerCase();
108
+ if (!signal || lower.startsWith('errsig:') || lower.startsWith('errsig_norm:') || lower.startsWith('recurring_errsig'))
109
+ continue;
110
+ const colon = signal.indexOf(':');
111
+ if (colon > 0 && !PUBLIC_SEMANTIC_NAMESPACES.has(lower.slice(0, colon)))
112
+ continue;
113
+ const candidate = (colon > 0 && colon < 30 ? signal.slice(colon + 1) : signal).trim().toLowerCase();
114
+ if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(candidate)
115
+ // Signals are user-controlled. Only a fixed public taxonomy may enter the GET query because URLs are
116
+ // commonly retained by Hub and reverse-proxy access logs. Unknown terms remain in the structured POST leg.
117
+ || !PUBLIC_SEMANTIC_TERMS.has(candidate)
118
+ || seen.has(candidate))
119
+ continue;
120
+ const nextLength = terms.length === 0 ? candidate.length : terms.join(' ').length + 1 + candidate.length;
121
+ if (nextLength > SEMANTIC_QUERY_MAX_CHARS)
122
+ break;
123
+ seen.add(candidate);
124
+ terms.push(candidate);
125
+ if (terms.length >= SEMANTIC_QUERY_MAX_TERMS)
126
+ break;
127
+ }
128
+ return terms.join(' ');
129
+ }
50
130
  /** Stable signal fingerprint (ported from v1 _cacheKey: sort + join). */
51
131
  export function signalFingerprint(signals) {
52
132
  return [...signals].map((s) => String(s).trim()).filter(Boolean).sort().join('|');
53
133
  }
134
+ export const TASK_DOMAIN_SIGNAL_PREFIX = signalNs.TASK_DOMAIN_SIGNAL_PREFIX;
135
+ /**
136
+ * evolver domain slug → hub domain taxonomy (evomap-hub domainDetectionService VALID_DOMAINS).
137
+ * Only mapped slugs may ride the wire: the hub validates against its own taxonomy and silently
138
+ * ignores unknown values (fail-open), so an unmapped slug would just waste the fence. Slugs the
139
+ * hub has no counterpart for (pdf/mail/calendar) intentionally map to nothing.
140
+ */
141
+ const HUB_DOMAIN_BY_SLUG = {
142
+ coding: 'software_engineering',
143
+ sql: 'software_engineering',
144
+ pptx: 'content_creation',
145
+ docx: 'content_creation',
146
+ xlsx: 'data_analysis',
147
+ marketing: 'marketing',
148
+ };
149
+ /**
150
+ * Resolve the hub-side domain fence from this turn's signals. Exactly one domain is used and only
151
+ * when the turn is unambiguous: with two or more distinct task_domain:* signals the turn spans
152
+ * domains, and scoping recall to either one would hide the other's assets — so we return null and
153
+ * fall back to unscoped recall (today's behaviour).
154
+ */
155
+ export function hubDomainFromSignals(signals) {
156
+ const resolution = signalNs.resolveTaskDomainSignals(signals);
157
+ return resolution.status === 'resolved'
158
+ ? HUB_DOMAIN_BY_SLUG[resolution.slug] ?? null
159
+ : null;
160
+ }
54
161
  /**
55
162
  * The two-layer reuse cache. Bounded + TTL'd, per-process. A search-cache hit means phase 1 makes ZERO hub
56
163
  * calls; a payload-cache hit means phase 3 makes ZERO hub calls. The clock is injected for deterministic tests.
@@ -76,10 +183,13 @@ export class ReuseCache {
76
183
  this.search.delete(key);
77
184
  return null;
78
185
  }
186
+ this.search.delete(key);
187
+ this.search.set(key, e);
79
188
  return e.value;
80
189
  }
81
190
  setSearch(key, value) {
82
- if (this.search.size >= this.searchMax) {
191
+ const exists = this.search.delete(key);
192
+ if (!exists && this.search.size >= this.searchMax) {
83
193
  const oldest = this.search.keys().next().value;
84
194
  if (oldest !== undefined)
85
195
  this.search.delete(oldest);
@@ -90,15 +200,19 @@ export class ReuseCache {
90
200
  const asset = this.payload.get(assetId) ?? null;
91
201
  if (!asset)
92
202
  return null;
93
- if (assetMatchesId(asset, assetId))
203
+ if (assetMatchesId(asset, assetId)) {
204
+ this.payload.delete(assetId);
205
+ this.payload.set(assetId, asset);
94
206
  return asset;
207
+ }
95
208
  this.payload.delete(assetId);
96
209
  return null;
97
210
  }
98
211
  setPayload(assetId, payload) {
99
212
  if (!assetMatchesId(payload, assetId))
100
213
  return;
101
- if (this.payload.size >= this.payloadMax) {
214
+ const exists = this.payload.delete(assetId);
215
+ if (!exists && this.payload.size >= this.payloadMax) {
102
216
  const oldest = this.payload.keys().next().value;
103
217
  if (oldest !== undefined)
104
218
  this.payload.delete(oldest);
@@ -132,6 +246,15 @@ function stripHubPayloadMetadata(rec) {
132
246
  }
133
247
  return out;
134
248
  }
249
+ function stripHubDeliveryMetadataForIntegrity(rec) {
250
+ const out = { ...rec };
251
+ for (const key of HUB_DELIVERY_METADATA_KEYS)
252
+ delete out[key];
253
+ // Hub ranking confidence is metadata for Genes, while Capsule.confidence is canonical content.
254
+ if (out['type'] === 'Gene')
255
+ delete out['confidence'];
256
+ return out;
257
+ }
135
258
  /**
136
259
  * Map a hub search row (AssetRecord with arbitrary quality fields) → the core's price-free HubMetadata.
137
260
  * Accepts both camelCase and the hub's snake_case (gdi_score / success_rate / reuse_count / ...). Drops any
@@ -153,7 +276,9 @@ export function toHubMetadata(rec) {
153
276
  ...(num(r['gdi_score'] ?? r['gdiScore']) !== undefined ? { gdiScore: num(r['gdi_score'] ?? r['gdiScore']) } : {}),
154
277
  ...(num(r['success_rate'] ?? r['successRate']) !== undefined ? { successRate: num(r['success_rate'] ?? r['successRate']) } : {}),
155
278
  ...(num(r['reuse_count'] ?? r['reuseCount']) !== undefined ? { reuseCount: num(r['reuse_count'] ?? r['reuseCount']) } : {}),
156
- ...(num(r['similarity'] ?? r['semanticSimilarity']) !== undefined ? { semanticSimilarity: num(r['similarity'] ?? r['semanticSimilarity']) } : {}),
279
+ ...(num(r['similarity'] ?? r['semantic_similarity'] ?? r['_semantic_similarity'] ?? r['semanticSimilarity']) !== undefined
280
+ ? { semanticSimilarity: num(r['similarity'] ?? r['semantic_similarity'] ?? r['_semantic_similarity'] ?? r['semanticSimilarity']) }
281
+ : {}),
157
282
  ...(updatedAt !== undefined ? { updatedAt } : {}),
158
283
  };
159
284
  }
@@ -179,6 +304,68 @@ export function toGeneCandidate(rec) {
179
304
  hubAsset: stripHubPayloadMetadata(rec),
180
305
  };
181
306
  }
307
+ /**
308
+ * Run the complete free-search phase shared by reuse and economic miss probes. This function never performs the
309
+ * paid fetch. Only complete dual-leg results enter the cache, so a partial outage cannot become a verified miss.
310
+ */
311
+ export async function searchHubMetadata(cap, cache, signals, opts = {}) {
312
+ const signalList = signals.map((signal) => String(signal).trim()).filter(Boolean);
313
+ const fingerprint = signalFingerprint(signalList);
314
+ if (signalList.length === 0) {
315
+ return { signals: signalList, fingerprint, metadata: [], searchCached: false, complete: true };
316
+ }
317
+ const env = opts.env ?? process.env;
318
+ const semanticQuery = isSemanticSearchEnabled(env) ? buildSemanticQuery(signalList) : '';
319
+ const semanticActive = semanticQuery.length >= 3;
320
+ const semanticQueryDigest = semanticActive
321
+ ? createHash('sha256').update(semanticQuery).digest('hex')
322
+ : undefined;
323
+ // Domain fence: derived from the turn's own task_domain:* signals (never from prose), mapped to
324
+ // the hub taxonomy. Scopes the structured signal leg only — the semantic leg already carries its
325
+ // own allowlisted free-text and stays domain-agnostic as the discovery fallback.
326
+ const hubDomain = hubDomainFromSignals(signals);
327
+ const signalSearchLimit = opts.searchLimit ? opts.searchLimit : undefined;
328
+ const limitKey = signalSearchLimit === undefined ? 'all' : String(signalSearchLimit);
329
+ const domainKey = hubDomain === null ? '' : `:domain:${hubDomain}`;
330
+ const key = semanticQueryDigest
331
+ ? `semantic:${fingerprint}:${semanticQueryDigest}:limit:${limitKey}${domainKey}`
332
+ : `signals:${fingerprint}:limit:${limitKey}${domainKey}`;
333
+ const cached = cache.getSearch(key);
334
+ if (cached !== null) {
335
+ return { signals: signalList, fingerprint, metadata: cached, searchCached: true, complete: true };
336
+ }
337
+ // Enter a promise boundary before invoking an injected provider: interface implementations can still throw
338
+ // synchronously even though their declared return type is Promise, and reuse must remain best-effort.
339
+ const signalSearch = Promise.resolve().then(() => cap.search({
340
+ signalsAny: signalList,
341
+ ...(hubDomain !== null ? { domain: hubDomain } : {}),
342
+ ...(signalSearchLimit ? { limit: signalSearchLimit } : {}),
343
+ }));
344
+ const semanticSearch = semanticActive
345
+ ? Promise.resolve().then(() => cap.search({ text: semanticQuery, kind: 'Gene', limit: SEMANTIC_SEARCH_LIMIT }))
346
+ : Promise.resolve([]);
347
+ const [signalResult, semanticResult] = await Promise.allSettled([signalSearch, semanticSearch]);
348
+ const signalRows = signalResult.status === 'fulfilled' ? signalResult.value : [];
349
+ const semanticRows = semanticResult.status === 'fulfilled' ? semanticResult.value : [];
350
+ const failedSearch = signalResult.status === 'rejected'
351
+ ? signalResult
352
+ : semanticResult.status === 'rejected'
353
+ ? semanticResult
354
+ : undefined;
355
+ const metadata = mergeSearchRows(signalRows, semanticRows)
356
+ .map(toHubMetadata)
357
+ .filter((candidate) => candidate.assetId.length > 0);
358
+ if (!failedSearch)
359
+ cache.setSearch(key, metadata);
360
+ return {
361
+ signals: signalList,
362
+ fingerprint,
363
+ metadata,
364
+ searchCached: false,
365
+ complete: failedSearch === undefined,
366
+ ...(failedSearch ? { error: failedSearch.reason } : {}),
367
+ };
368
+ }
182
369
  /**
183
370
  * The reuse-before-solve flow. Returns the single winner (already fetched) as a selection candidate, or a
184
371
  * solve-fresh verdict. Never throws on a hub error — reuse is an optimization, not a hard dependency:
@@ -189,40 +376,58 @@ export function toGeneCandidate(rec) {
189
376
  * @param signals the local problem signals.
190
377
  */
191
378
  export async function reuseBeforeSolve(cap, cache, signals, opts = {}) {
192
- const mode = opts.mode ?? getReuseMode();
193
- const threshold = opts.threshold ?? getMinReuseScore();
379
+ const env = opts.env ?? process.env;
380
+ const mode = opts.mode ?? getReuseMode(env);
381
+ const threshold = opts.threshold ?? getMinReuseScore(env);
194
382
  const runId = opts.runId ?? null;
195
383
  const log = opts.log;
196
- const signalList = signals.map((s) => String(s).trim()).filter(Boolean);
384
+ const searchResult = await searchHubMetadata(cap, cache, signals, {
385
+ env,
386
+ ...(opts.searchLimit ? { searchLimit: opts.searchLimit } : {}),
387
+ });
388
+ const { signals: signalList, fingerprint, metadata, searchCached, complete: searchComplete, error: searchFailure, } = searchResult;
197
389
  if (signalList.length === 0) {
198
390
  return { action: 'solve-fresh', mode, zeroHubCalls: true, reason: 'no_signals' };
199
391
  }
200
392
  // ── Phase 1: free search (signal fingerprint cache → ZERO hub calls on hit) ──
201
- const key = signalFingerprint(signalList);
202
- let metadata = cache.getSearch(key);
203
- const searchCached = metadata !== null;
204
- if (metadata === null) {
205
- let rows = [];
206
- try {
207
- rows = await cap.search({ signalsAny: signalList, ...(opts.searchLimit ? { limit: opts.searchLimit } : {}) });
208
- }
209
- catch (e) {
210
- log?.append({ run_id: runId, action: 'hub_search_miss', signals: signalList, reason: 'search_error', error: errMsg(e) });
211
- return { action: 'solve-fresh', mode, zeroHubCalls: false, reason: 'search_error' };
212
- }
213
- metadata = rows.map(toHubMetadata).filter((m) => m.assetId.length > 0);
214
- cache.setSearch(key, metadata);
393
+ const searchIncomplete = !searchComplete;
394
+ if (searchIncomplete && metadata.length === 0) {
395
+ log?.append({
396
+ run_id: runId,
397
+ action: 'hub_search_miss',
398
+ signals: signalList,
399
+ reason: 'search_error',
400
+ error: errMsg(searchFailure),
401
+ });
402
+ return { action: 'solve-fresh', mode, zeroHubCalls: false, reason: 'search_error' };
215
403
  }
216
404
  if (metadata.length === 0) {
217
- log?.append({ run_id: runId, action: 'hub_search_miss', signals: signalList, reason: 'no_results', via: searchCached ? 'search_cached' : 'search' });
218
- return { action: 'solve-fresh', mode, zeroHubCalls: searchCached, reason: 'no_results' };
405
+ const reason = searchIncomplete ? 'search_error' : 'no_results';
406
+ log?.append({
407
+ run_id: runId,
408
+ action: 'hub_search_miss',
409
+ signals: signalList,
410
+ reason,
411
+ via: searchCached ? 'search_cached' : 'search',
412
+ ...(searchIncomplete ? { error: errMsg(searchFailure) } : {}),
413
+ });
414
+ return { action: 'solve-fresh', mode, zeroHubCalls: searchCached, reason };
219
415
  }
220
416
  // ── Phase 2: PURE decision (core — no price) ──
221
417
  const ranked = scoreSearchResults(signalList, metadata, opts.now !== undefined ? { now: opts.now } : {});
222
418
  const decision = decideReuse(ranked, { threshold });
223
419
  if (decision.action === 'solve-fresh' || !decision.candidate) {
224
- log?.append({ run_id: runId, action: 'hub_search_miss', signals: signalList, reason: 'below_threshold', candidates: metadata.length, threshold });
225
- return { action: 'solve-fresh', mode, zeroHubCalls: searchCached, reason: 'below_threshold' };
420
+ const reason = searchIncomplete ? 'search_error' : 'below_threshold';
421
+ log?.append({
422
+ run_id: runId,
423
+ action: 'hub_search_miss',
424
+ signals: signalList,
425
+ reason,
426
+ candidates: metadata.length,
427
+ threshold,
428
+ ...(searchIncomplete ? { error: errMsg(searchFailure) } : {}),
429
+ });
430
+ return { action: 'solve-fresh', mode, zeroHubCalls: searchCached, reason };
226
431
  }
227
432
  // ── Phase 3: paid fetch for the ONE winner (payload cache → ZERO hub calls on hit) ──
228
433
  const winner = decision.candidate;
@@ -233,14 +438,14 @@ export async function reuseBeforeSolve(cap, cache, signals, opts = {}) {
233
438
  if (asset === null) {
234
439
  try {
235
440
  if (isAssetByIdFetcher(cap)) {
236
- asset = await cap.fetchAssetById(winnerId);
441
+ asset = normalizeMatchedAssetId(await cap.fetchAssetById(winnerId), winnerId);
237
442
  creditCost = asset?.['credit_cost'];
238
443
  }
239
444
  else {
240
445
  const results = await cap.fetch({ signalsAny: signalList, limit: metadata.length });
241
446
  // The paid fetch returns full payloads; select the winner by id (content-addressed match).
242
- asset = results.find((a) => String(a['asset_id'] ?? '') === winnerId)
243
- ?? null;
447
+ const fetched = results.find((candidate) => assetMatchesId(candidate, winnerId));
448
+ asset = normalizeMatchedAssetId(fetched, winnerId);
244
449
  // Economic receipt read-through (read-only): surface credit_cost if the hub attached one, never gate.
245
450
  const carrier = results.credit_cost
246
451
  ?? asset?.['credit_cost'];
@@ -258,7 +463,7 @@ export async function reuseBeforeSolve(cap, cache, signals, opts = {}) {
258
463
  payloadCached = false;
259
464
  }
260
465
  if (!asset) {
261
- return { action: 'solve-fresh', mode, zeroHubCalls: searchCached, reason: 'fetch_empty' };
466
+ return { action: 'solve-fresh', mode, zeroHubCalls: false, reason: 'fetch_empty' };
262
467
  }
263
468
  const zeroHubCalls = searchCached && payloadCached;
264
469
  log?.append({
@@ -270,7 +475,7 @@ export async function reuseBeforeSolve(cap, cache, signals, opts = {}) {
270
475
  // payload/cache pull rather than a fresh LLM solve, so ≈0 here. Never lets an emission error break reuse.
271
476
  if (opts.onReuseHit) {
272
477
  try {
273
- opts.onReuseHit({ assetId: winnerId, cycleId: opts.cycleId ?? '', signalFingerprint: key, fetchTokens: 0 });
478
+ opts.onReuseHit({ assetId: winnerId, cycleId: opts.cycleId ?? '', signalFingerprint: fingerprint, fetchTokens: 0 });
274
479
  }
275
480
  catch { /* emission must never break the reuse path */ }
276
481
  }
@@ -284,12 +489,78 @@ export async function reuseBeforeSolve(cap, cache, signals, opts = {}) {
284
489
  zeroHubCalls,
285
490
  };
286
491
  }
492
+ function mergeSearchRows(signalRows, semanticRows) {
493
+ const merged = [];
494
+ const indexById = new Map();
495
+ for (const row of signalRows) {
496
+ const record = row;
497
+ const assetId = String(record['asset_id'] ?? record['assetId'] ?? '');
498
+ if (!assetId || indexById.has(assetId))
499
+ continue;
500
+ indexById.set(assetId, merged.length);
501
+ merged.push(row);
502
+ }
503
+ for (const row of semanticRows) {
504
+ const record = row;
505
+ const assetId = String(record['asset_id'] ?? record['assetId'] ?? '');
506
+ if (!assetId)
507
+ continue;
508
+ const existingIndex = indexById.get(assetId);
509
+ if (existingIndex === undefined) {
510
+ indexById.set(assetId, merged.length);
511
+ merged.push(row);
512
+ continue;
513
+ }
514
+ const similarity = num(record['similarity'] ?? record['semantic_similarity'] ?? record['_semantic_similarity'] ?? record['semanticSimilarity']);
515
+ if (similarity !== undefined) {
516
+ merged[existingIndex] = { ...merged[existingIndex], similarity };
517
+ }
518
+ }
519
+ return merged;
520
+ }
287
521
  function errMsg(e) {
288
522
  return e instanceof Error ? e.message : String(e);
289
523
  }
290
524
  function isAssetByIdFetcher(value) {
291
525
  return typeof value.fetchAssetById === 'function';
292
526
  }
527
+ function normalizeMatchedAssetId(asset, assetId) {
528
+ if (!assetMatchesId(asset, assetId))
529
+ return null;
530
+ const record = asset;
531
+ if (typeof record['asset_id'] === 'string')
532
+ return asset;
533
+ const normalized = { ...record, asset_id: assetId };
534
+ if (record['assetId'] === assetId)
535
+ delete normalized['assetId'];
536
+ if (record['id'] === assetId)
537
+ delete normalized['id'];
538
+ return normalized;
539
+ }
293
540
  export function assetMatchesId(asset, assetId) {
294
- return Boolean(asset && asset.asset_id === assetId);
541
+ if (!asset)
542
+ return false;
543
+ const record = asset;
544
+ const canonicalAssetId = typeof record['asset_id'] === 'string' ? record['asset_id'] : undefined;
545
+ if (canonicalAssetId !== undefined && canonicalAssetId !== assetId)
546
+ return false;
547
+ const hasCamelAlias = typeof record['assetId'] === 'string';
548
+ if (canonicalAssetId === undefined && hasCamelAlias && record['assetId'] !== assetId)
549
+ return false;
550
+ if (canonicalAssetId === undefined && !hasCamelAlias && record['id'] !== assetId)
551
+ return false;
552
+ const content = { ...record };
553
+ if (record['assetId'] === assetId)
554
+ delete content['assetId'];
555
+ if (record['id'] === assetId)
556
+ delete content['id'];
557
+ try {
558
+ if (assetId.startsWith('sha256:') && !/^sha256:[0-9a-f]{64}$/.test(assetId))
559
+ return false;
560
+ const contentMatches = wire.computeAssetId(stripHubDeliveryMetadataForIntegrity(content)) === assetId;
561
+ return assetId.startsWith('sha256:') ? contentMatches : canonicalAssetId !== undefined || contentMatches;
562
+ }
563
+ catch {
564
+ return false;
565
+ }
295
566
  }
package/dist/index.d.ts CHANGED
@@ -14,6 +14,8 @@ export * from './antiAbuseTelemetry.js';
14
14
  export * from './offlinePermit.js';
15
15
  export * from './hubReuse.js';
16
16
  export * from './hubUrl.js';
17
+ export * from './learningPacketSink.js';
18
+ export * from './learningPacketFeedback.js';
17
19
  export * from './atp.js';
18
20
  export * from './pricing/modelPrices.js';
19
21
  export * from './connect.js';
package/dist/index.js CHANGED
@@ -14,6 +14,8 @@ export * from './antiAbuseTelemetry.js';
14
14
  export * from './offlinePermit.js';
15
15
  export * from './hubReuse.js';
16
16
  export * from './hubUrl.js';
17
+ export * from './learningPacketSink.js';
18
+ export * from './learningPacketFeedback.js';
17
19
  export * from './atp.js';
18
20
  export * from './pricing/modelPrices.js';
19
21
  export * from './connect.js';
@@ -0,0 +1,68 @@
1
+ import type { hub } from '@evomap/evolver-core';
2
+ import { type FetchLike } from './hubFetch.js';
3
+ /** Hub appendLearningFeedbackSchema closed enums (evomap-hub src/schemas/learningOps.js). */
4
+ export declare const LEARNING_FEEDBACK_TYPES: readonly ["outcome", "rating", "correction", "governance", "note"];
5
+ export type LearningFeedbackType = (typeof LEARNING_FEEDBACK_TYPES)[number];
6
+ export declare const LEARNING_FEEDBACK_DECISIONS: readonly ["accepted", "rejected", "needs_redaction", "not_training_eligible", "training_candidate", "note"];
7
+ export type LearningFeedbackDecision = (typeof LEARNING_FEEDBACK_DECISIONS)[number];
8
+ export interface LearningPacketFeedbackInput {
9
+ /** Default hub-side: 'outcome'. */
10
+ feedbackType?: LearningFeedbackType;
11
+ decision: LearningFeedbackDecision;
12
+ /** 0..1 (hub-validated). */
13
+ rating?: number;
14
+ scores?: Record<string, unknown>;
15
+ rationale?: string;
16
+ /** Hub VERIFIERS enum member (e.g. 'automated_test', 'human'). */
17
+ verifier?: string;
18
+ /** Hub FAILURE_CATEGORIES enum member. */
19
+ failureCategory?: string;
20
+ /** Anchor the feedback to one trace event instead of the whole packet. */
21
+ traceEventId?: string;
22
+ actorNodeId?: string;
23
+ }
24
+ export type LearningFeedbackResult = {
25
+ ok: true;
26
+ feedbackId?: string;
27
+ } | {
28
+ ok: false;
29
+ reason: string;
30
+ };
31
+ /** Server-managed governance/eligibility state read back from GET /api/learning-packets/:id. */
32
+ export interface LearningPacketStatus {
33
+ id: string;
34
+ status?: string;
35
+ outcomeStatus?: string | null;
36
+ verifier?: string | null;
37
+ /** LearningOpsTrainingEligibility mirror: pending/eligible/ineligible/revoked/expired. */
38
+ trainingEligibilityStatus?: string | null;
39
+ /** pending/approved/blocked/purge_requested. */
40
+ governanceStatus?: string | null;
41
+ trainingEligible?: boolean;
42
+ consentStatus?: string | null;
43
+ redactionStatus?: string | null;
44
+ retentionPolicy?: string | null;
45
+ }
46
+ export type LearningPacketReadResult = {
47
+ ok: true;
48
+ packet: LearningPacketStatus;
49
+ } | {
50
+ ok: false;
51
+ reason: string;
52
+ };
53
+ export interface HubLearningPacketFeedbackClientOptions {
54
+ baseUrl: string;
55
+ auth: hub.AuthProvider;
56
+ fetchFn: FetchLike;
57
+ }
58
+ /**
59
+ * Feedback append + packet governance read-back against the hub Learning Ops API. Best-effort by the
60
+ * same contract as HubLearningPacketSink: this is observability/ops tooling, so every failure —
61
+ * network, auth, 4xx/5xx, unparseable body — returns { ok:false, reason } and never throws.
62
+ */
63
+ export declare class HubLearningPacketFeedbackClient {
64
+ private readonly opts;
65
+ constructor(opts: HubLearningPacketFeedbackClientOptions);
66
+ submitFeedback(packetId: string, feedback: LearningPacketFeedbackInput): Promise<LearningFeedbackResult>;
67
+ getPacket(packetId: string): Promise<LearningPacketReadResult>;
68
+ }
@@ -0,0 +1,104 @@
1
+ import { assertHubUrlSecure, isHubUnreachableError } from './hubFetch.js';
2
+ import { learningOpsAuthHeaders } from './learningPacketSink.js';
3
+ /** Hub appendLearningFeedbackSchema closed enums (evomap-hub src/schemas/learningOps.js). */
4
+ export const LEARNING_FEEDBACK_TYPES = ['outcome', 'rating', 'correction', 'governance', 'note'];
5
+ export const LEARNING_FEEDBACK_DECISIONS = [
6
+ 'accepted', 'rejected', 'needs_redaction', 'not_training_eligible', 'training_candidate', 'note',
7
+ ];
8
+ function pickString(record, key) {
9
+ const value = record[key];
10
+ if (value === null)
11
+ return null;
12
+ return typeof value === 'string' ? value : undefined;
13
+ }
14
+ async function failureReason(res) {
15
+ const text = await res.text().catch(() => '');
16
+ return `hub ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}`;
17
+ }
18
+ /**
19
+ * Feedback append + packet governance read-back against the hub Learning Ops API. Best-effort by the
20
+ * same contract as HubLearningPacketSink: this is observability/ops tooling, so every failure —
21
+ * network, auth, 4xx/5xx, unparseable body — returns { ok:false, reason } and never throws.
22
+ */
23
+ export class HubLearningPacketFeedbackClient {
24
+ opts;
25
+ constructor(opts) {
26
+ this.opts = opts;
27
+ }
28
+ async submitFeedback(packetId, feedback) {
29
+ try {
30
+ const path = `/api/learning-packets/${encodeURIComponent(packetId)}/feedback`;
31
+ const url = `${this.opts.baseUrl}${path}`;
32
+ assertHubUrlSecure(url);
33
+ const headers = await learningOpsAuthHeaders(this.opts.auth, 'POST', path);
34
+ // Exactly the appendLearningFeedbackSchema fields (strict zod): optional keys are omitted, not nulled.
35
+ const res = await this.opts.fetchFn(url, {
36
+ method: 'POST',
37
+ headers,
38
+ redirect: 'manual',
39
+ body: JSON.stringify({
40
+ decision: feedback.decision,
41
+ ...(feedback.feedbackType !== undefined ? { feedbackType: feedback.feedbackType } : {}),
42
+ ...(feedback.rating !== undefined ? { rating: feedback.rating } : {}),
43
+ ...(feedback.scores !== undefined ? { scores: feedback.scores } : {}),
44
+ ...(feedback.rationale !== undefined ? { rationale: feedback.rationale } : {}),
45
+ ...(feedback.verifier !== undefined ? { verifier: feedback.verifier } : {}),
46
+ ...(feedback.failureCategory !== undefined ? { failureCategory: feedback.failureCategory } : {}),
47
+ ...(feedback.traceEventId !== undefined ? { traceEventId: feedback.traceEventId } : {}),
48
+ ...(feedback.actorNodeId !== undefined ? { actorNodeId: feedback.actorNodeId } : {}),
49
+ }),
50
+ });
51
+ if (res.status === 201) {
52
+ const body = await res.json().catch(() => null);
53
+ const row = body && typeof body === 'object' ? body.feedback : undefined;
54
+ return { ok: true, ...(typeof row?.id === 'string' ? { feedbackId: row.id } : {}) };
55
+ }
56
+ return { ok: false, reason: await failureReason(res) };
57
+ }
58
+ catch (e) {
59
+ if (isHubUnreachableError(e))
60
+ return { ok: false, reason: 'hub_unreachable' };
61
+ return { ok: false, reason: e instanceof Error ? e.message : String(e) };
62
+ }
63
+ }
64
+ async getPacket(packetId) {
65
+ try {
66
+ const path = `/api/learning-packets/${encodeURIComponent(packetId)}`;
67
+ const url = `${this.opts.baseUrl}${path}`;
68
+ assertHubUrlSecure(url);
69
+ const headers = await learningOpsAuthHeaders(this.opts.auth, 'GET', path);
70
+ const res = await this.opts.fetchFn(url, { method: 'GET', headers, redirect: 'manual' });
71
+ if (res.status !== 200)
72
+ return { ok: false, reason: await failureReason(res) };
73
+ const body = await res.json().catch(() => null);
74
+ const packet = body && typeof body === 'object' ? body.packet : undefined;
75
+ if (!packet || typeof packet !== 'object' || Array.isArray(packet)) {
76
+ return { ok: false, reason: 'hub 200: response missing packet object' };
77
+ }
78
+ const record = packet;
79
+ if (typeof record['id'] !== 'string' || record['id'].length === 0) {
80
+ return { ok: false, reason: 'hub 200: packet missing id' };
81
+ }
82
+ return {
83
+ ok: true,
84
+ packet: {
85
+ id: record['id'],
86
+ ...(pickString(record, 'status') !== undefined && pickString(record, 'status') !== null ? { status: record['status'] } : {}),
87
+ ...(pickString(record, 'outcomeStatus') !== undefined ? { outcomeStatus: pickString(record, 'outcomeStatus') } : {}),
88
+ ...(pickString(record, 'verifier') !== undefined ? { verifier: pickString(record, 'verifier') } : {}),
89
+ ...(pickString(record, 'trainingEligibilityStatus') !== undefined ? { trainingEligibilityStatus: pickString(record, 'trainingEligibilityStatus') } : {}),
90
+ ...(pickString(record, 'governanceStatus') !== undefined ? { governanceStatus: pickString(record, 'governanceStatus') } : {}),
91
+ ...(typeof record['trainingEligible'] === 'boolean' ? { trainingEligible: record['trainingEligible'] } : {}),
92
+ ...(pickString(record, 'consentStatus') !== undefined ? { consentStatus: pickString(record, 'consentStatus') } : {}),
93
+ ...(pickString(record, 'redactionStatus') !== undefined ? { redactionStatus: pickString(record, 'redactionStatus') } : {}),
94
+ ...(pickString(record, 'retentionPolicy') !== undefined ? { retentionPolicy: pickString(record, 'retentionPolicy') } : {}),
95
+ },
96
+ };
97
+ }
98
+ catch (e) {
99
+ if (isHubUnreachableError(e))
100
+ return { ok: false, reason: 'hub_unreachable' };
101
+ return { ok: false, reason: e instanceof Error ? e.message : String(e) };
102
+ }
103
+ }
104
+ }