@topgunbuild/core 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1973,6 +1973,95 @@ var Predicates = class {
1973
1973
  static containsAny(attribute, values) {
1974
1974
  return { op: "containsAny", attribute, value: values };
1975
1975
  }
1976
+ // ============== Full-Text Search Predicates (Phase 12) ==============
1977
+ /**
1978
+ * Create a 'match' predicate for full-text search.
1979
+ * Uses BM25 scoring to find relevant documents.
1980
+ *
1981
+ * @param attribute - Field to search in
1982
+ * @param query - Search query string
1983
+ * @param options - Match options (minScore, boost, operator, fuzziness)
1984
+ *
1985
+ * @example
1986
+ * ```typescript
1987
+ * // Simple match
1988
+ * Predicates.match('title', 'machine learning')
1989
+ *
1990
+ * // With options
1991
+ * Predicates.match('body', 'neural networks', { minScore: 1.0, boost: 2.0 })
1992
+ * ```
1993
+ */
1994
+ static match(attribute, query, options) {
1995
+ return { op: "match", attribute, query, matchOptions: options };
1996
+ }
1997
+ /**
1998
+ * Create a 'matchPhrase' predicate for exact phrase matching.
1999
+ * Matches documents containing the exact phrase (words in order).
2000
+ *
2001
+ * @param attribute - Field to search in
2002
+ * @param query - Phrase to match
2003
+ * @param slop - Word distance tolerance (0 = exact, 1 = allow 1 word between)
2004
+ *
2005
+ * @example
2006
+ * ```typescript
2007
+ * // Exact phrase
2008
+ * Predicates.matchPhrase('body', 'machine learning')
2009
+ *
2010
+ * // With slop (allows "machine deep learning")
2011
+ * Predicates.matchPhrase('body', 'machine learning', 1)
2012
+ * ```
2013
+ */
2014
+ static matchPhrase(attribute, query, slop) {
2015
+ return { op: "matchPhrase", attribute, query, slop };
2016
+ }
2017
+ /**
2018
+ * Create a 'matchPrefix' predicate for prefix matching.
2019
+ * Matches documents where field starts with the given prefix.
2020
+ *
2021
+ * @param attribute - Field to search in
2022
+ * @param prefix - Prefix to match
2023
+ * @param maxExpansions - Maximum number of term expansions
2024
+ *
2025
+ * @example
2026
+ * ```typescript
2027
+ * // Match titles starting with "mach"
2028
+ * Predicates.matchPrefix('title', 'mach')
2029
+ *
2030
+ * // Limit expansions for performance
2031
+ * Predicates.matchPrefix('title', 'mach', 50)
2032
+ * ```
2033
+ */
2034
+ static matchPrefix(attribute, prefix, maxExpansions) {
2035
+ return { op: "matchPrefix", attribute, prefix, maxExpansions };
2036
+ }
2037
+ /**
2038
+ * Create a multi-field match predicate.
2039
+ * Searches across multiple fields with optional per-field boosting.
2040
+ *
2041
+ * @param attributes - Fields to search in
2042
+ * @param query - Search query string
2043
+ * @param options - Options including per-field boost factors
2044
+ *
2045
+ * @example
2046
+ * ```typescript
2047
+ * // Search title and body
2048
+ * Predicates.multiMatch(['title', 'body'], 'machine learning')
2049
+ *
2050
+ * // With boosting (title 2x more important)
2051
+ * Predicates.multiMatch(['title', 'body'], 'machine learning', {
2052
+ * boost: { title: 2.0, body: 1.0 }
2053
+ * })
2054
+ * ```
2055
+ */
2056
+ static multiMatch(attributes, query, options) {
2057
+ const children = attributes.map((attr) => ({
2058
+ op: "match",
2059
+ attribute: attr,
2060
+ query,
2061
+ matchOptions: options?.boost?.[attr] ? { boost: options.boost[attr] } : void 0
2062
+ }));
2063
+ return { op: "or", children };
2064
+ }
1976
2065
  };
1977
2066
  function evaluatePredicate(predicate, data) {
1978
2067
  if (!data) return false;
@@ -3595,6 +3684,9 @@ function isSimpleQuery(query) {
3595
3684
  function isLogicalQuery(query) {
3596
3685
  return query.type === "and" || query.type === "or" || query.type === "not";
3597
3686
  }
3687
+ function isFTSQuery(query) {
3688
+ return query.type === "match" || query.type === "matchPhrase" || query.type === "matchPrefix";
3689
+ }
3598
3690
 
3599
3691
  // src/query/indexes/StandingQueryIndex.ts
3600
3692
  var _StandingQueryIndex = class _StandingQueryIndex {
@@ -5853,11 +5945,48 @@ var QueryOptimizer = class {
5853
5945
  if ("indexRegistry" in indexRegistryOrOptions) {
5854
5946
  this.indexRegistry = indexRegistryOrOptions.indexRegistry;
5855
5947
  this.standingQueryRegistry = indexRegistryOrOptions.standingQueryRegistry;
5948
+ this.fullTextIndexes = indexRegistryOrOptions.fullTextIndexes ?? /* @__PURE__ */ new Map();
5856
5949
  } else {
5857
5950
  this.indexRegistry = indexRegistryOrOptions;
5858
5951
  this.standingQueryRegistry = standingQueryRegistry;
5952
+ this.fullTextIndexes = /* @__PURE__ */ new Map();
5859
5953
  }
5860
5954
  }
5955
+ /**
5956
+ * Register a full-text index for a field (Phase 12).
5957
+ *
5958
+ * @param field - Field name
5959
+ * @param index - FullTextIndex instance
5960
+ */
5961
+ registerFullTextIndex(field, index) {
5962
+ this.fullTextIndexes.set(field, index);
5963
+ }
5964
+ /**
5965
+ * Unregister a full-text index (Phase 12).
5966
+ *
5967
+ * @param field - Field name
5968
+ */
5969
+ unregisterFullTextIndex(field) {
5970
+ this.fullTextIndexes.delete(field);
5971
+ }
5972
+ /**
5973
+ * Get registered full-text index for a field (Phase 12).
5974
+ *
5975
+ * @param field - Field name
5976
+ * @returns FullTextIndex or undefined
5977
+ */
5978
+ getFullTextIndex(field) {
5979
+ return this.fullTextIndexes.get(field);
5980
+ }
5981
+ /**
5982
+ * Check if a full-text index exists for a field (Phase 12).
5983
+ *
5984
+ * @param field - Field name
5985
+ * @returns True if FTS index exists
5986
+ */
5987
+ hasFullTextIndex(field) {
5988
+ return this.fullTextIndexes.has(field);
5989
+ }
5861
5990
  /**
5862
5991
  * Optimize a query and return an execution plan.
5863
5992
  *
@@ -5931,12 +6060,151 @@ var QueryOptimizer = class {
5931
6060
  optimizeNode(query) {
5932
6061
  if (isLogicalQuery(query)) {
5933
6062
  return this.optimizeLogical(query);
6063
+ } else if (isFTSQuery(query)) {
6064
+ return this.optimizeFTS(query);
5934
6065
  } else if (isSimpleQuery(query)) {
5935
6066
  return this.optimizeSimple(query);
5936
6067
  } else {
5937
6068
  return { type: "full-scan", predicate: query };
5938
6069
  }
5939
6070
  }
6071
+ /**
6072
+ * Optimize a full-text search query (Phase 12).
6073
+ */
6074
+ optimizeFTS(query) {
6075
+ const field = query.attribute;
6076
+ if (!this.hasFullTextIndex(field)) {
6077
+ return { type: "full-scan", predicate: query };
6078
+ }
6079
+ return this.buildFTSScanStep(query);
6080
+ }
6081
+ /**
6082
+ * Build an FTS scan step from a query node (Phase 12).
6083
+ */
6084
+ buildFTSScanStep(query) {
6085
+ const field = query.attribute;
6086
+ switch (query.type) {
6087
+ case "match":
6088
+ return {
6089
+ type: "fts-scan",
6090
+ field,
6091
+ query: query.query,
6092
+ ftsType: "match",
6093
+ options: query.options,
6094
+ returnsScored: true,
6095
+ estimatedCost: this.estimateFTSCost(field)
6096
+ };
6097
+ case "matchPhrase":
6098
+ return {
6099
+ type: "fts-scan",
6100
+ field,
6101
+ query: query.query,
6102
+ ftsType: "matchPhrase",
6103
+ options: query.slop !== void 0 ? { fuzziness: query.slop } : void 0,
6104
+ returnsScored: true,
6105
+ estimatedCost: this.estimateFTSCost(field)
6106
+ };
6107
+ case "matchPrefix":
6108
+ return {
6109
+ type: "fts-scan",
6110
+ field,
6111
+ query: query.prefix,
6112
+ ftsType: "matchPrefix",
6113
+ options: query.maxExpansions !== void 0 ? { fuzziness: query.maxExpansions } : void 0,
6114
+ returnsScored: true,
6115
+ estimatedCost: this.estimateFTSCost(field)
6116
+ };
6117
+ default:
6118
+ throw new Error(`Unknown FTS query type: ${query.type}`);
6119
+ }
6120
+ }
6121
+ /**
6122
+ * Estimate cost of FTS query based on index size (Phase 12).
6123
+ */
6124
+ estimateFTSCost(field) {
6125
+ const index = this.fullTextIndexes.get(field);
6126
+ if (!index) {
6127
+ return Number.MAX_SAFE_INTEGER;
6128
+ }
6129
+ const docCount = index.getSize();
6130
+ return 50 + Math.log2(docCount + 1) * 10;
6131
+ }
6132
+ /**
6133
+ * Classify predicates by type for hybrid query planning (Phase 12).
6134
+ *
6135
+ * @param predicates - Array of predicates to classify
6136
+ * @returns Classified predicates
6137
+ */
6138
+ classifyPredicates(predicates) {
6139
+ const result = {
6140
+ exactPredicates: [],
6141
+ rangePredicates: [],
6142
+ ftsPredicates: [],
6143
+ otherPredicates: []
6144
+ };
6145
+ for (const pred of predicates) {
6146
+ if (isFTSQuery(pred)) {
6147
+ result.ftsPredicates.push(pred);
6148
+ } else if (isSimpleQuery(pred)) {
6149
+ switch (pred.type) {
6150
+ case "eq":
6151
+ case "neq":
6152
+ case "in":
6153
+ result.exactPredicates.push(pred);
6154
+ break;
6155
+ case "gt":
6156
+ case "gte":
6157
+ case "lt":
6158
+ case "lte":
6159
+ case "between":
6160
+ result.rangePredicates.push(pred);
6161
+ break;
6162
+ default:
6163
+ result.otherPredicates.push(pred);
6164
+ }
6165
+ } else if (isLogicalQuery(pred)) {
6166
+ result.otherPredicates.push(pred);
6167
+ } else {
6168
+ result.otherPredicates.push(pred);
6169
+ }
6170
+ }
6171
+ return result;
6172
+ }
6173
+ /**
6174
+ * Determine fusion strategy based on step types (Phase 12).
6175
+ *
6176
+ * Strategy selection:
6177
+ * - All binary (exact/range with no scores) → 'intersection'
6178
+ * - All scored (FTS) → 'score-filter' (filter by score, sort by score)
6179
+ * - Mixed (binary + scored) → 'rrf' (Reciprocal Rank Fusion)
6180
+ *
6181
+ * @param steps - Plan steps to fuse
6182
+ * @returns Fusion strategy
6183
+ */
6184
+ determineFusionStrategy(steps) {
6185
+ const hasScored = steps.some((s) => this.stepReturnsScored(s));
6186
+ const hasBinary = steps.some((s) => !this.stepReturnsScored(s));
6187
+ if (hasScored && hasBinary) {
6188
+ return "rrf";
6189
+ } else if (hasScored) {
6190
+ return "score-filter";
6191
+ } else {
6192
+ return "intersection";
6193
+ }
6194
+ }
6195
+ /**
6196
+ * Check if a plan step returns scored results (Phase 12).
6197
+ */
6198
+ stepReturnsScored(step) {
6199
+ switch (step.type) {
6200
+ case "fts-scan":
6201
+ return true;
6202
+ case "fusion":
6203
+ return step.returnsScored;
6204
+ default:
6205
+ return false;
6206
+ }
6207
+ }
5940
6208
  /**
5941
6209
  * Optimize a simple (attribute-based) query.
5942
6210
  */
@@ -6192,6 +6460,18 @@ var QueryOptimizer = class {
6192
6460
  return this.estimateCost(step.source) + 10;
6193
6461
  case "not":
6194
6462
  return this.estimateCost(step.source) + 100;
6463
+ // Phase 12: FTS step types
6464
+ case "fts-scan":
6465
+ return step.estimatedCost;
6466
+ case "fusion":
6467
+ return step.steps.reduce((sum, s) => {
6468
+ const cost = this.estimateCost(s);
6469
+ if (cost === Number.MAX_SAFE_INTEGER) {
6470
+ return Number.MAX_SAFE_INTEGER;
6471
+ }
6472
+ return Math.min(sum + cost, Number.MAX_SAFE_INTEGER);
6473
+ }, 0) + 20;
6474
+ // Fusion overhead
6195
6475
  default:
6196
6476
  return Number.MAX_SAFE_INTEGER;
6197
6477
  }
@@ -6212,6 +6492,12 @@ var QueryOptimizer = class {
6212
6492
  return this.usesIndexes(step.source);
6213
6493
  case "not":
6214
6494
  return this.usesIndexes(step.source);
6495
+ // Phase 12: FTS step types
6496
+ case "fts-scan":
6497
+ return true;
6498
+ // FTS uses FullTextIndex
6499
+ case "fusion":
6500
+ return step.steps.some((s) => this.usesIndexes(s));
6215
6501
  default:
6216
6502
  return false;
6217
6503
  }
@@ -7760,6 +8046,131 @@ var DefaultIndexingStrategy = class {
7760
8046
  }
7761
8047
  };
7762
8048
 
8049
+ // src/search/ReciprocalRankFusion.ts
8050
+ var ReciprocalRankFusion = class {
8051
+ constructor(config) {
8052
+ this.k = config?.k ?? 60;
8053
+ }
8054
+ /**
8055
+ * Merge multiple ranked result lists using RRF.
8056
+ *
8057
+ * Formula: RRF_score(d) = Σ 1 / (k + rank_i(d))
8058
+ *
8059
+ * @param resultSets - Array of ranked result lists from different search methods
8060
+ * @returns Merged results sorted by RRF score (descending)
8061
+ */
8062
+ merge(resultSets) {
8063
+ const nonEmptySets = resultSets.filter((set) => set.length > 0);
8064
+ if (nonEmptySets.length === 0) {
8065
+ return [];
8066
+ }
8067
+ const scoreMap = /* @__PURE__ */ new Map();
8068
+ for (const resultSet of nonEmptySets) {
8069
+ for (let rank = 0; rank < resultSet.length; rank++) {
8070
+ const result = resultSet[rank];
8071
+ const { docId, score, source } = result;
8072
+ const rrfContribution = 1 / (this.k + rank + 1);
8073
+ const existing = scoreMap.get(docId);
8074
+ if (existing) {
8075
+ existing.rrfScore += rrfContribution;
8076
+ existing.sources.add(source);
8077
+ existing.originalScores[source] = score;
8078
+ } else {
8079
+ scoreMap.set(docId, {
8080
+ rrfScore: rrfContribution,
8081
+ sources: /* @__PURE__ */ new Set([source]),
8082
+ originalScores: { [source]: score }
8083
+ });
8084
+ }
8085
+ }
8086
+ }
8087
+ const merged = [];
8088
+ for (const [docId, data] of scoreMap) {
8089
+ merged.push({
8090
+ docId,
8091
+ score: data.rrfScore,
8092
+ source: Array.from(data.sources).sort().join("+"),
8093
+ originalScores: data.originalScores
8094
+ });
8095
+ }
8096
+ merged.sort((a, b) => b.score - a.score);
8097
+ return merged;
8098
+ }
8099
+ /**
8100
+ * Merge with weighted RRF for different method priorities.
8101
+ *
8102
+ * Weighted formula: RRF_score(d) = Σ weight_i * (1 / (k + rank_i(d)))
8103
+ *
8104
+ * @param resultSets - Array of ranked result lists
8105
+ * @param weights - Weights for each result set (same order as resultSets)
8106
+ * @returns Merged results sorted by weighted RRF score (descending)
8107
+ *
8108
+ * @example
8109
+ * ```typescript
8110
+ * const rrf = new ReciprocalRankFusion();
8111
+ *
8112
+ * // Prioritize exact matches (weight 2.0) over FTS (weight 1.0)
8113
+ * const merged = rrf.mergeWeighted(
8114
+ * [exactResults, ftsResults],
8115
+ * [2.0, 1.0]
8116
+ * );
8117
+ * ```
8118
+ */
8119
+ mergeWeighted(resultSets, weights) {
8120
+ if (weights.length !== resultSets.length) {
8121
+ throw new Error(
8122
+ `Weights array length (${weights.length}) must match resultSets length (${resultSets.length})`
8123
+ );
8124
+ }
8125
+ const nonEmptyPairs = [];
8126
+ for (let i = 0; i < resultSets.length; i++) {
8127
+ if (resultSets[i].length > 0) {
8128
+ nonEmptyPairs.push({ resultSet: resultSets[i], weight: weights[i] });
8129
+ }
8130
+ }
8131
+ if (nonEmptyPairs.length === 0) {
8132
+ return [];
8133
+ }
8134
+ const scoreMap = /* @__PURE__ */ new Map();
8135
+ for (const { resultSet, weight } of nonEmptyPairs) {
8136
+ for (let rank = 0; rank < resultSet.length; rank++) {
8137
+ const result = resultSet[rank];
8138
+ const { docId, score, source } = result;
8139
+ const rrfContribution = weight * (1 / (this.k + rank + 1));
8140
+ const existing = scoreMap.get(docId);
8141
+ if (existing) {
8142
+ existing.rrfScore += rrfContribution;
8143
+ existing.sources.add(source);
8144
+ existing.originalScores[source] = score;
8145
+ } else {
8146
+ scoreMap.set(docId, {
8147
+ rrfScore: rrfContribution,
8148
+ sources: /* @__PURE__ */ new Set([source]),
8149
+ originalScores: { [source]: score }
8150
+ });
8151
+ }
8152
+ }
8153
+ }
8154
+ const merged = [];
8155
+ for (const [docId, data] of scoreMap) {
8156
+ merged.push({
8157
+ docId,
8158
+ score: data.rrfScore,
8159
+ source: Array.from(data.sources).sort().join("+"),
8160
+ originalScores: data.originalScores
8161
+ });
8162
+ }
8163
+ merged.sort((a, b) => b.score - a.score);
8164
+ return merged;
8165
+ }
8166
+ /**
8167
+ * Get the k constant used for RRF calculation.
8168
+ */
8169
+ getK() {
8170
+ return this.k;
8171
+ }
8172
+ };
8173
+
7763
8174
  // src/IndexedLWWMap.ts
7764
8175
  var IndexedLWWMap = class extends LWWMap {
7765
8176
  constructor(hlc, options = {}) {
@@ -10439,6 +10850,7 @@ export {
10439
10850
  QuerySubMessageSchema,
10440
10851
  QueryUnsubMessageSchema,
10441
10852
  RESOLVER_FORBIDDEN_PATTERNS,
10853
+ ReciprocalRankFusion,
10442
10854
  RegisterResolverRequestSchema,
10443
10855
  RegisterResolverResponseSchema,
10444
10856
  Ringbuffer,