@harperfast/harper 5.3.0 → 5.3.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.
- package/bin/copyDb.ts +5 -1
- package/bin/deployRenderer.ts +3 -0
- package/components/Application.ts +66 -22
- package/components/DESIGN.md +89 -36
- package/components/componentLoader.ts +2 -0
- package/components/deploymentRecorder.ts +18 -1
- package/components/installFingerprint.ts +123 -0
- package/components/mcp/DESIGN.md +4 -4
- package/components/operations.js +29 -0
- package/config/DESIGN.md +5 -5
- package/dataLayer/DESIGN.md +37 -22
- package/dataLayer/backupRepository.ts +248 -0
- package/dataLayer/restoreMarker.ts +30 -75
- package/dataLayer/rocksdbBackup.ts +253 -110
- package/dist/bin/copyDb.js +3 -0
- package/dist/bin/copyDb.js.map +1 -1
- package/dist/bin/deployRenderer.js +3 -0
- package/dist/bin/deployRenderer.js.map +1 -1
- package/dist/components/Application.d.ts +5 -0
- package/dist/components/Application.js +51 -19
- package/dist/components/Application.js.map +1 -1
- package/dist/components/componentLoader.js +2 -0
- package/dist/components/componentLoader.js.map +1 -1
- package/dist/components/deploymentRecorder.js +18 -1
- package/dist/components/deploymentRecorder.js.map +1 -1
- package/dist/components/installFingerprint.d.ts +39 -0
- package/dist/components/installFingerprint.js +113 -0
- package/dist/components/installFingerprint.js.map +1 -0
- package/dist/components/operations.d.ts +2 -0
- package/dist/components/operations.js +31 -0
- package/dist/components/operations.js.map +1 -1
- package/dist/dataLayer/backupRepository.d.ts +33 -0
- package/dist/dataLayer/backupRepository.js +232 -0
- package/dist/dataLayer/backupRepository.js.map +1 -0
- package/dist/dataLayer/restoreMarker.d.ts +4 -1
- package/dist/dataLayer/restoreMarker.js +29 -68
- package/dist/dataLayer/restoreMarker.js.map +1 -1
- package/dist/dataLayer/rocksdbBackup.js +221 -112
- package/dist/dataLayer/rocksdbBackup.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js.map +1 -1
- package/dist/json/systemSchema.json +14 -0
- package/dist/resources/DatabaseTransaction.d.ts +140 -11
- package/dist/resources/DatabaseTransaction.js +1055 -464
- package/dist/resources/DatabaseTransaction.js.map +1 -1
- package/dist/resources/IterableEventQueue.d.ts +2 -1
- package/dist/resources/IterableEventQueue.js +16 -8
- package/dist/resources/IterableEventQueue.js.map +1 -1
- package/dist/resources/LMDBTransaction.d.ts +13 -2
- package/dist/resources/LMDBTransaction.js +219 -93
- package/dist/resources/LMDBTransaction.js.map +1 -1
- package/dist/resources/RecordEncoder.js +2 -3
- package/dist/resources/RecordEncoder.js.map +1 -1
- package/dist/resources/Resource.js +5 -6
- package/dist/resources/Resource.js.map +1 -1
- package/dist/resources/ResourceInterface.d.ts +3 -1
- package/dist/resources/ResourceInterface.js.map +1 -1
- package/dist/resources/RocksIndexStore.d.ts +2 -1
- package/dist/resources/RocksIndexStore.js +3 -0
- package/dist/resources/RocksIndexStore.js.map +1 -1
- package/dist/resources/Table.js +116 -12
- package/dist/resources/Table.js.map +1 -1
- package/dist/resources/analytics/write.js +6 -2
- package/dist/resources/analytics/write.js.map +1 -1
- package/dist/resources/auditStore.d.ts +71 -17
- package/dist/resources/auditStore.js +248 -64
- package/dist/resources/auditStore.js.map +1 -1
- package/dist/resources/branchDatabase.js +2 -0
- package/dist/resources/branchDatabase.js.map +1 -1
- package/dist/resources/databases.js +8 -2
- package/dist/resources/databases.js.map +1 -1
- package/dist/resources/indexes/fullTextNativeBinding.d.ts +6 -2
- package/dist/resources/indexes/fullTextNativeBinding.js +12 -2
- package/dist/resources/indexes/fullTextNativeBinding.js.map +1 -1
- package/dist/resources/indexes/fullTextQueryIndex.d.ts +4 -0
- package/dist/resources/indexes/fullTextQueryIndex.js +207 -10
- package/dist/resources/indexes/fullTextQueryIndex.js.map +1 -1
- package/dist/resources/models/Models.d.ts +11 -0
- package/dist/resources/models/Models.js +95 -4
- package/dist/resources/models/Models.js.map +1 -1
- package/dist/resources/models/backendRegistry.d.ts +8 -0
- package/dist/resources/models/backendRegistry.js +15 -1
- package/dist/resources/models/backendRegistry.js.map +1 -1
- package/dist/resources/models/bootstrap.d.ts +2 -0
- package/dist/resources/models/bootstrap.js +6 -0
- package/dist/resources/models/bootstrap.js.map +1 -1
- package/dist/resources/models/calibration.d.ts +88 -0
- package/dist/resources/models/calibration.js +244 -0
- package/dist/resources/models/calibration.js.map +1 -0
- package/dist/resources/models/calibrationStore.d.ts +163 -0
- package/dist/resources/models/calibrationStore.js +986 -0
- package/dist/resources/models/calibrationStore.js.map +1 -0
- package/dist/resources/models/decisionStore.d.ts +24 -0
- package/dist/resources/models/decisionStore.js +45 -0
- package/dist/resources/models/decisionStore.js.map +1 -1
- package/dist/resources/models/generativeDecision.js +44 -15
- package/dist/resources/models/generativeDecision.js.map +1 -1
- package/dist/resources/models/types.d.ts +7 -0
- package/dist/resources/recordLock.d.ts +8 -5
- package/dist/resources/recordLock.js +6 -4
- package/dist/resources/recordLock.js.map +1 -1
- package/dist/resources/replayLogs.js +8 -1
- package/dist/resources/replayLogs.js.map +1 -1
- package/dist/resources/scheduler/engine.d.ts +4 -0
- package/dist/resources/scheduler/engine.js +30 -2
- package/dist/resources/scheduler/engine.js.map +1 -1
- package/dist/resources/search.d.ts +16 -4
- package/dist/resources/search.js +117 -13
- package/dist/resources/search.js.map +1 -1
- package/dist/resources/transaction.js +85 -5
- package/dist/resources/transaction.js.map +1 -1
- package/dist/resources/transactionBroadcast.d.ts +8 -0
- package/dist/resources/transactionBroadcast.js +65 -0
- package/dist/resources/transactionBroadcast.js.map +1 -1
- package/dist/security/keys.d.ts +1 -1
- package/dist/security/keys.js +170 -120
- package/dist/security/keys.js.map +1 -1
- package/dist/server/http.js +10 -0
- package/dist/server/http.js.map +1 -1
- package/dist/server/serverHelpers/Request.js +11 -2
- package/dist/server/serverHelpers/Request.js.map +1 -1
- package/dist/server/serverHelpers/contentTypes.js +8 -3
- package/dist/server/serverHelpers/contentTypes.js.map +1 -1
- package/dist/server/serverHelpers/uwsServer.js +38 -7
- package/dist/server/serverHelpers/uwsServer.js.map +1 -1
- package/dist/server/threads/manageThreads.d.ts +3 -3
- package/dist/server/threads/manageThreads.js +15 -13
- package/dist/server/threads/manageThreads.js.map +1 -1
- package/dist/server/threads/threadServer.d.ts +3 -0
- package/dist/server/threads/threadServer.js +24 -6
- package/dist/server/threads/threadServer.js.map +1 -1
- package/dist/upgrade/directives/5-3-0.js +9 -3
- package/dist/upgrade/directives/5-3-0.js.map +1 -1
- package/dist/utility/durableFile.d.ts +36 -0
- package/dist/utility/durableFile.js +106 -0
- package/dist/utility/durableFile.js.map +1 -0
- package/dist/utility/errors/hdbError.d.ts +4 -0
- package/dist/utility/errors/hdbError.js +10 -1
- package/dist/utility/errors/hdbError.js.map +1 -1
- package/dist/utility/globalSchema.d.ts +9 -0
- package/dist/utility/hdbTerms.d.ts +1 -0
- package/dist/utility/hdbTerms.js +1 -0
- package/dist/utility/hdbTerms.js.map +1 -1
- package/dist/validation/configValidator.js +29 -0
- package/dist/validation/configValidator.js.map +1 -1
- package/index.ts +7 -0
- package/json/systemSchema.json +14 -0
- package/npm-shrinkwrap.json +22 -22
- package/package.json +2 -2
- package/resources/DESIGN.md +57 -272
- package/resources/DatabaseTransaction.ts +708 -87
- package/resources/FULL_TEXT.md +6 -2
- package/resources/IterableEventQueue.ts +14 -7
- package/resources/LMDBTransaction.ts +212 -84
- package/resources/RecordEncoder.ts +2 -3
- package/resources/Resource.ts +5 -6
- package/resources/ResourceInterface.ts +3 -1
- package/resources/RocksIndexStore.ts +5 -0
- package/resources/Table.ts +118 -15
- package/resources/analytics/write.ts +6 -2
- package/resources/audit-retention.md +203 -0
- package/resources/auditStore.ts +306 -84
- package/resources/branchDatabase.ts +2 -0
- package/resources/databases.ts +6 -2
- package/resources/indexes/DESIGN.md +120 -177
- package/resources/indexes/fullTextNativeBinding.ts +22 -3
- package/resources/indexes/fullTextQueryIndex.ts +274 -11
- package/resources/models/DESIGN.md +11 -3
- package/resources/models/Models.ts +127 -4
- package/resources/models/backendRegistry.ts +17 -0
- package/resources/models/bootstrap.ts +9 -1
- package/resources/models/calibration.ts +298 -0
- package/resources/models/calibrationStore.ts +1167 -0
- package/resources/models/decisionStore.ts +57 -0
- package/resources/models/generativeDecision.ts +54 -16
- package/resources/models/types.ts +6 -0
- package/resources/record-locks.md +155 -141
- package/resources/recordLock.ts +12 -6
- package/resources/replayLogs.ts +9 -2
- package/resources/scheduler/DESIGN.md +4 -3
- package/resources/scheduler/engine.ts +35 -2
- package/resources/search.ts +154 -16
- package/resources/transaction.ts +50 -5
- package/resources/transactionBroadcast.ts +69 -0
- package/security/DESIGN.md +9 -30
- package/security/keys.ts +175 -127
- package/server/DESIGN.md +70 -36
- package/server/http.ts +10 -0
- package/server/serverHelpers/Request.ts +10 -2
- package/server/serverHelpers/contentTypes.ts +5 -3
- package/server/serverHelpers/uwsServer.ts +34 -7
- package/server/threads/manageThreads.js +16 -14
- package/server/threads/threadServer.js +24 -6
- package/studio/web/assets/{Chat-CNEKMllJ.js → Chat-4LMxI0yB.js} +1 -1
- package/studio/web/assets/{FloatingChat-CEnWhHeR.js → FloatingChat-kWHApEtF.js} +3 -3
- package/studio/web/assets/{SearchDialog-CwhJdEdY.js → SearchDialog-B7ymna97.js} +1 -1
- package/studio/web/assets/{apiToken-DC5vsaaO.js → apiToken-CE3AAt9f.js} +1 -1
- package/studio/web/assets/{applications-DQZxDy0x.js → applications-DdN7CqsB.js} +1 -1
- package/studio/web/assets/{cssMode-Dz2DxF4e.js → cssMode-BpySZUSt.js} +1 -1
- package/studio/web/assets/{editor-B3mjd3tz.js → editor-myLCVv9C.js} +1 -1
- package/studio/web/assets/{html-BsRGM7tp.js → html-DGNYO55O.js} +1 -1
- package/studio/web/assets/{htmlMode-tUKJaA3o.js → htmlMode-BUgpQr41.js} +1 -1
- package/studio/web/assets/{index-BuqFjN5K.js → index-FN4KC9va.js} +39 -39
- package/studio/web/assets/index-QLZw6xrU.css +1 -0
- package/studio/web/assets/{index.lazy-CcpfQFY_.js → index.lazy-DZFuaumG.js} +1 -1
- package/studio/web/assets/{javascript-BZ_uRGHl.js → javascript-DiBzh4pj.js} +1 -1
- package/studio/web/assets/{jsonMode-DgB-aFMj.js → jsonMode-Bm312nmu.js} +1 -1
- package/studio/web/assets/{languageServices-BleuBw17.js → languageServices-Dz2K9rZN.js} +1 -1
- package/studio/web/assets/{lspLanguageFeatures-ByVYLSsQ.js → lspLanguageFeatures-CNu1L5xn.js} +1 -1
- package/studio/web/assets/{notifications-TuzN0IMi.js → notifications--nwFLGmM.js} +1 -1
- package/studio/web/assets/{notifications-D1Nf-Edn.js → notifications-BelKw5bR.js} +1 -1
- package/studio/web/assets/{profile-D7C7Lupz.js → profile-AU5FjOBx.js} +1 -1
- package/studio/web/assets/{regions-nGQCm-v8.js → regions-C0sTSEHH.js} +1 -1
- package/studio/web/assets/{register-C3x9Ze6P.js → register-DyNR2rGk.js} +2 -2
- package/studio/web/assets/{setComponentFile-DpjrRqeG.js → setComponentFile-BkWx8AN3.js} +1 -1
- package/studio/web/assets/{setup-CGiKLZ9e.js → setup-D6LZS78d.js} +2 -2
- package/studio/web/assets/{status-CGJwrNPH.js → status-BCWnn70o.js} +1 -1
- package/studio/web/assets/{toggleHighContrast-C-Ne6P7T.js → toggleHighContrast-pyeGJ6W1.js} +1 -1
- package/studio/web/assets/{tsMode-CSreYTys.js → tsMode-CGV3iaWC.js} +1 -1
- package/studio/web/assets/{typescript-MnxBk89W.js → typescript-Ci7Khvwo.js} +1 -1
- package/studio/web/assets/{useEntityRestURL-TNrh77-s.js → useEntityRestURL-DNiz3aZ0.js} +1 -1
- package/studio/web/assets/{workers-BfZlUNVI.js → workers-byzxjbqy.js} +1 -1
- package/studio/web/assets/{xml-BHd6qGH7.js → xml-DGnHQ3CX.js} +1 -1
- package/studio/web/assets/{yaml-C53R8OQq.js → yaml-BUoTnPWb.js} +1 -1
- package/studio/web/index.html +2 -2
- package/upgrade/directives/5-3-0.ts +10 -3
- package/utility/durableFile.ts +96 -0
- package/utility/errors/hdbError.ts +9 -0
- package/utility/hdbTerms.ts +1 -0
- package/validation/configValidator.ts +31 -0
- package/studio/web/assets/index-72cEH474.css +0 -1
|
@@ -36,10 +36,24 @@ import {
|
|
|
36
36
|
type DerivedNativeIndexHost,
|
|
37
37
|
} from './hnswDerivedIndex.ts';
|
|
38
38
|
import { loggerWithTag } from '../../utility/logging/logger.ts';
|
|
39
|
+
import type { CandidateKeyPlan } from '../search.ts';
|
|
40
|
+
import type { Id } from '../ResourceInterface.ts';
|
|
39
41
|
|
|
40
42
|
const RAW_PAGE_SIZE = 256;
|
|
43
|
+
const MAX_FILTERED_RAW_PAGE_SIZE = 4_096;
|
|
41
44
|
const MIN_RAW_PAGE_SIZE = 32;
|
|
42
45
|
const RAW_PAGE_OVERFETCH_FACTOR = 2;
|
|
46
|
+
const RAW_PAGE_ZERO_YIELD_GROWTH_FACTOR = 4;
|
|
47
|
+
const RAW_PAGE_YIELD_INTERVAL = 256;
|
|
48
|
+
const CANDIDATE_KEYS_PER_SOURCE_READ = 8;
|
|
49
|
+
const MAX_CANDIDATE_KEYS = 4_096;
|
|
50
|
+
// Four key-only reads remain cheaper than loading and decoding the authoritative record they replace.
|
|
51
|
+
const MAX_POINT_PROBE_READS_PER_CANDIDATE = 4;
|
|
52
|
+
const REJECTED_GATE_SAMPLE_SIZE = 8;
|
|
53
|
+
const CANDIDATE_GATE_FAILURE_RETRY_MILLISECONDS = 1_000;
|
|
54
|
+
// Fulltext 0.5.0 Query API v3 uses a 13-byte response header and 13 bytes per versioned hit before string data.
|
|
55
|
+
const NATIVE_SEARCH_RESPONSE_HEADER_BYTES = 13;
|
|
56
|
+
const NATIVE_SEARCH_HIT_OVERHEAD_BYTES = 13;
|
|
43
57
|
const MAX_RELOAD_FAILURES_BEFORE_REOPEN = 3;
|
|
44
58
|
const HIGHLIGHT_BLOB_READ_TIMEOUT_MILLISECONDS = 5_000;
|
|
45
59
|
const READER_DRAIN_GRACE_MILLISECONDS = 1_000;
|
|
@@ -72,6 +86,12 @@ type ReaderSlot = {
|
|
|
72
86
|
reloadFailures: number;
|
|
73
87
|
};
|
|
74
88
|
|
|
89
|
+
type CandidateGate = {
|
|
90
|
+
complete: boolean;
|
|
91
|
+
maxReadsPerCandidate: number;
|
|
92
|
+
has(primaryKey: Id): boolean;
|
|
93
|
+
};
|
|
94
|
+
|
|
75
95
|
type FullTextQueryHost = Pick<DerivedNativeIndexHost, 'readiness' | 'waitForCoverage' | 'requestRebuild'>;
|
|
76
96
|
|
|
77
97
|
class FullTextReaderPublicationError extends Error {}
|
|
@@ -97,6 +117,8 @@ export type FullTextQueryIndexOptions = {
|
|
|
97
117
|
|
|
98
118
|
export class FullTextQueryIndex {
|
|
99
119
|
readonly filteredSearch = true;
|
|
120
|
+
readonly candidateKeyFilter = true;
|
|
121
|
+
readonly candidateKeyProbe = true;
|
|
100
122
|
readonly filePrimary = true;
|
|
101
123
|
readonly #options: FullTextQueryIndexOptions;
|
|
102
124
|
readonly #nativeOptions: NativeFullTextIndexConfiguration & {
|
|
@@ -112,6 +134,7 @@ export class FullTextQueryIndex {
|
|
|
112
134
|
#readerOperation?: Promise<ReaderSlot>;
|
|
113
135
|
#retiredReaderSlots = new Set<ReaderSlot>();
|
|
114
136
|
#maxSearchWindow?: number;
|
|
137
|
+
#maxFilteredRawPageSize?: number;
|
|
115
138
|
#maxAutocompleteResults?: number;
|
|
116
139
|
#maxSearchBudgetMilliseconds?: number;
|
|
117
140
|
#maxTraceRecords?: number;
|
|
@@ -125,6 +148,13 @@ export class FullTextQueryIndex {
|
|
|
125
148
|
#publicationRebuildRequested = false;
|
|
126
149
|
#refreshFailureWarned = false;
|
|
127
150
|
#unexpectedSearchFailureWarned = false;
|
|
151
|
+
#incompletePageFailureLogged = false;
|
|
152
|
+
#candidateCollectionFailureWarned = false;
|
|
153
|
+
#candidateProbeFailureWarned = false;
|
|
154
|
+
#candidateCollectionRetryAfter = 0;
|
|
155
|
+
#candidateProbeRetryAfter = 0;
|
|
156
|
+
#candidateCollectionFailureEpoch = 0;
|
|
157
|
+
#candidateProbeFailureEpoch = 0;
|
|
128
158
|
|
|
129
159
|
constructor(options: FullTextQueryIndexOptions) {
|
|
130
160
|
this.#options = options;
|
|
@@ -184,6 +214,7 @@ export class FullTextQueryIndex {
|
|
|
184
214
|
minResults?: number;
|
|
185
215
|
resultOffset?: number;
|
|
186
216
|
assertTransactionActive?: () => void;
|
|
217
|
+
candidateKeys?: CandidateKeyPlan;
|
|
187
218
|
} = {}
|
|
188
219
|
): Promise<Array<{ key: unknown; $score: number; $highlights?: Record<string, unknown>; loadedEntry: any }>> {
|
|
189
220
|
const maxIndexLagMilliseconds = condition.maxIndexLagMilliseconds ?? DEFAULT_MAX_INDEX_LAG_MILLISECONDS;
|
|
@@ -225,11 +256,13 @@ export class FullTextQueryIndex {
|
|
|
225
256
|
context?.signal?.throwIfAborted();
|
|
226
257
|
const result = await this.#search(condition, context, options);
|
|
227
258
|
this.#unexpectedSearchFailureWarned = false;
|
|
259
|
+
this.#incompletePageFailureLogged = false;
|
|
228
260
|
return result;
|
|
229
261
|
}
|
|
230
262
|
coverage = this.#queryCoverage(maxIndexLagMilliseconds);
|
|
231
263
|
const result = await this.#search(condition, context, options);
|
|
232
264
|
this.#unexpectedSearchFailureWarned = false;
|
|
265
|
+
this.#incompletePageFailureLogged = false;
|
|
233
266
|
return result;
|
|
234
267
|
};
|
|
235
268
|
const operation = execute().catch((error) => {
|
|
@@ -333,6 +366,7 @@ export class FullTextQueryIndex {
|
|
|
333
366
|
minResults?: number;
|
|
334
367
|
resultOffset?: number;
|
|
335
368
|
assertTransactionActive?: () => void;
|
|
369
|
+
candidateKeys?: CandidateKeyPlan;
|
|
336
370
|
}
|
|
337
371
|
): Promise<Array<{ key: unknown; $score: number; $highlights?: Record<string, unknown>; loadedEntry: any }>> {
|
|
338
372
|
const readiness = readDerivedIndexReadiness(this.#options.auditStore, this.#options.readinessId);
|
|
@@ -395,13 +429,25 @@ export class FullTextQueryIndex {
|
|
|
395
429
|
let moreMayExist = false;
|
|
396
430
|
let staleVersionHits = 0;
|
|
397
431
|
let expiredHits = 0;
|
|
432
|
+
let rejectedGateSampleCount = 0;
|
|
433
|
+
let rejectedGateKeys: Id[] | undefined;
|
|
434
|
+
let rejectedGateVersions: string[] | undefined;
|
|
435
|
+
let rawPageSize = bounded
|
|
436
|
+
? Math.min(
|
|
437
|
+
this.#maxFilteredRawPageSize!,
|
|
438
|
+
RAW_PAGE_SIZE,
|
|
439
|
+
Math.max(MIN_RAW_PAGE_SIZE, target * RAW_PAGE_OVERFETCH_FACTOR)
|
|
440
|
+
)
|
|
441
|
+
: Math.min(this.#maxFilteredRawPageSize!, RAW_PAGE_SIZE);
|
|
398
442
|
const transaction = context && this.#options.Table._readTxnForContext(context);
|
|
443
|
+
const candidatePlan = options.filter ? options.candidateKeys : undefined;
|
|
444
|
+
let candidateGate: CandidateGate | undefined;
|
|
445
|
+
let candidateGatePlanned = false;
|
|
446
|
+
let candidateProbeFailureEpoch = 0;
|
|
399
447
|
while (accepted.length < target && offset < searchWindow) {
|
|
400
448
|
if (context?.signal?.aborted) throw context.signal.reason ?? new Error('Full-text search aborted');
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
: RAW_PAGE_SIZE;
|
|
404
|
-
const limit = autocomplete ? searchWindow : Math.min(RAW_PAGE_SIZE, desiredPageSize, searchWindow - offset);
|
|
449
|
+
const limit = autocomplete ? searchWindow : Math.min(rawPageSize, searchWindow - offset);
|
|
450
|
+
const acceptedBeforePage = accepted.length;
|
|
405
451
|
const result = await reader.search(
|
|
406
452
|
{
|
|
407
453
|
query,
|
|
@@ -413,15 +459,111 @@ export class FullTextQueryIndex {
|
|
|
413
459
|
);
|
|
414
460
|
if (!bounded && !autocomplete && result.totalRelation === 'exact' && result.total > searchWindow)
|
|
415
461
|
throw new ClientError(`Full-text query exceeds the ${searchWindow}-result search window; add a limit`, 400);
|
|
462
|
+
// Fulltext 0.5.0 Query API v3 only reports lower-bound while a full requested page remains retrievable.
|
|
463
|
+
if (
|
|
464
|
+
result.totalRelation === 'lower-bound' &&
|
|
465
|
+
result.hits.length < limit &&
|
|
466
|
+
offset + result.hits.length < result.total
|
|
467
|
+
) {
|
|
468
|
+
if (!this.#incompletePageFailureLogged) {
|
|
469
|
+
this.#incompletePageFailureLogged = true;
|
|
470
|
+
logger.error?.(
|
|
471
|
+
`Full-text index '${this.#definition.name}' returned ${result.hits.length} of ${limit} hits at offset ${offset} with lower-bound total ${result.total}`
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
throw new ServerError('Full-text index returned an incomplete result page', 500);
|
|
475
|
+
}
|
|
476
|
+
// An exact total can safely continue from the returned offset; only a short lower-bound page breaks the v3 contract.
|
|
416
477
|
moreMayExist =
|
|
417
478
|
result.totalRelation === 'exact' ? offset + result.hits.length < result.total : result.hits.length === limit;
|
|
418
479
|
if (result.hits.length === 0) break;
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
480
|
+
if (!candidateGatePlanned) {
|
|
481
|
+
candidateGatePlanned = true;
|
|
482
|
+
if (candidatePlan) {
|
|
483
|
+
const assertSearchActive = () => {
|
|
484
|
+
if (context?.signal?.aborted) throw context.signal.reason ?? new Error('Full-text search aborted');
|
|
485
|
+
options.assertTransactionActive?.();
|
|
486
|
+
remainingSearchBudget(deadline);
|
|
487
|
+
};
|
|
488
|
+
assertSearchActive();
|
|
489
|
+
const nativeHitCount = Math.min(searchWindow, Math.max(result.hits.length, result.total));
|
|
490
|
+
candidateGate = await this.#candidateGate(
|
|
491
|
+
candidatePlan,
|
|
492
|
+
target,
|
|
493
|
+
nativeHitCount,
|
|
494
|
+
async () => {
|
|
495
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
496
|
+
assertSearchActive();
|
|
497
|
+
},
|
|
498
|
+
assertSearchActive
|
|
499
|
+
);
|
|
500
|
+
candidateProbeFailureEpoch = this.#candidateProbeFailureEpoch;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
const pointReadsPerCandidate = candidateGate?.maxReadsPerCandidate ?? 0;
|
|
504
|
+
const workYieldInterval = Math.min(
|
|
505
|
+
limit,
|
|
506
|
+
pointReadsPerCandidate === 0
|
|
507
|
+
? RAW_PAGE_YIELD_INTERVAL
|
|
508
|
+
: Math.max(1, Math.floor(RAW_PAGE_YIELD_INTERVAL / (pointReadsPerCandidate + 1)))
|
|
509
|
+
);
|
|
510
|
+
let nextYieldIndex = workYieldInterval;
|
|
511
|
+
for (let hitIndex = 0; hitIndex < result.hits.length; hitIndex++) {
|
|
512
|
+
if (hitIndex === nextYieldIndex) {
|
|
513
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
514
|
+
if (context?.signal?.aborted) throw context.signal.reason ?? new Error('Full-text search aborted');
|
|
515
|
+
remainingSearchBudget(deadline);
|
|
516
|
+
nextYieldIndex += workYieldInterval;
|
|
517
|
+
}
|
|
518
|
+
const hit = result.hits[hitIndex];
|
|
519
|
+
const key = decodeNativeId(hit.id, this.#options.Table.tableId) as Id;
|
|
423
520
|
if (typeof hit.version !== 'string')
|
|
424
521
|
throw new ServerError('Full-text index returned a hit without a source version', 500);
|
|
522
|
+
options.assertTransactionActive?.();
|
|
523
|
+
let candidateDecides = false;
|
|
524
|
+
if (candidateGate) {
|
|
525
|
+
try {
|
|
526
|
+
const admitted = candidateGate.has(key);
|
|
527
|
+
if (
|
|
528
|
+
candidateGate.maxReadsPerCandidate > 0 &&
|
|
529
|
+
candidateProbeFailureEpoch === this.#candidateProbeFailureEpoch
|
|
530
|
+
) {
|
|
531
|
+
this.#candidateProbeFailureWarned = false;
|
|
532
|
+
this.#candidateProbeRetryAfter = 0;
|
|
533
|
+
}
|
|
534
|
+
if (!admitted) {
|
|
535
|
+
if (rejectedGateSampleCount < REJECTED_GATE_SAMPLE_SIZE) {
|
|
536
|
+
(rejectedGateKeys ??= [])[rejectedGateSampleCount] = key;
|
|
537
|
+
(rejectedGateVersions ??= [])[rejectedGateSampleCount] = hit.version;
|
|
538
|
+
rejectedGateSampleCount++;
|
|
539
|
+
}
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
candidateDecides = candidateGate.complete;
|
|
543
|
+
} catch (error) {
|
|
544
|
+
const usedPointProbe = candidateGate.maxReadsPerCandidate > 0;
|
|
545
|
+
candidateGate = undefined;
|
|
546
|
+
if (usedPointProbe) {
|
|
547
|
+
this.#candidateProbeFailureEpoch++;
|
|
548
|
+
this.#candidateProbeRetryAfter = Date.now() + CANDIDATE_GATE_FAILURE_RETRY_MILLISECONDS;
|
|
549
|
+
if (!this.#candidateProbeFailureWarned) {
|
|
550
|
+
this.#candidateProbeFailureWarned = true;
|
|
551
|
+
logger.warn?.('could not probe the full-text companion index; using the record predicate', error);
|
|
552
|
+
}
|
|
553
|
+
} else {
|
|
554
|
+
this.#candidateCollectionFailureEpoch++;
|
|
555
|
+
this.#candidateCollectionRetryAfter = Date.now() + CANDIDATE_GATE_FAILURE_RETRY_MILLISECONDS;
|
|
556
|
+
if (!this.#candidateCollectionFailureWarned) {
|
|
557
|
+
this.#candidateCollectionFailureWarned = true;
|
|
558
|
+
logger.warn?.(
|
|
559
|
+
'could not apply the full-text candidate-key set; using the record predicate; point probes may resume on later requests',
|
|
560
|
+
error
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
const entry = this.#options.Table.primaryStore.getEntry(key, { transaction });
|
|
425
567
|
if (!entry?.value || hit.version !== String(entry.version)) {
|
|
426
568
|
staleVersionHits++;
|
|
427
569
|
continue;
|
|
@@ -434,17 +576,50 @@ export class FullTextQueryIndex {
|
|
|
434
576
|
expiredHits++;
|
|
435
577
|
continue;
|
|
436
578
|
}
|
|
437
|
-
if (options.filter && !options.filter(key, entry)) continue;
|
|
579
|
+
if (options.filter && !candidateDecides && !options.filter(key, entry)) continue;
|
|
438
580
|
accepted.push({ key, $score: hit.score, nativeId: hit.id, record: entry.value, recordEntry: entry });
|
|
439
581
|
if (accepted.length >= target) break;
|
|
440
582
|
}
|
|
441
583
|
offset += result.hits.length;
|
|
442
584
|
if (autocomplete) break;
|
|
443
585
|
if (!moreMayExist) break;
|
|
444
|
-
if (accepted.length < target)
|
|
586
|
+
if (accepted.length < target) {
|
|
587
|
+
if (bounded) {
|
|
588
|
+
const acceptedThisPage = accepted.length - acceptedBeforePage;
|
|
589
|
+
rawPageSize = nextFilteredRawPageSize(
|
|
590
|
+
limit,
|
|
591
|
+
result.hits.length,
|
|
592
|
+
acceptedThisPage,
|
|
593
|
+
target - accepted.length,
|
|
594
|
+
this.#maxFilteredRawPageSize!
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
598
|
+
}
|
|
445
599
|
}
|
|
446
600
|
if (!bounded && moreMayExist)
|
|
447
601
|
throw new ClientError(`Full-text query exceeds the ${searchWindow}-result search window; add a limit`, 400);
|
|
602
|
+
if (
|
|
603
|
+
bounded &&
|
|
604
|
+
accepted.length < target &&
|
|
605
|
+
moreMayExist &&
|
|
606
|
+
staleVersionHits === 0 &&
|
|
607
|
+
rejectedGateSampleCount > 0
|
|
608
|
+
) {
|
|
609
|
+
options.assertTransactionActive?.();
|
|
610
|
+
for (let sampleIndex = 0; sampleIndex < rejectedGateSampleCount; sampleIndex++) {
|
|
611
|
+
const entry = this.#options.Table.primaryStore.getEntry(rejectedGateKeys![sampleIndex], { transaction });
|
|
612
|
+
if (
|
|
613
|
+
!entry?.value ||
|
|
614
|
+
rejectedGateVersions![sampleIndex] !== String(entry.version) ||
|
|
615
|
+
this.#options.Table.isFullTextSearchEntryCurrent?.(entry) === false
|
|
616
|
+
) {
|
|
617
|
+
staleVersionHits++;
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
620
|
+
if (entry.expiresAt !== undefined && entry.expiresAt < Date.now()) expiredHits++;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
448
623
|
if (bounded && accepted.length < target && moreMayExist && staleVersionHits > 0)
|
|
449
624
|
throw new DerivedIndexLagError(
|
|
450
625
|
`Full-text index '${this.#definition.name}' changed while searching; retry this query`
|
|
@@ -475,6 +650,64 @@ export class FullTextQueryIndex {
|
|
|
475
650
|
}
|
|
476
651
|
}
|
|
477
652
|
|
|
653
|
+
async #candidateGate(
|
|
654
|
+
plan: CandidateKeyPlan,
|
|
655
|
+
target: number,
|
|
656
|
+
maxSourceReads: number,
|
|
657
|
+
cooperate: () => Promise<void>,
|
|
658
|
+
assertActive: () => void
|
|
659
|
+
): Promise<CandidateGate | undefined> {
|
|
660
|
+
const store = this.#options.Table.primaryStore;
|
|
661
|
+
const estimatedRecords =
|
|
662
|
+
typeof store.getEstimatedKeyCount === 'function' ? store.getEstimatedKeyCount() : store.getStats?.().entryCount;
|
|
663
|
+
const recordCount = Math.max(1, Number.isFinite(estimatedRecords) ? estimatedRecords : target);
|
|
664
|
+
const selectivity = Math.min(1, Math.max(1, plan.estimatedCount) / recordCount);
|
|
665
|
+
const expectedSourceReads = Math.min(maxSourceReads, Math.ceil(target / selectivity));
|
|
666
|
+
const expectedRejectedReads = Math.ceil(expectedSourceReads * (1 - selectivity));
|
|
667
|
+
const maxKeys = Math.min(MAX_CANDIDATE_KEYS, Math.ceil(expectedRejectedReads * CANDIDATE_KEYS_PER_SOURCE_READ));
|
|
668
|
+
const now = Date.now();
|
|
669
|
+
if (plan.estimatedCount <= maxKeys && now >= this.#candidateCollectionRetryAfter) {
|
|
670
|
+
const collectionFailureEpoch = this.#candidateCollectionFailureEpoch;
|
|
671
|
+
try {
|
|
672
|
+
const collected = await plan.collect(maxKeys, cooperate);
|
|
673
|
+
assertActive();
|
|
674
|
+
if (collectionFailureEpoch === this.#candidateCollectionFailureEpoch) {
|
|
675
|
+
this.#candidateCollectionFailureWarned = false;
|
|
676
|
+
this.#candidateCollectionRetryAfter = 0;
|
|
677
|
+
}
|
|
678
|
+
if (collected)
|
|
679
|
+
return {
|
|
680
|
+
complete: collected.complete,
|
|
681
|
+
maxReadsPerCandidate: 0,
|
|
682
|
+
has: (primaryKey) => collected.keys.has(primaryKey),
|
|
683
|
+
};
|
|
684
|
+
} catch (error) {
|
|
685
|
+
assertActive();
|
|
686
|
+
this.#candidateCollectionFailureEpoch++;
|
|
687
|
+
this.#candidateCollectionRetryAfter = Date.now() + CANDIDATE_GATE_FAILURE_RETRY_MILLISECONDS;
|
|
688
|
+
if (!this.#candidateCollectionFailureWarned) {
|
|
689
|
+
this.#candidateCollectionFailureWarned = true;
|
|
690
|
+
logger.warn?.(
|
|
691
|
+
'could not build the full-text candidate-key set; using point probes or the record predicate',
|
|
692
|
+
error
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
const probe = plan.probe;
|
|
698
|
+
if (
|
|
699
|
+
!probe ||
|
|
700
|
+
now < this.#candidateProbeRetryAfter ||
|
|
701
|
+
probe.maxReadsPerCandidate > MAX_POINT_PROBE_READS_PER_CANDIDATE
|
|
702
|
+
)
|
|
703
|
+
return;
|
|
704
|
+
const probeSelectivity = Math.min(1, Math.max(1, probe.estimatedCount) / recordCount);
|
|
705
|
+
const expectedAvoidedSourceReadsPerCandidate = 1 - probeSelectivity;
|
|
706
|
+
return probe.maxReadsPerCandidate <= expectedAvoidedSourceReadsPerCandidate * CANDIDATE_KEYS_PER_SOURCE_READ
|
|
707
|
+
? probe
|
|
708
|
+
: undefined;
|
|
709
|
+
}
|
|
710
|
+
|
|
478
711
|
async #addHighlights(
|
|
479
712
|
reader: NativeFullTextReader,
|
|
480
713
|
definition: FullTextDefinition,
|
|
@@ -827,7 +1060,22 @@ export class FullTextQueryIndex {
|
|
|
827
1060
|
this.#nativeOptions.limits.searchThreads
|
|
828
1061
|
);
|
|
829
1062
|
this.#maxSearchWindow = info.limits.maxSearchWindow;
|
|
830
|
-
|
|
1063
|
+
const responseHitCapacity = Math.floor(
|
|
1064
|
+
(info.limits.maxSearchResponseBytes - NATIVE_SEARCH_RESPONSE_HEADER_BYTES) /
|
|
1065
|
+
(NATIVE_SEARCH_HIT_OVERHEAD_BYTES + info.limits.maxRecordIdBytes + info.limits.maxRecordVersionBytes)
|
|
1066
|
+
);
|
|
1067
|
+
if (responseHitCapacity < 1)
|
|
1068
|
+
throw new TypeError('@harperfast/fulltext/native search response limit cannot hold one maximum-size hit');
|
|
1069
|
+
if (responseHitCapacity < info.limits.maxAutocompleteResults)
|
|
1070
|
+
logger.warn?.(
|
|
1071
|
+
`Full-text index '${this.#definition.name}' limits autocomplete to ${responseHitCapacity} results because the native response envelope cannot hold its advertised maximum`
|
|
1072
|
+
);
|
|
1073
|
+
this.#maxFilteredRawPageSize = Math.min(
|
|
1074
|
+
MAX_FILTERED_RAW_PAGE_SIZE,
|
|
1075
|
+
info.limits.maxSearchWindow,
|
|
1076
|
+
responseHitCapacity
|
|
1077
|
+
);
|
|
1078
|
+
this.#maxAutocompleteResults = Math.min(info.limits.maxAutocompleteResults, responseHitCapacity);
|
|
831
1079
|
this.#maxSearchBudgetMilliseconds = info.limits.maxSearchBudgetMilliseconds;
|
|
832
1080
|
this.#maxTraceRecords = info.limits.maxTraceRecords;
|
|
833
1081
|
this.#maxTraceSourceBytes = info.limits.maxTraceSourceBytes;
|
|
@@ -858,6 +1106,21 @@ export class FullTextQueryIndex {
|
|
|
858
1106
|
}
|
|
859
1107
|
}
|
|
860
1108
|
|
|
1109
|
+
function nextFilteredRawPageSize(
|
|
1110
|
+
currentPageSize: number,
|
|
1111
|
+
rawHits: number,
|
|
1112
|
+
acceptedHits: number,
|
|
1113
|
+
remainingResults: number,
|
|
1114
|
+
maxPageSize: number
|
|
1115
|
+
): number {
|
|
1116
|
+
const projected =
|
|
1117
|
+
acceptedHits === 0
|
|
1118
|
+
? // With no selectivity signal, grow independently of the remaining target to minimize native round trips.
|
|
1119
|
+
currentPageSize * RAW_PAGE_ZERO_YIELD_GROWTH_FACTOR
|
|
1120
|
+
: Math.ceil((remainingResults * rawHits * RAW_PAGE_OVERFETCH_FACTOR) / acceptedHits);
|
|
1121
|
+
return Math.min(maxPageSize, Math.max(MIN_RAW_PAGE_SIZE, projected));
|
|
1122
|
+
}
|
|
1123
|
+
|
|
861
1124
|
export async function pauseNativeFullTextQueryReaders(
|
|
862
1125
|
path: string,
|
|
863
1126
|
readinessId: string,
|
|
@@ -12,15 +12,15 @@ Every sample the adapter (`generativeDecision.ts`) takes is a `models.generate`
|
|
|
12
12
|
|
|
13
13
|
## Required capabilities are checked on the candidate about to be invoked
|
|
14
14
|
|
|
15
|
-
The router's candidate list is advisory: the default router filters by `requires`, but a custom router installed with `registerRouter` may return any backend. `assertCapabilities` (`Models.ts`) therefore re-checks every required capability against `capabilities()` immediately before each candidate is invoked, in every facade method, fallbacks included, and for a stream both before the iterable is returned and again on its first `next()`; a miss is a `ModelCapabilityError` recorded as `capability_unsupported`, and the next candidate is tried, except by a stream, which only ever uses its first candidate. Candidates are backend instances captured when the call was resolved, so the check sees a backend's own `capabilities()` at invocation time, not a registry entry a reload has since replaced; the built-in providers return
|
|
15
|
+
The router's candidate list is advisory: the default router filters by `requires`, but a custom router installed with `registerRouter` may return any backend. `assertCapabilities` (`Models.ts`) therefore re-checks every required capability against `capabilities()` immediately before each candidate is invoked, in every facade method, fallbacks included, and for a stream both before the iterable is returned and again on its first `next()`; a miss is a `ModelCapabilityError` recorded as `capability_unsupported`, and the next candidate is tried, except by a stream, which only ever uses its first candidate. Candidates are backend instances captured when the call was resolved, so the check sees a backend's own `capabilities()` at invocation time, not a registry entry a reload has since replaced; the built-in providers return frozen constants, so the check allocates nothing. The generative decision backend relies on it: by default it passes `requires: ['structuredOutput']` on every sample, so a generative candidate whose integration does not send the response schema as a decoding constraint (Anthropic and Bedrock today) is refused before a completion is billed, whatever router produced it, unless the entry sets `requireStructuredOutput: false`. Enforced by the custom-router cases in `unitTests/resources/models/Models.test.js`; the bootstrap cases in `generativeDecision.test.js` cover the default router's filtering of the same requirement.
|
|
16
16
|
|
|
17
17
|
## Likelihood scoring is a leaf-level hook with voting as the fallback
|
|
18
18
|
|
|
19
|
-
A generative backend may implement `scoreChoices` (#2838): one finite, unnormalized log-likelihood per choice, in order, for a whole call, or a `ChoiceScoringUnsupportedError` for a call it cannot score. A decline is weaker news than a failure, and the two are kept apart at every layer. The facade's internal `models.scoreChoices` validates the vector's shape before it records a success, so a malformed result is one failed attempt against that backend and the next candidate is tried; when every candidate fails it surfaces the first failure, and the primary's decline only when every candidate declined, so a decline never hides a broken fallback. A declined call is recorded as `scoring_unsupported` with the usage the error carries, because a completion that came back without log-probabilities was still billed: those tokens land on that attempt's failure row and in the token metric while the call-count metric stays a count of successes. They are billed once: no other error's `usage` is read, no other method's failure row carries usage (the `decide` row stays usage-free when a decline propagates under `score`), and the adapter aborts sibling calls with a fresh reason rather than the decline itself, so a call cut short is recorded as `aborted` and not as another copy of the decline. The adapter scores one leaf per call (each property of an object schema separately), normalizes by softmax, and never mixes paths: in `auto` mode a decline, or a `ModelCapabilityError`, discards any leaf already scored and votes the whole decision under the same budget, so a distribution is wholly scored or wholly voted; any other error fails the decision. `auto` asks the router synchronously before deciding, so a generative backend whose capabilities say no costs no attempt, no exception and no analytics row. The same probe reads each candidate's `maxScoredChoices`: when no scoring candidate can take the schema's largest leaf, `auto` votes without a scoring attempt, and the facade treats a call above a candidate's limit as a decline it records without invoking that backend, so sibling leaves are never scored and then discarded. A candidate that advertises scoring without implementing it still counts as able, so its contract failure is reported rather than hidden by a vote. `maxScoredChoices` is a limit, not a `Capability`: it cannot be required. The OpenAI backend labels the choices `A`..`T` on one line each (a value that cannot sit on one line as itself is JSON-quoted, so it cannot pose as a further option and values that differ only in whitespace stay distinct), asks for one token with `top_logprobs: 20`, combines spellings of a label by log-sum-exp, and places a label with no listed spelling only when the mass the list leaves over is smaller than the leading observed label's, so a wholly unlisted label can never be the chosen value; otherwise the call is declined rather than guessed.
|
|
19
|
+
A generative backend may implement `scoreChoices` (#2838): one finite, unnormalized log-likelihood per choice, in order, for a whole call, or a `ChoiceScoringUnsupportedError` for a call it cannot score. A decline is weaker news than a failure, and the two are kept apart at every layer. The facade's internal `models.scoreChoices` validates the vector's shape before it records a success, so a malformed result is one failed attempt against that backend and the next candidate is tried; when every candidate fails it surfaces the first failure, and the primary's decline only when every candidate declined, so a decline never hides a broken fallback. A declined call is recorded as `scoring_unsupported` with the usage the error carries, because a completion that came back without log-probabilities was still billed: those tokens land on that attempt's failure row and in the token metric while the call-count metric stays a count of successes. They are billed once: no other error's `usage` is read, no other method's failure row carries usage (the `decide` row stays usage-free when a decline propagates under `score`), and the adapter aborts sibling calls with a fresh reason rather than the decline itself, so a call cut short is recorded as `aborted` and not as another copy of the decline. The adapter scores one leaf per call (each property of an object schema separately), normalizes by softmax, and never mixes paths: in `auto` mode a decline, or a `ModelCapabilityError`, discards any leaf already scored and votes the whole decision under the same budget, so a distribution is wholly scored or wholly voted; any other error fails the decision. `auto` asks the router synchronously before deciding, so a generative backend whose capabilities say no costs no attempt, no exception and no analytics row. The same probe reads each candidate's `maxScoredChoices`: when no scoring candidate can take the schema's largest leaf, `auto` votes without a scoring attempt, and the facade treats a call above a candidate's limit as a decline it records without invoking that backend, so sibling leaves are never scored and then discarded. A candidate that advertises scoring without implementing it still counts as able, so its contract failure is reported rather than hidden by a vote. `maxScoredChoices` is a limit, not a `Capability`: it cannot be required. The OpenAI backend labels the choices `A`..`T` on one line each (a value that cannot sit on one line as itself is JSON-quoted, so it cannot pose as a further option and values that differ only in whitespace stay distinct), asks for one token with `top_logprobs: 20`, combines spellings of a label by log-sum-exp, and places a label with no listed spelling only when the mass the list leaves over is smaller than the leading observed label's, so a wholly unlisted label can never be the chosen value; otherwise the call is declined rather than guessed. Each unlisted label then takes an equal share of that leftover, so together they never exceed it and one can still sit above a listed tail label; when the list leaves nothing over they sit a fixed margin (`UNLISTED_MARGIN`) below its smallest entry, so the vector stays finite. Every placed score is still an estimate: an observed label's own unlisted spellings can add up to the leftover to it, so two observed labels closer than that may be ordered wrongly, and a response with no label at all is declined rather than reported as uniform. A malformed alternative is a failure, not a decline. A model that refuses `logprobs`, by a 400 (by `param`, or by a message that names it and says it is unsupported) or by an answer with no log-probability field at all, has scoring switched off until reload and `capabilities()` says so for the configured model, so later decisions vote without an attempt; a completion with no content token or no alternatives, and an answer that is simply not a label, are declined for that call only, and any other HTTP failure stays an error so an outage does not silently turn into voting. Probabilities from scoring are still `calibrated: false`. Enforced by `unitTests/resources/models/generativeDecision.test.js`, `scoreChoices.test.js`, `scoringLimit.test.js` and `unitTests/components/openai/index.test.js`.
|
|
20
20
|
|
|
21
21
|
## `@decide` and `@embed` share one trigger rule
|
|
22
22
|
|
|
23
|
-
Both directives derive an attribute on the originating node before the record is staged, in `_writeUpdate` and in the cache-from-source write. `writeHookApplies` and `sourceState` (`embedHook.ts`) are the only trigger logic: a replication receive, an `x-replicate-from: none` write or an audit replay never re-derives, a payload without the source leaves the derived attributes alone, a null source clears them, and a CRDT op is skipped. `@decide` writes the chosen value and, when named, its probability from one `models.decide` call, and a failure fails the write, so the two are never from different decisions and never half-written; a missing decider or an override without the promised probability fails the write too. `runWriteJobs` aborts the siblings of a failed hook and settles them before the failure is reported, so a rejected write neither waits on a hook that honors the signal nor keeps being mutated by one; the built-in hooks forward the signal to their model calls. `assertDerivedFieldOwnership` gives every derived field one writer, refuses a directive whose source is another directive's output, because the hooks run concurrently within a write, holds a decided field and its confidence to what the loader accepts, and refuses an `Object.prototype` key as any of those fields, because `sourceState` tests presence with `in`. The loader runs it, and `declareTable` runs it on the complete list (a peer's fields merged, an omitted primary key inherited) before that list replaces the live one or reaches the catalog, so a programmatic `table({ attributes })` declaration is held to the same rules and a refused declaration changes nothing. `Table.updatedAttributes` runs it too but only logs a violation, because it also runs when a descriptor an earlier build accepted is loaded from the catalog, and a table must keep loading. The hooks receive the request's signal, so a client that disconnects cancels the model calls as it did before the hooks were combined. A `decide` descriptor change persists without a reindex: a stored decision does not depend on the model, unlike an embedding, whose `version` carries the model. Enforced by `unitTests/resources/models/decideHook.test.js`, `decideDirective.test.js`, `decideWrite.test.js` and `integrationTests/server/decide-directive.test.ts`.
|
|
23
|
+
Both directives derive an attribute on the originating node before the record is staged, in `_writeUpdate` and in the cache-from-source write. `writeHookApplies` and `sourceState` (`embedHook.ts`) are the only trigger logic: a replication receive, an `x-replicate-from: none` write or an audit replay never re-derives, a payload without the source leaves the derived attributes alone, a null source clears them, and a CRDT op is skipped. `@decide` writes the chosen value and, when named, its probability from one `models.decide` call, and a failure fails the write, so the two are never from different decisions and never half-written; a missing decider or an override without the promised probability fails the write too. `runWriteJobs` aborts the siblings of a failed hook and settles them before the failure is reported, so a rejected write neither waits on a hook that honors the signal nor keeps being mutated by one; the built-in hooks forward the signal to their model calls. `assertDerivedFieldOwnership` gives every derived field one writer, refuses a directive whose source is another directive's output, because the hooks run concurrently within a write, holds a decided field and its confidence to what the loader accepts, and refuses an `Object.prototype` key as any of those fields, because `sourceState` tests presence with `in`. The loader runs it, and `declareTable` runs it on the complete list (a peer's fields merged, an omitted primary key inherited) before that list replaces the live one or reaches the catalog, so a programmatic `table({ attributes })` declaration is held to the same rules and a refused declaration changes nothing. `Table.updatedAttributes` runs it too but only logs a violation, because it also runs when a descriptor an earlier build accepted is loaded from the catalog, and a table must keep loading. The hooks receive the request's signal, so a client that disconnects cancels the model calls as it did before the hooks were combined. A `decide` descriptor change persists without a reindex: a stored decision does not depend on the model, unlike an embedding, whose `version` carries the model. Enforced by `unitTests/resources/models/decideHook.test.js`, `decideDirective.test.js`, `decideRegistry.test.js`, `decideWrite.test.js` and `integrationTests/server/decide-directive.test.ts`.
|
|
24
24
|
|
|
25
25
|
## Built-ins serve one kind
|
|
26
26
|
|
|
@@ -37,3 +37,11 @@ A leaf opts in with `noMatch: true` (#2846, decided on #2843). The backend then
|
|
|
37
37
|
## A `@decide` decision is recorded only when the record can reach it
|
|
38
38
|
|
|
39
39
|
The built-in decider passes `persist: true` only when the directive names a `decision` attribute, so a directive nobody can report outcomes for writes no `hdb_model_decisions` row (#2852). With one, the decider records the decision and the hook stores `Decision.id` beside the value and confidence; a null source clears all three. The id is provenance an outcome report trusts, so `buildDecideBefore` rejects a write that carries it unless that same write derives or clears it, or arrives by replication or replay; `x-replicate-from: none` and a CRDT operation on the source do not qualify, since any request can send them. The association is bounded, not atomic: the decision commits in its own transaction before the record, so an aborted write, a later source write, or a delete leaves an unreferenced row that expires with the retention. On a read-only node no write can commit, so `Table` skips the model hooks and serves a source-cache fill without staging it (`noCacheStore`), for every table. Enforced by `decideHook.test.js`, `decideWrite.test.js`, `decideOutcome.test.js` and `integrationTests/server/decide-directive.test.ts`.
|
|
40
|
+
|
|
41
|
+
## A fitted calibration never crosses a score source, and never waits on storage or fails a call
|
|
42
|
+
|
|
43
|
+
`calibrated: true` from a fit (#2841) means an eligible, unexpired version, fitted under the active policy, was applied to every field of the decision. A population is `populationKey` (`calibration.ts`): tenant, logical model, the decision entry's fingerprint, the signature, the instructions hash and the schema hash. The fingerprint is `sourceFingerprint` (`decisionStore.ts`): the entry's logical name, because a module-backed backend may choose its scorer by name, and its expanded configuration without its named credential fields (`apiKey`, `accessKeyId`, `secretAccessKey`, `sessionToken`, `authorization`, `password`, `token`, `credentials` and the like, matched by exact name) or `fallback`, published per backend by `bootstrap.ts` into `backendRegistry.ts`. An operator-set `revision` covers a deployment change the configuration cannot show, such as a floating model tag. The accounting context's `app` is left out on purpose, because it is the calling route, not an application, and would split one question into a population per endpoint. The generative adapter signs a decision with `;source=<fingerprint>` only when every inner call reported the same identified source through the `SERVED_SOURCE` hook. The facade calls that hook after a successful attempt, outside its `try`, and swallows its faults. A fallback inside a vote, or a backend registered from code, leaves the decision unsigned, and an unsigned decision is never calibrated or learned from. A custom decision backend registered from code identifies its score source only through the `signature` it returns, which it must change whenever that source changes; Harper cannot see a replaced implementation behind the same name and signature. Only decisions recorded while calibration is enabled carry a `population`, so a fit never reads scores whose source was not collected. `getCalibrations` returns that same key on every summary, because the fields a summary otherwise shows (signature and hashes) do not include the entry fingerprint: a caller matching a decision to its report on those alone would read another population's risk after an entry setting changed.
|
|
44
|
+
|
|
45
|
+
`applyFits` (`calibrationStore.ts`) reads the in-memory cache only. A key never loaded returns the decision raw and starts one load; an entry older than the freshness window keeps serving while it refreshes, so a population is uncorrected only before its first load or after a load times out or fails, at most `maxLoads` in flight, each counted until its read settles, so a store whose reads never settle holds the slots and leaves that node's decisions uncorrected rather than piling up reads. Faults are logged through `safeErrorMessage` and return the decision raw. Reliability scores the value a decision returned, which on tied scores need not be the schema-first one, so a fit is judged on the decisions it will actually serve. A reload that leaves the calibration block unchanged keeps the cache and the scheduled job, so frequent unrelated config changes never postpone the first run. Calibration runs after the analytics row, outside the candidate loop, so it never bills a second call. `requires: ['calibrated']` still routes on the backend's own claim. A schema with a no-match leaf is never fit-calibrated, because its no-match score would stay raw under the flag; its population gets reports whose reason is `no-match-schema`, never an eligible version.
|
|
46
|
+
|
|
47
|
+
Versions in `hdb_model_calibrations` (`replicate: true`) are immutable. `fitId` covers the input digest, the policy and the result, so a repeated run writes nothing. A row is applied only when its `key`, its `rank` prefix and the key recomputed from its `population` and `field` all match the key it was read under, so a malformed replicated row cannot cross a population. The reader takes the greatest `rank` (`key | evidenceAt | fittedAt | fitId`) in one descending prefix read. `evidenceAt` is the newest decision or truth fact a version read, so the version that saw newer evidence wins whatever node wrote it and in whatever order rows arrive; two runs on different nodes that both step past the same previous version tie on `evidenceAt`, and the later `fittedAt` wins until a run that reads both writes a successor. A new version's `evidenceAt` is also at least one above the version it replaces, so a correction stamped by a node with a slower clock still supersedes it. A version is written whenever a population's input digest changes, even with no labels left, so a correction revokes an older eligible version. Rows expire after their decisions do. `calibrate()` can only lower a run's budgets, never raise them or change its policy, and a run invalidates only the cache entries of the keys it wrote. A population's decisions are read newest first through the indexed `populationRank` (`population | expiresAt | id`) with a limit, so a large population is never loaded in full. The limit is the newest decisions whose examples, at their cost across every field, fit in the configured `maxBytes` beside the population's own head: a size fixed by the configuration and the schema, so a population too large for the byte budget is still refit, on the same sample each run. A run that cannot afford that sample, because `calibrate()` lowered `maxBytes`, stops on the byte budget and writes nothing, because a smaller sample could revoke a fit its evidence still supports. Heads and a population's sample are budgeted separately, each against `maxBytes`, so other populations in a run never crowd a sample out, and a run holds at most about twice `maxBytes`. A head is renewed only by a run that read decisions for it, so a population whose decisions have all expired lapses instead of staying in the table. Discovery uses at most half of `maxDecisions`, split between the newest decisions and a saved cursor that keeps the ids on its boundary, and splits its population budget the same way. A row that hits a cap is left for the next run, a newly discovered population gets a head at once, and ties at one timestamp never stall the cursor. No discovery page asks for more rows than the scan may still take. Decisions read for fitting come out of the rest of the same budget, so one run reads at most `maxDecisions`, plus one decision per deferred population. A population is read up to one decision past what is left of the budget, and deferred to a later run when that extra decision exists, so a truncated read is never fitted and a population that exactly fits is not deferred forever; a population a run could not finish, deferred or stopped by the byte or time budget, moves to the back of the queue so it cannot hold up the ones behind it, and configuration requires `maxDecisions` of at least twice `maxExamplesPerKey` plus 2, so the first population fitted in a run always has room for a full sample, because a partial sample would write a version that revokes a fit its evidence still supports. A failed run fails its scheduled job. A run walks known populations least recently fitted first, then those discovered newest first, under global decision, population, byte and time budgets, and writes a population only after its report is complete. Its job is registered with `registerInternalJobs` (`resources/scheduler/engine.ts`), under a reserved prefix no component can register or unregister. Enforced by `unitTests/resources/models/calibration.test.js`, `calibrationStore.test.js`, `sourceIdentity.test.js` and `unitTests/resources/scheduler/engine.test.js`.
|