@jarenjs/core 0.73.0 → 0.83.2
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/ARCHITECTURE.md +4 -0
- package/README.md +15 -0
- package/dist/types/range/index.d.ts +81 -0
- package/dist/types/retry.d.ts +43 -0
- package/dist/types/schedule.d.ts +43 -0
- package/dist/types/search/config.d.ts +69 -0
- package/dist/types/search/index.d.ts +145 -0
- package/dist/types/search/vocabulary.d.ts +9 -0
- package/dist/types/virtual/index.d.ts +86 -0
- package/docs/SCHEDULING.md +62 -0
- package/docs/SEARCH.md +158 -0
- package/docs/VIRTUAL.md +40 -0
- package/package.json +22 -2
- package/src/range/index.js +125 -0
- package/src/retry.js +80 -0
- package/src/schedule.js +156 -0
- package/src/search/config.js +65 -0
- package/src/search/index.js +360 -0
- package/src/search/vocabulary.js +39 -0
- package/src/virtual/index.js +140 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Versioned lexical semantics. Host decoding happens before this boundary. */
|
|
3
|
+
import { deepFreeze } from '../object.js';
|
|
4
|
+
|
|
5
|
+
/** @typedef {{version: number, fields: string[], profile?: string, prefix?: boolean,
|
|
6
|
+
* fuzzy?: number, combineWith?: string, boost?: Record<string, number>,
|
|
7
|
+
* normalization?: string, limits?: Partial<typeof SEARCH_LIMITS>}} LexicalDefinition */
|
|
8
|
+
|
|
9
|
+
/** Finite logical allocation and work credits, independent of output truncation. */
|
|
10
|
+
export const SEARCH_LIMITS = Object.freeze({
|
|
11
|
+
maxDocuments: 100000, maxSourceBytes: 64 * 1024 * 1024,
|
|
12
|
+
maxIndexBytes: 256 * 1024 * 1024, maxTemporaryBytes: 512 * 1024 * 1024,
|
|
13
|
+
maxTokens: 4000000, maxPostings: 4000000, maxVocabulary: 300000,
|
|
14
|
+
maxFieldBytes: 65536, maxTokenLength: 128, maxQueryBytes: 4096,
|
|
15
|
+
maxExpansions: 16384, maxCandidates: 100000, maxResults: 1000,
|
|
16
|
+
maxWork: 100000000, maxBatchWork: 100000,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/** @param {LexicalDefinition} input */
|
|
20
|
+
export function lexicalConfig(input) {
|
|
21
|
+
if (!input || input.version !== 1 || !Array.isArray(input.fields) || !input.fields.length
|
|
22
|
+
|| input.fields.length > 64 || input.fields.some((f) => typeof f !== 'string' || !f || f.length > 256)
|
|
23
|
+
|| new Set(input.fields).size !== input.fields.length) throw new TypeError('Invalid lexical definition');
|
|
24
|
+
const allowed = ['version', 'fields', 'profile', 'prefix', 'fuzzy', 'combineWith', 'boost', 'normalization', 'limits'];
|
|
25
|
+
if (Object.keys(input).some((key) => !allowed.includes(key))) throw new TypeError('Unknown lexical option');
|
|
26
|
+
const config = { version: 1, fields: [...input.fields], profile: input.profile ?? 'minisearch-7.2.0-cold',
|
|
27
|
+
prefix: input.prefix ?? true, fuzzy: input.fuzzy ?? 0.15, combineWith: input.combineWith ?? 'AND',
|
|
28
|
+
boost: { ...input.boost }, normalization: input.normalization ?? 'host-text/1',
|
|
29
|
+
limits: { ...SEARCH_LIMITS, ...input.limits } };
|
|
30
|
+
if (!['minisearch-7.2.0-cold', 'lexical-key/1'].includes(config.profile)
|
|
31
|
+
|| typeof config.prefix !== 'boolean' || !Number.isFinite(config.fuzzy) || config.fuzzy < 0 || config.fuzzy > 1
|
|
32
|
+
|| !['AND', 'OR'].includes(config.combineWith) || typeof config.normalization !== 'string' || !config.normalization)
|
|
33
|
+
throw new TypeError('Unsupported lexical semantics');
|
|
34
|
+
for (const [field, boost] of Object.entries(config.boost))
|
|
35
|
+
if (!config.fields.includes(field) || !Number.isFinite(boost) || boost <= 0 || boost > 1000)
|
|
36
|
+
throw new TypeError('Invalid lexical field boost');
|
|
37
|
+
for (const [key, value] of Object.entries(config.limits))
|
|
38
|
+
if (!Object.hasOwn(SEARCH_LIMITS, key) || !Number.isSafeInteger(value) || value < 1)
|
|
39
|
+
throw new TypeError(`Invalid lexical credit: ${key}`);
|
|
40
|
+
return deepFreeze(config);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Token boundaries intentionally preserve the oracle's tab and accent behavior.
|
|
44
|
+
* @param {string} value @returns {string[]} */
|
|
45
|
+
export function lexicalTokens(value) { return value.split(/[\n\r\p{Z}\p{P}]+/u); }
|
|
46
|
+
|
|
47
|
+
/** Banded Levenshtein in UTF-16 units, matching the named compatibility profile.
|
|
48
|
+
* @param {string} a @param {string} b @param {number} limit @param {() => void} step
|
|
49
|
+
* @param {Uint16Array} previous @param {Uint16Array} current */
|
|
50
|
+
export function lexicalDistance(a, b, limit, step, previous, current) {
|
|
51
|
+
if (Math.abs(a.length - b.length) > limit) return limit + 1;
|
|
52
|
+
for (let j = 0; j <= b.length; j++) previous[j] = j;
|
|
53
|
+
for (let i = 1; i <= a.length; i++) {
|
|
54
|
+
current.fill(limit + 1, 0, b.length + 1); current[0] = i;
|
|
55
|
+
let best = limit + 1;
|
|
56
|
+
for (let j = Math.max(1, i - limit); j <= Math.min(b.length, i + limit); j++) {
|
|
57
|
+
step();
|
|
58
|
+
const value = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
59
|
+
current[j] = value; best = Math.min(best, value);
|
|
60
|
+
}
|
|
61
|
+
if (best > limit) return limit + 1;
|
|
62
|
+
[previous, current] = [current, previous];
|
|
63
|
+
}
|
|
64
|
+
return previous[b.length];
|
|
65
|
+
}
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Resident postings and rank statistics, published atomically by source generation. */
|
|
3
|
+
import { lexicalConfig, lexicalTokens, lexicalDistance } from './config.js';
|
|
4
|
+
import { utf8ByteLength as textBytes, hashContent } from '../string.js';
|
|
5
|
+
import { lexicalVocabulary } from './vocabulary.js';
|
|
6
|
+
export { SEARCH_LIMITS } from './config.js';
|
|
7
|
+
|
|
8
|
+
/** @typedef {import('./config.js').LexicalDefinition} LexicalDefinition */
|
|
9
|
+
/** @typedef {{generation?:number, sourceRevision?:string}} LexicalIdentity */
|
|
10
|
+
/** @typedef {{id:string, score:number}} LexicalHit */
|
|
11
|
+
|
|
12
|
+
/** Compile a versioned definition once. Each create() owns an isolated resident index.
|
|
13
|
+
* @param {LexicalDefinition} definition */
|
|
14
|
+
export function compileLexical(definition) {
|
|
15
|
+
const config = lexicalConfig(definition);
|
|
16
|
+
const identity = JSON.stringify(config);
|
|
17
|
+
return Object.freeze({ config, identity, create: () => createIndex(config, identity) });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function createIndex(config, identity) {
|
|
21
|
+
const limits = config.limits, fields = config.fields;
|
|
22
|
+
const empty = () => ({ docs: new Map(), postings: new Map(), lengths: fields.map(() => 0),
|
|
23
|
+
sourceBytes: 0, tokens: 0, postingCount: 0, bytes: 0, dictionaryBytes: 0, forward: [], reverse: [], nextOrder: 0 });
|
|
24
|
+
let state = empty(), generation = 0, sourceRevision = '', disposed = false, ticket = 0, stagingOverhead = 0;
|
|
25
|
+
const exhausted = (reason) => { throw new RangeError(reason); };
|
|
26
|
+
const guard = (value, max, reason) => { if (value > max) exhausted(reason); };
|
|
27
|
+
const outcome = (status, reason, extra = {}) => ({ state: status, ...(reason ? { reason } : {}),
|
|
28
|
+
generation, sourceRevision, identity, ...extra });
|
|
29
|
+
const stats = () => ({ documents: state.docs.size, vocabulary: state.postings.size,
|
|
30
|
+
postings: state.postingCount, tokens: state.tokens, sourceBytes: state.sourceBytes,
|
|
31
|
+
indexBytes: state.bytes, tombstones: 0, fieldLengths: [...state.lengths], generation, sourceRevision, disposed });
|
|
32
|
+
|
|
33
|
+
function prepare(row, order) {
|
|
34
|
+
if (!row || typeof row.id !== 'string' || !row.id) throw new TypeError('Lexical IDs must be nonempty strings');
|
|
35
|
+
guard(row.id.length * 2, limits.maxFieldBytes, 'field-bytes');
|
|
36
|
+
const values = new Array(fields.length), terms = new Array(fields.length), lengths = new Array(fields.length);
|
|
37
|
+
let sourceBytes = textBytes(row.id), tokens = 0, postings = 0, bytes = 128 + row.id.length * 2, work = 1;
|
|
38
|
+
for (let fieldIndex = 0; fieldIndex < fields.length; fieldIndex++) {
|
|
39
|
+
const field = fields[fieldIndex];
|
|
40
|
+
const value = Object.hasOwn(row, field) ? row[field] ?? '' : '';
|
|
41
|
+
if (typeof value !== 'string') throw new TypeError('Lexical fields must be strings');
|
|
42
|
+
guard(value.length, limits.maxFieldBytes, 'field-bytes');
|
|
43
|
+
const size = textBytes(value); guard(size, limits.maxFieldBytes, 'field-bytes');
|
|
44
|
+
// Token arrays, sets, strings and maps are reserved before tokenization.
|
|
45
|
+
guard(bytes + value.length * 256, limits.maxTemporaryBytes - state.bytes, 'temporary-bytes');
|
|
46
|
+
guard(work + value.length, limits.maxBatchWork, 'batch-work');
|
|
47
|
+
const raw = lexicalTokens(value), counts = new Map();
|
|
48
|
+
lengths[fieldIndex] = new Set(raw).size;
|
|
49
|
+
for (const token of raw) {
|
|
50
|
+
const term = token.toLowerCase();
|
|
51
|
+
guard(term.length, limits.maxTokenLength, 'token-length');
|
|
52
|
+
if (term) { counts.set(term, (counts.get(term) ?? 0) + 1); tokens++; }
|
|
53
|
+
}
|
|
54
|
+
for (const term of counts.keys()) bytes += 160 + term.length * 2;
|
|
55
|
+
sourceBytes += size; bytes += 48 + value.length * 2; work += value.length + raw.length;
|
|
56
|
+
postings += counts.size; values[fieldIndex] = value;
|
|
57
|
+
const pairs = new Array(counts.size * 2); let offset = 0;
|
|
58
|
+
for (const [term, frequency] of counts) { pairs[offset++] = term; pairs[offset++] = frequency; }
|
|
59
|
+
terms[fieldIndex] = pairs;
|
|
60
|
+
}
|
|
61
|
+
guard(work, limits.maxBatchWork, 'batch-work');
|
|
62
|
+
return { id: row.id, values, terms, lengths, sourceBytes, tokens, postings, bytes, order, work };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function* stage(rows, remove, replace, request, myTicket) {
|
|
66
|
+
if (disposed) return outcome('error', 'disposed');
|
|
67
|
+
const wanted = request.generation ?? generation + 1;
|
|
68
|
+
const revision = request.sourceRevision ?? sourceRevision;
|
|
69
|
+
if (!Number.isSafeInteger(wanted) || wanted < 0 || typeof revision !== 'string') return outcome('error', 'invalid-identity');
|
|
70
|
+
if (wanted < generation) return outcome('invalidated', 'stale-generation');
|
|
71
|
+
if (stagingOverhead + state.bytes + (replace ? 0 : state.docs.size * 48 + state.postings.size * 48) > limits.maxTemporaryBytes)
|
|
72
|
+
return outcome('budget-exhausted', 'temporary-bytes');
|
|
73
|
+
const next = replace ? empty() : { ...state, bytes: state.bytes - state.dictionaryBytes,
|
|
74
|
+
docs: new Map(state.docs), postings: new Map(state.postings), lengths: [...state.lengths] };
|
|
75
|
+
const copied = new Set(), seen = new Set();
|
|
76
|
+
let work = 0, changes = replace ? state.docs.size : 0, temporary = stagingOverhead + state.bytes + next.docs.size * 48 + next.postings.size * 48;
|
|
77
|
+
const writable = (term) => {
|
|
78
|
+
if (!copied.has(term)) {
|
|
79
|
+
const old = next.postings.get(term);
|
|
80
|
+
const cost = old ? [...old.values()].reduce((n, entries) => n + entries.size * 48 + 64, 64) : 64;
|
|
81
|
+
guard(temporary + cost, limits.maxTemporaryBytes, 'temporary-bytes'); temporary += cost;
|
|
82
|
+
next.postings.set(term, old ? new Map([...old].map(([field, entries]) => [field, new Map(entries)])) : new Map());
|
|
83
|
+
copied.add(term);
|
|
84
|
+
}
|
|
85
|
+
return next.postings.get(term);
|
|
86
|
+
};
|
|
87
|
+
const subtract = (old, remove = true) => {
|
|
88
|
+
if (remove) next.docs.delete(old.id); next.bytes -= old.bytes; next.sourceBytes -= old.sourceBytes;
|
|
89
|
+
next.tokens -= old.tokens; next.postingCount -= old.postings;
|
|
90
|
+
for (let field = 0; field < fields.length; field++) {
|
|
91
|
+
next.lengths[field] -= old.lengths[field];
|
|
92
|
+
for (let i = 0; i < old.terms[field].length; i += 2) {
|
|
93
|
+
const term = old.terms[field][i];
|
|
94
|
+
const posting = writable(term), entries = posting.get(field);
|
|
95
|
+
entries.delete(old.id); if (!entries.size) posting.delete(field);
|
|
96
|
+
if (!posting.size) { next.postings.delete(term); copied.delete(term); }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
try {
|
|
101
|
+
guard(temporary, limits.maxTemporaryBytes, 'temporary-bytes');
|
|
102
|
+
for (const id of remove) {
|
|
103
|
+
if (typeof id !== 'string') throw new TypeError('Invalid lexical removal ID');
|
|
104
|
+
const old = next.docs.get(id);
|
|
105
|
+
if (old) { subtract(old); changes++; work += old.work; }
|
|
106
|
+
guard(++work, limits.maxWork, 'work'); yield work;
|
|
107
|
+
}
|
|
108
|
+
for (const row of rows) {
|
|
109
|
+
guard(seen.size + 1, limits.maxDocuments, 'documents');
|
|
110
|
+
if (seen.has(row?.id)) throw new TypeError('Duplicate lexical ID');
|
|
111
|
+
seen.add(row?.id);
|
|
112
|
+
const old = next.docs.get(row?.id);
|
|
113
|
+
const doc = prepare(row, old?.order ?? next.nextOrder);
|
|
114
|
+
work += doc.work; guard(work, limits.maxWork, 'work');
|
|
115
|
+
if (old && doc.values.every((v, i) => v === old.values[i])) { yield work; continue; }
|
|
116
|
+
guard(temporary + doc.bytes * 2, limits.maxTemporaryBytes, 'temporary-bytes'); temporary += doc.bytes * 2;
|
|
117
|
+
if (old) subtract(old, false); else next.nextOrder++;
|
|
118
|
+
guard(next.docs.size + (old ? 0 : 1), limits.maxDocuments, 'documents');
|
|
119
|
+
guard(next.sourceBytes + doc.sourceBytes, limits.maxSourceBytes, 'source-bytes');
|
|
120
|
+
guard(next.tokens + doc.tokens, limits.maxTokens, 'tokens');
|
|
121
|
+
guard(next.postingCount + doc.postings, limits.maxPostings, 'postings');
|
|
122
|
+
guard(next.bytes + doc.bytes, limits.maxIndexBytes, 'index-bytes');
|
|
123
|
+
next.docs.set(doc.id, doc); next.bytes += doc.bytes; next.sourceBytes += doc.sourceBytes;
|
|
124
|
+
next.tokens += doc.tokens; next.postingCount += doc.postings;
|
|
125
|
+
for (let field = 0; field < fields.length; field++) {
|
|
126
|
+
next.lengths[field] += doc.lengths[field];
|
|
127
|
+
for (let i = 0; i < doc.terms[field].length; i += 2) {
|
|
128
|
+
const term = doc.terms[field][i], frequency = doc.terms[field][i + 1];
|
|
129
|
+
if (!next.postings.has(term)) guard(next.postings.size + 1, limits.maxVocabulary, 'vocabulary');
|
|
130
|
+
const posting = writable(term);
|
|
131
|
+
if (!posting.has(field)) posting.set(field, new Map());
|
|
132
|
+
posting.get(field).set(doc.id, frequency);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
changes++; yield work;
|
|
136
|
+
}
|
|
137
|
+
for (const term of copied) if (!next.postings.get(term)?.size) next.postings.delete(term);
|
|
138
|
+
if (disposed || myTicket !== ticket) return outcome(disposed ? 'error' : 'invalidated', disposed ? 'disposed' : 'superseded');
|
|
139
|
+
if (replace && state.docs.size === next.docs.size) {
|
|
140
|
+
let same = true;
|
|
141
|
+
const oldIds = state.docs.keys();
|
|
142
|
+
for (const [id, doc] of next.docs) {
|
|
143
|
+
const old = state.docs.get(id);
|
|
144
|
+
if (!old || oldIds.next().value !== id || !doc.values.every((v, i) => v === old.values[i])) { same = false; break; }
|
|
145
|
+
}
|
|
146
|
+
if (same) changes = 0;
|
|
147
|
+
}
|
|
148
|
+
if (!changes && revision === sourceRevision) return outcome('complete', null, { changes: 0, work, peakBytes: temporary });
|
|
149
|
+
if (wanted === generation) return outcome('invalidated', 'conflicting-generation');
|
|
150
|
+
next.dictionaryBytes = next.postings.size * 16;
|
|
151
|
+
guard(next.bytes + next.dictionaryBytes, limits.maxIndexBytes, 'index-bytes'); next.bytes += next.dictionaryBytes;
|
|
152
|
+
guard(temporary + next.postings.size * 384, limits.maxTemporaryBytes, 'temporary-bytes');
|
|
153
|
+
temporary += next.postings.size * 384;
|
|
154
|
+
const words = new Set();
|
|
155
|
+
for (const doc of next.docs.values()) {
|
|
156
|
+
for (const terms of doc.terms) for (let i = 0; i < terms.length; i += 2) words.add(terms[i]);
|
|
157
|
+
work += doc.postings; guard(work, limits.maxWork, 'work'); yield work;
|
|
158
|
+
}
|
|
159
|
+
const vocabulary = lexicalVocabulary(words, () => { guard(++work, limits.maxWork, 'work'); });
|
|
160
|
+
let ordered = vocabulary.next();
|
|
161
|
+
while (!ordered.done) { yield work; ordered = vocabulary.next(); }
|
|
162
|
+
next.forward = ordered.value.forward; next.reverse = ordered.value.reverse;
|
|
163
|
+
if (!replace) for (const word of copied) {
|
|
164
|
+
const posting = next.postings.get(word); if (!posting) continue;
|
|
165
|
+
for (const [field, entries] of posting) {
|
|
166
|
+
let comparisons = 0;
|
|
167
|
+
const sorted = [...entries].sort((a, b) => {
|
|
168
|
+
guard(++comparisons, limits.maxBatchWork, 'batch-work'); guard(++work, limits.maxWork, 'work');
|
|
169
|
+
return next.docs.get(a[0]).order - next.docs.get(b[0]).order;
|
|
170
|
+
});
|
|
171
|
+
posting.set(field, new Map(sorted)); yield work;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (disposed || myTicket !== ticket) return outcome(disposed ? 'error' : 'invalidated', disposed ? 'disposed' : 'superseded');
|
|
175
|
+
state = next; generation = wanted; sourceRevision = revision;
|
|
176
|
+
return outcome('complete', null, { changes, work, peakBytes: temporary });
|
|
177
|
+
}
|
|
178
|
+
catch (error) { return outcome(error instanceof RangeError ? 'budget-exhausted' : 'error', error.message); }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function mutate(rows, remove, replace, request = {}) {
|
|
182
|
+
const steps = stage(rows, remove, replace, request, ++ticket);
|
|
183
|
+
let result = steps.next(); while (!result.done) result = steps.next();
|
|
184
|
+
return result.value;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function mutateAsync(rows, remove, replace, request) {
|
|
188
|
+
if (typeof request?.yield !== 'function') throw new TypeError('A cooperative yield capability is required');
|
|
189
|
+
const mine = ++ticket, steps = stage(rows, remove, replace, request, mine);
|
|
190
|
+
let previous = 0;
|
|
191
|
+
try {
|
|
192
|
+
for (;;) {
|
|
193
|
+
if (request.signal?.aborted) return outcome('error', 'cancelled');
|
|
194
|
+
if (disposed || mine !== ticket) return outcome(disposed ? 'error' : 'invalidated', disposed ? 'disposed' : 'superseded');
|
|
195
|
+
const result = steps.next(); if (result.done) return result.value;
|
|
196
|
+
if (result.value - previous >= limits.maxBatchWork / 2) {
|
|
197
|
+
previous = result.value; request.onProgress?.({ work: previous, generation: request.generation }); await request.yield();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
finally { steps.return(undefined); }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** @param {string} text @param {any} [options] */
|
|
205
|
+
function search(text, options = {}) {
|
|
206
|
+
const queriedState = state;
|
|
207
|
+
const used = { work: 0, expansions: 0, candidates: 0 };
|
|
208
|
+
const fail = (kind, reason) => outcome(kind, reason, { hits: [], total: null, hasMore: false, used });
|
|
209
|
+
if (disposed) return fail('error', 'disposed');
|
|
210
|
+
if (options.sourceRevision !== undefined && options.sourceRevision !== sourceRevision) return fail('invalidated', 'source-stale');
|
|
211
|
+
const limit = options.limit ?? limits.maxResults;
|
|
212
|
+
if (typeof text !== 'string' || !Number.isSafeInteger(limit) || limit < 0 || limit > limits.maxResults) return fail('error', 'invalid-query');
|
|
213
|
+
if (text.length > limits.maxQueryBytes || textBytes(text) > limits.maxQueryBytes) return fail('budget-exhausted', 'query-bytes');
|
|
214
|
+
const credits = { work: limits.maxWork, expansions: limits.maxExpansions, candidates: limits.maxCandidates, ...options.credits };
|
|
215
|
+
for (const [key, value] of Object.entries(credits))
|
|
216
|
+
if (!['work', 'expansions', 'candidates'].includes(key) || !Number.isSafeInteger(value) || value < 0
|
|
217
|
+
|| value > ({ work: limits.maxWork, expansions: limits.maxExpansions, candidates: limits.maxCandidates })[key]) return fail('error', 'invalid-credits');
|
|
218
|
+
const step = () => { if (used.work >= credits.work) exhausted('work'); used.work++; };
|
|
219
|
+
const terms = lexicalTokens(text).map((t) => t.toLowerCase()).filter(Boolean);
|
|
220
|
+
const quality = new Set(terms).size;
|
|
221
|
+
let combined = null;
|
|
222
|
+
try {
|
|
223
|
+
for (const term of terms) {
|
|
224
|
+
guard(term.length, limits.maxTokenLength, 'token-length');
|
|
225
|
+
const matches = new Map();
|
|
226
|
+
const rank = (word, weight) => {
|
|
227
|
+
if (used.expansions >= credits.expansions) exhausted('expansions'); used.expansions++;
|
|
228
|
+
const posting = state.postings.get(word);
|
|
229
|
+
for (let field = 0; field < fields.length; field++) {
|
|
230
|
+
const entries = posting.get(field); if (!entries) continue;
|
|
231
|
+
const idf = Math.log(1 + (state.docs.size - entries.size + 0.5) / (entries.size + 0.5));
|
|
232
|
+
const average = state.lengths[field] / state.docs.size;
|
|
233
|
+
for (const [id, frequency] of entries) {
|
|
234
|
+
step(); const doc = state.docs.get(id);
|
|
235
|
+
const score = weight * (Object.hasOwn(config.boost, fields[field]) ? config.boost[fields[field]] : 1) * idf
|
|
236
|
+
* (0.5 + frequency * 2.2 / (frequency + 1.2 * (0.3 + 0.7 * doc.lengths[field] / average)));
|
|
237
|
+
if (!matches.has(id)) {
|
|
238
|
+
guard(matches.size + 1, credits.candidates, 'candidates');
|
|
239
|
+
guard(state.bytes + (matches.size + 1 + (combined?.size ?? 0)) * 192, limits.maxTemporaryBytes, 'temporary-bytes');
|
|
240
|
+
used.candidates = Math.max(used.candidates, matches.size + 1);
|
|
241
|
+
}
|
|
242
|
+
matches.set(id, (matches.get(id) ?? 0) + score);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
step();
|
|
247
|
+
if (state.postings.has(term)) rank(term, 1);
|
|
248
|
+
const distance = Math.min(6, Math.round(term.length * config.fuzzy));
|
|
249
|
+
const previous = new Uint16Array(term.length + distance + 1), current = new Uint16Array(term.length + distance + 1);
|
|
250
|
+
// Exact terms precede expansions; prefix matches own their fuzzy overlap.
|
|
251
|
+
if (config.prefix) for (const word of state.reverse) {
|
|
252
|
+
step(); if (word === term) continue;
|
|
253
|
+
if (word.startsWith(term)) rank(word, 0.375 * word.length / (word.length + 0.3 * (word.length - term.length)));
|
|
254
|
+
}
|
|
255
|
+
if (distance) for (const word of state.forward) {
|
|
256
|
+
step(); if (word === term || (config.prefix && word.startsWith(term))) continue;
|
|
257
|
+
const d = lexicalDistance(term, word, distance, step, previous, current);
|
|
258
|
+
if (d <= distance) rank(word, 0.45 * word.length / (word.length + d));
|
|
259
|
+
}
|
|
260
|
+
if (combined === null) combined = matches;
|
|
261
|
+
else if (config.combineWith === 'AND') {
|
|
262
|
+
for (const [id, score] of matches) { step();
|
|
263
|
+
if (combined.has(id)) matches.set(id, score + combined.get(id)); else matches.delete(id); }
|
|
264
|
+
combined = matches;
|
|
265
|
+
}
|
|
266
|
+
else for (const [id, score] of matches) { step();
|
|
267
|
+
guard(combined.has(id) ? combined.size : combined.size + 1, credits.candidates, 'candidates');
|
|
268
|
+
combined.set(id, (combined.get(id) ?? 0) + score); }
|
|
269
|
+
}
|
|
270
|
+
const hits = [];
|
|
271
|
+
for (const [id, score] of combined ?? []) { step();
|
|
272
|
+
const hit = { id, score: score * quality };
|
|
273
|
+
if (!options.filter || options.filter(hit)) { options.onMatch?.(hit); hits.push(hit); }
|
|
274
|
+
}
|
|
275
|
+
const tie = (a, b) => config.profile === 'lexical-key/1' ? (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
|
|
276
|
+
: 0;
|
|
277
|
+
hits.sort((a, b) => { step(); return (options.compare ? options.compare(a, b) : b.score - a.score) || tie(a, b); });
|
|
278
|
+
let start = 0;
|
|
279
|
+
if (options.after != null) {
|
|
280
|
+
const after = options.after;
|
|
281
|
+
if (after.identity !== identity || after.sourceRevision !== sourceRevision || after.text !== text || after.query !== (options.query ?? ''))
|
|
282
|
+
return fail('invalidated', 'continuation-changed');
|
|
283
|
+
const position = hits.findIndex((hit) => hit.id === after.id && hit.score === after.score);
|
|
284
|
+
if (position < 0) return fail('invalidated', 'continuation-changed');
|
|
285
|
+
start = position + 1;
|
|
286
|
+
}
|
|
287
|
+
const page = hits.slice(start, start + limit), hasMore = start + page.length < hits.length;
|
|
288
|
+
const continuation = hasMore && page.length ? { ...page.at(-1), identity, sourceRevision, text, query: options.query ?? '' } : null;
|
|
289
|
+
if (disposed || state !== queriedState) return fail(disposed ? 'error' : 'invalidated', disposed ? 'disposed' : 'source-changed');
|
|
290
|
+
return outcome('complete', null, { hits: page, total: hits.length, hasMore, continuation, used });
|
|
291
|
+
}
|
|
292
|
+
catch (error) { return fail(error instanceof RangeError ? 'budget-exhausted' : 'error', error.message); }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
config, identity, stats, search,
|
|
297
|
+
/** Serialize a complete derived snapshot. The checksum detects corruption, not forgery.
|
|
298
|
+
* @returns {string} */
|
|
299
|
+
snapshot() {
|
|
300
|
+
if (disposed) throw new Error('disposed');
|
|
301
|
+
guard(state.bytes + state.sourceBytes * 12 + state.docs.size * 256, limits.maxTemporaryBytes, 'temporary-bytes');
|
|
302
|
+
const payload = JSON.stringify({ format: 'jaren-lexical/1', complete: true, identity, generation, sourceRevision,
|
|
303
|
+
documents: [...state.docs.values()].map((doc) => [doc.id, doc.values, doc.order]), nextOrder: state.nextOrder });
|
|
304
|
+
return JSON.stringify({ checksum: hashContent(payload), payload });
|
|
305
|
+
},
|
|
306
|
+
/** Validate format, configuration and authoritative source before atomic publication.
|
|
307
|
+
* Recovery always rebuilds postings through the same bounded engine.
|
|
308
|
+
* @param {string} serialized @param {LexicalIdentity & {sourceRevision:string}} request */
|
|
309
|
+
restore(serialized, request) {
|
|
310
|
+
if (disposed) return outcome('error', 'disposed');
|
|
311
|
+
const refuse = (reason) => outcome('rebuild-required', reason);
|
|
312
|
+
if (typeof serialized !== 'string') return refuse('corrupt-snapshot');
|
|
313
|
+
if (serialized.length * 16 + state.bytes > limits.maxTemporaryBytes) return refuse('snapshot-bytes');
|
|
314
|
+
let data;
|
|
315
|
+
try {
|
|
316
|
+
const envelope = JSON.parse(serialized);
|
|
317
|
+
if (typeof envelope.payload !== 'string' || hashContent(envelope.payload) !== envelope.checksum) return refuse('corrupt-snapshot');
|
|
318
|
+
data = JSON.parse(envelope.payload);
|
|
319
|
+
}
|
|
320
|
+
catch { return refuse('corrupt-snapshot'); }
|
|
321
|
+
if (data?.format !== 'jaren-lexical/1' || data.complete !== true) return refuse('snapshot-format');
|
|
322
|
+
if (data.identity !== identity) return refuse('config-mismatch');
|
|
323
|
+
if (typeof request?.sourceRevision !== 'string' || data.sourceRevision !== request.sourceRevision) return refuse('source-stale');
|
|
324
|
+
if (!Number.isSafeInteger(data.generation) || data.generation < 0 || !Array.isArray(data.documents)
|
|
325
|
+
|| data.documents.length > limits.maxDocuments || !Number.isSafeInteger(data.nextOrder) || data.nextOrder < 0)
|
|
326
|
+
return refuse('corrupt-snapshot');
|
|
327
|
+
let prior = -1;
|
|
328
|
+
const rows = [];
|
|
329
|
+
for (const doc of data.documents) {
|
|
330
|
+
if (!Array.isArray(doc) || doc.length !== 3 || !Array.isArray(doc[1]) || doc[1].length !== fields.length
|
|
331
|
+
|| !Number.isSafeInteger(doc[2]) || doc[2] <= prior || doc[2] >= data.nextOrder) return refuse('corrupt-snapshot');
|
|
332
|
+
prior = doc[2]; rows.push(Object.fromEntries([['id', doc[0]], ...fields.map((field, i) => [field, doc[1][i]])]));
|
|
333
|
+
}
|
|
334
|
+
let result;
|
|
335
|
+
stagingOverhead = serialized.length * 16;
|
|
336
|
+
try { result = mutate(rows, [], true, { generation: request.generation ?? Math.max(generation + 1, data.generation),
|
|
337
|
+
sourceRevision: request.sourceRevision }); }
|
|
338
|
+
finally { stagingOverhead = 0; }
|
|
339
|
+
if (result.state !== 'complete') return result.state === 'invalidated' ? result : refuse(result.reason);
|
|
340
|
+
for (const doc of data.documents) state.docs.get(doc[0]).order = doc[2];
|
|
341
|
+
state.nextOrder = data.nextOrder;
|
|
342
|
+
return result;
|
|
343
|
+
},
|
|
344
|
+
/** Atomically build from authoritative text rows. @param {Iterable<any>} rows @param {LexicalIdentity} [request] */
|
|
345
|
+
rebuild: (rows, request) => mutate(rows, [], true, request),
|
|
346
|
+
/** Replace/remove by stable ID; unchanged text is a no-op. @param {{put?:Iterable<any>, remove?:Iterable<string>}} changes @param {LexicalIdentity} [request] */
|
|
347
|
+
update: (changes, request) => mutate(changes.put ?? [], changes.remove ?? [], false, request),
|
|
348
|
+
/** Clear all postings without retaining tombstones. @param {LexicalIdentity} [request] */
|
|
349
|
+
clear: (request) => mutate([], [], true, request),
|
|
350
|
+
/** Cooperative atomic build. The host yields to its event loop and owns cancellation.
|
|
351
|
+
* @param {Iterable<any>} rows @param {LexicalIdentity & {yield:()=>Promise<void>, signal?:AbortSignal, onProgress?:(value:any)=>void}} request */
|
|
352
|
+
rebuildAsync: (rows, request) => mutateAsync(rows, [], true, request),
|
|
353
|
+
/** Cooperative atomic replacement/removal using the same staging engine.
|
|
354
|
+
* @param {{put?:Iterable<any>, remove?:Iterable<string>}} changes
|
|
355
|
+
* @param {LexicalIdentity & {yield:()=>Promise<void>, signal?:AbortSignal, onProgress?:(value:any)=>void}} request */
|
|
356
|
+
updateAsync: (changes, request) => mutateAsync(changes.put ?? [], changes.remove ?? [], false, request),
|
|
357
|
+
/** Idempotently free every resident reference and fence staged work. */
|
|
358
|
+
dispose() { disposed = true; ticket++; state = empty(); },
|
|
359
|
+
};
|
|
360
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Compact traversal orders preserve the compatibility profile without retaining a trie. */
|
|
3
|
+
|
|
4
|
+
/** Build traversal ordinals from first occurrence in authoritative document order.
|
|
5
|
+
* The temporary compressed tree is released before publication; only two arrays of
|
|
6
|
+
* term references remain. Splitting a branch appends that branch at its parent.
|
|
7
|
+
* @param {Iterable<string>} words @param {()=>void} step */
|
|
8
|
+
export function* lexicalVocabulary(words, step) {
|
|
9
|
+
const root = [];
|
|
10
|
+
for (const word of words) {
|
|
11
|
+
let edges = root, offset = 0;
|
|
12
|
+
while (offset < word.length) {
|
|
13
|
+
step();
|
|
14
|
+
const position = edges.findIndex((edge) => { step(); return edge.label && edge.label[0] === word[offset]; });
|
|
15
|
+
if (position < 0) { const children = []; edges.push({ label: word.slice(offset), children }); edges = children; break; }
|
|
16
|
+
const edge = edges[position];
|
|
17
|
+
let shared = 0;
|
|
18
|
+
while (shared < edge.label.length && word[offset + shared] === edge.label[shared]) { shared++; step(); }
|
|
19
|
+
if (shared < edge.label.length) {
|
|
20
|
+
const children = [{ label: edge.label.slice(shared), children: edge.children }];
|
|
21
|
+
edges.splice(position, 1); edges.push({ label: edge.label.slice(0, shared), children }); edges = children;
|
|
22
|
+
}
|
|
23
|
+
else edges = edge.children;
|
|
24
|
+
offset += shared;
|
|
25
|
+
}
|
|
26
|
+
edges.push({ label: '', word }); yield;
|
|
27
|
+
}
|
|
28
|
+
const forward = [], reverse = [], stack = [root[Symbol.iterator]()];
|
|
29
|
+
while (stack.length) {
|
|
30
|
+
step(); const current = stack.at(-1).next();
|
|
31
|
+
if (current.done) stack.pop();
|
|
32
|
+
else if (current.value.label === '') forward.push(current.value.word);
|
|
33
|
+
else stack.push(current.value.children[Symbol.iterator]());
|
|
34
|
+
yield;
|
|
35
|
+
}
|
|
36
|
+
// A reverse depth-first traversal is the reverse of the forward leaf sequence.
|
|
37
|
+
for (let i = forward.length - 1; i >= 0; i--) { step(); reverse.push(forward[i]); yield; }
|
|
38
|
+
return { forward, reverse };
|
|
39
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** DOM-free virtual geometry. Work and retained state depend on the viewport and credits. */
|
|
3
|
+
|
|
4
|
+
/** @param {number} value @param {string} name @param {number} [min] */
|
|
5
|
+
function integer(value, name, min = 0) {
|
|
6
|
+
if (!Number.isSafeInteger(value) || value < min) throw new RangeError(`Invalid ${name}`);
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** @param {number} value */
|
|
11
|
+
function positive(value) {
|
|
12
|
+
if (!Number.isFinite(value) || value <= 0) throw new RangeError('Invalid size');
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** @param {number} value */
|
|
17
|
+
function nonnegative(value) { return Number.isFinite(value) ? Math.max(0, value) : 0; }
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A half-open fixed-size window, with no source access. Hidden viewports are empty.
|
|
21
|
+
* @param {{count:number, size:number, viewport:number, offset?:number, overscan?:number}} options
|
|
22
|
+
*/
|
|
23
|
+
export function fixedRange({ count, size, viewport, offset = 0, overscan = 0 }) {
|
|
24
|
+
integer(count, 'count'); positive(size); integer(overscan, 'overscan');
|
|
25
|
+
const extent = count * size;
|
|
26
|
+
if (!Number.isFinite(extent) || extent > Number.MAX_SAFE_INTEGER) throw new RangeError('Invalid extent');
|
|
27
|
+
viewport = nonnegative(viewport);
|
|
28
|
+
offset = Math.min(nonnegative(offset), Math.max(0, extent - viewport));
|
|
29
|
+
if (!count || !viewport) return { start: 0, end: 0, offset, extent };
|
|
30
|
+
return { start: Math.max(0, Math.floor(offset / size) - overscan),
|
|
31
|
+
end: Math.min(count, Math.ceil((offset + viewport) / size) + overscan), offset, extent };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Sparse measured axis. Only credited measurements are retained; evicted sizes become estimates.
|
|
36
|
+
* Prefix summaries rebuild on measurement, never by enumerating logical items.
|
|
37
|
+
* @param {{count:number, estimateSize:number, maxMeasurements?:number, maxBytes?:number}} options
|
|
38
|
+
*/
|
|
39
|
+
export function createVirtualAxis({ count, estimateSize, maxMeasurements = 256, maxBytes = 32768 }) {
|
|
40
|
+
integer(count, 'count'); positive(estimateSize);
|
|
41
|
+
integer(maxMeasurements, 'maxMeasurements'); integer(maxBytes, 'maxBytes');
|
|
42
|
+
const entries = new Map();
|
|
43
|
+
let sorted = [], prefix = [], bytes = 0, disposed = false;
|
|
44
|
+
function rebuild() {
|
|
45
|
+
sorted = [...entries.values()].sort((a, b) => a.index - b.index);
|
|
46
|
+
let sum = 0;
|
|
47
|
+
prefix = sorted.map((item) => (sum += item.size - estimateSize));
|
|
48
|
+
}
|
|
49
|
+
function position(index) {
|
|
50
|
+
let low = 0, high = sorted.length;
|
|
51
|
+
while (low < high) { const mid = Math.floor((low + high) / 2);
|
|
52
|
+
if (sorted[mid].index < index) low = mid + 1; else high = mid; }
|
|
53
|
+
return index * estimateSize + (low ? prefix[low - 1] : 0);
|
|
54
|
+
}
|
|
55
|
+
function size(index) {
|
|
56
|
+
let low = 0, high = sorted.length;
|
|
57
|
+
while (low < high) { const mid = Math.floor((low + high) / 2);
|
|
58
|
+
if (sorted[mid].index < index) low = mid + 1; else high = mid; }
|
|
59
|
+
return sorted[low]?.index === index ? sorted[low].size : estimateSize;
|
|
60
|
+
}
|
|
61
|
+
function indexAt(offset) {
|
|
62
|
+
let low = 0, high = count;
|
|
63
|
+
while (low < high) { const mid = Math.floor((low + high) / 2);
|
|
64
|
+
if (position(mid + 1) <= offset) low = mid + 1; else high = mid; }
|
|
65
|
+
return Math.min(low, Math.max(0, count - 1));
|
|
66
|
+
}
|
|
67
|
+
function remove(key) { bytes -= entries.get(key).bytes; entries.delete(key); }
|
|
68
|
+
return {
|
|
69
|
+
position, size, indexAt,
|
|
70
|
+
extent() { return position(count); },
|
|
71
|
+
range({ offset = 0, viewport = 0, overscan = 0 } = {}) {
|
|
72
|
+
if (!sorted.length) return fixedRange({ count: disposed ? 0 : count, size: estimateSize, offset, viewport, overscan });
|
|
73
|
+
integer(overscan, 'overscan'); viewport = nonnegative(viewport);
|
|
74
|
+
const extent = position(count);
|
|
75
|
+
offset = Math.min(nonnegative(offset), Math.max(0, extent - viewport));
|
|
76
|
+
if (disposed || !count || !viewport) return { start: 0, end: 0, offset, extent };
|
|
77
|
+
const first = indexAt(offset);
|
|
78
|
+
let end = indexAt(offset + viewport);
|
|
79
|
+
if (position(end) < offset + viewport) end++;
|
|
80
|
+
return { start: Math.max(0, first - overscan), end: Math.min(count, end + overscan), offset, extent };
|
|
81
|
+
},
|
|
82
|
+
measure(index, key, value) {
|
|
83
|
+
if (disposed) return { state: 'error', reason: 'disposed' };
|
|
84
|
+
integer(index, 'index'); positive(value);
|
|
85
|
+
if (index >= count || typeof key !== 'string') return { state: 'error', reason: 'invalid-measurement' };
|
|
86
|
+
if (key.length > maxBytes) return { state: 'budget-exhausted', reason: 'measurement-credits' };
|
|
87
|
+
const cost = new TextEncoder().encode(key).byteLength + 24;
|
|
88
|
+
if (!maxMeasurements || cost > maxBytes) return { state: 'budget-exhausted', reason: 'measurement-credits' };
|
|
89
|
+
if (entries.has(key)) remove(key);
|
|
90
|
+
for (const [other, entry] of entries) if (entry.index === index) remove(other);
|
|
91
|
+
while (entries.size >= maxMeasurements || bytes + cost > maxBytes) remove(entries.keys().next().value);
|
|
92
|
+
entries.set(key, { index, size: value, bytes: cost }); bytes += cost; rebuild();
|
|
93
|
+
return { state: 'ready' };
|
|
94
|
+
},
|
|
95
|
+
anchor(offset, keyAt, query = '') {
|
|
96
|
+
if (!count) return null;
|
|
97
|
+
const index = indexAt(nonnegative(offset));
|
|
98
|
+
return { key: String(keyAt(index)), index, offset: nonnegative(offset) - position(index), query };
|
|
99
|
+
},
|
|
100
|
+
restore(anchor, indexOf, query = '') {
|
|
101
|
+
if (!anchor || anchor.query !== query || !count) return { state: 'ready', offset: 0, fallback: true };
|
|
102
|
+
const found = indexOf?.(anchor.key);
|
|
103
|
+
const valid = Number.isSafeInteger(found) && found >= 0 && found < count;
|
|
104
|
+
const index = valid ? found : Math.min(count - 1, Math.max(0, anchor.index));
|
|
105
|
+
return { state: 'ready', offset: position(index) + Math.min(nonnegative(anchor.offset), size(index)), fallback: !valid };
|
|
106
|
+
},
|
|
107
|
+
update(next) {
|
|
108
|
+
if (disposed) return;
|
|
109
|
+
if (next.count !== undefined) count = integer(next.count, 'count');
|
|
110
|
+
if (next.estimateSize !== undefined && next.estimateSize !== estimateSize) {
|
|
111
|
+
estimateSize = positive(next.estimateSize); entries.clear(); bytes = 0;
|
|
112
|
+
}
|
|
113
|
+
const occupied = new Set();
|
|
114
|
+
for (const [key, entry] of entries) {
|
|
115
|
+
const index = next.indexOf ? next.indexOf(key) : entry.index;
|
|
116
|
+
if (!Number.isSafeInteger(index) || index < 0 || index >= count || occupied.has(index)) remove(key);
|
|
117
|
+
else { entry.index = index; occupied.add(index); }
|
|
118
|
+
}
|
|
119
|
+
rebuild();
|
|
120
|
+
},
|
|
121
|
+
clear() { entries.clear(); sorted = []; prefix = []; bytes = 0; },
|
|
122
|
+
stats() { return { measurements: entries.size, bytes, summaries: sorted.length }; },
|
|
123
|
+
dispose() { disposed = true; entries.clear(); sorted = []; prefix = []; bytes = 0; },
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Add finite pinned indices to a window; pins count against the same mounted budget.
|
|
128
|
+
* @param {{start:number,end:number}} range @param {number[]} pins @param {number} count @param {number} budget */
|
|
129
|
+
export function virtualIndices(range, pins, count, budget) {
|
|
130
|
+
integer(budget, 'pinBudget');
|
|
131
|
+
const extra = [...new Set(pins)].filter((i) => Number.isSafeInteger(i) && i >= 0 && i < count && (i < range.start || i >= range.end));
|
|
132
|
+
if (extra.length > budget) return { state: 'budget-exhausted', reason: 'pin-credits', indices: [] };
|
|
133
|
+
return { state: 'ready', indices: [...Array.from({ length: range.end - range.start }, (_, i) => range.start + i), ...extra].sort((a, b) => a - b) };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Normalize a browser RTL scroll offset at the DOM boundary. Modern engines use negative offsets.
|
|
137
|
+
* @param {number} value @param {number} maximum @param {'ltr'|'negative'|'reverse'|'default'} [mode] */
|
|
138
|
+
export function logicalScrollOffset(value, maximum, mode = 'ltr') {
|
|
139
|
+
return Math.min(Math.max(0, maximum), Math.max(0, mode === 'negative' ? -value : mode === 'reverse' ? maximum - value : value));
|
|
140
|
+
}
|