knolo-core 0.1.4 → 0.2.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.
@@ -0,0 +1,31 @@
1
+ // src/quality/proximity.ts
2
+ // Map<termId, positions[]>
3
+ export function minCoverSpan(posMap) {
4
+ const lists = posMap ? [...posMap.values()].map(arr => arr.slice().sort((a, b) => a - b)) : [];
5
+ if (lists.length === 0)
6
+ return null;
7
+ const idx = new Array(lists.length).fill(0);
8
+ let best = null;
9
+ while (true) {
10
+ const cur = [];
11
+ for (let i = 0; i < lists.length; i++) {
12
+ const val = lists[i][idx[i]];
13
+ if (val === undefined)
14
+ return best;
15
+ cur.push(val);
16
+ }
17
+ const min = Math.min(...cur);
18
+ const max = Math.max(...cur);
19
+ const span = max - min;
20
+ if (best === null || span < best)
21
+ best = span;
22
+ // advance list with current min
23
+ const minList = cur.indexOf(min);
24
+ idx[minList]++;
25
+ }
26
+ }
27
+ export function proximityMultiplier(span, strength = 0.15) {
28
+ if (span === null)
29
+ return 1;
30
+ return 1 + strength / (1 + span); // gentle, bounded
31
+ }
@@ -0,0 +1,3 @@
1
+ export type KNSSignature = [number, number, number];
2
+ export declare function knsSignature(s: string): KNSSignature;
3
+ export declare function knsDistance(a: KNSSignature, b: KNSSignature): number;
@@ -0,0 +1,24 @@
1
+ // src/quality/signature.ts
2
+ // "KNS" — simple, deterministic lexical numeric signature for tie-breaking.
3
+ const PRIMES = [257, 263, 269];
4
+ export function knsSignature(s) {
5
+ let s1 = 0, s2 = 0, s3 = 0;
6
+ for (let i = 0; i < s.length; i++) {
7
+ const code = s.charCodeAt(i);
8
+ s1 = (s1 + code) % PRIMES[0];
9
+ s2 = (s2 + code * (i + 1)) % PRIMES[1];
10
+ s3 = (s3 + ((code << 1) ^ (i + 7))) % PRIMES[2];
11
+ }
12
+ return [s1, s2, s3];
13
+ }
14
+ export function knsDistance(a, b) {
15
+ // circular distance on a mod prime, averaged & normalized to 0..1
16
+ let acc = 0;
17
+ for (let i = 0; i < PRIMES.length; i++) {
18
+ const p = PRIMES[i];
19
+ const diff = Math.abs(a[i] - b[i]);
20
+ const circ = Math.min(diff, p - diff) / p;
21
+ acc += circ;
22
+ }
23
+ return acc / PRIMES.length;
24
+ }
@@ -0,0 +1,3 @@
1
+ export declare function ngramSet(s: string, n?: number): Set<string>;
2
+ export declare function jaccardFromSets(a: Set<string>, b: Set<string>): number;
3
+ export declare function jaccard5(s1: string, s2: string): number;
@@ -0,0 +1,27 @@
1
+ // src/quality/similarity.ts
2
+ import { normalize } from '../tokenize.js';
3
+ export function ngramSet(s, n = 5) {
4
+ const t = normalize(s);
5
+ const out = new Set();
6
+ if (t.length < n) {
7
+ if (t)
8
+ out.add(t);
9
+ return out;
10
+ }
11
+ for (let i = 0; i <= t.length - n; i++)
12
+ out.add(t.slice(i, i + n));
13
+ return out;
14
+ }
15
+ export function jaccardFromSets(a, b) {
16
+ if (a.size === 0 && b.size === 0)
17
+ return 1;
18
+ let inter = 0;
19
+ for (const x of a)
20
+ if (b.has(x))
21
+ inter++;
22
+ const uni = a.size + b.size - inter;
23
+ return uni ? inter / uni : 0;
24
+ }
25
+ export function jaccard5(s1, s2) {
26
+ return jaccardFromSets(ngramSet(s1, 5), ngramSet(s2, 5));
27
+ }
package/dist/query.d.ts CHANGED
@@ -1,7 +1,6 @@
1
- import type { Pack } from './pack';
1
+ import type { Pack } from "./pack.js";
2
2
  export type QueryOptions = {
3
3
  topK?: number;
4
- /** Additional phrases (unquoted) that must be present in results. */
5
4
  requirePhrases?: string[];
6
5
  };
7
6
  export type Hit = {
@@ -10,9 +9,4 @@ export type Hit = {
10
9
  text: string;
11
10
  source?: string;
12
11
  };
13
- /** Execute a search against a mounted pack. The query string can contain
14
- * quoted phrases; unquoted terms are treated individually. The `topK`
15
- * parameter controls how many results are returned. If `requirePhrases`
16
- * contains strings, those phrases must appear verbatim in candidate blocks.
17
- */
18
12
  export declare function query(pack: Pack, q: string, opts?: QueryOptions): Hit[];
package/dist/query.js CHANGED
@@ -1,95 +1,154 @@
1
1
  /*
2
2
  * query.ts
3
3
  *
4
- * Implements the main query function that ties together tokenization, lexical
5
- * lookup in the pack's lexicon, scanning the postings list to generate
6
- * candidates, and ranking them using the BM25L ranker. Phrase detection
7
- * operates on the block text itself and is naive but effective for short
8
- * queries. Future versions may incorporate more advanced mechanisms.
4
+ * Deterministic, embedding-free retrieval with:
5
+ * - REQUIRED phrase enforcement (quoted and requirePhrases)
6
+ * - Proximity bonus based on min cover span
7
+ * - Optional heading overlap boost
8
+ * - KNS numeric-signature tie-breaker (tiny)
9
+ * - Near-duplicate suppression + MMR diversity
9
10
  */
10
- import { tokenize, parsePhrases } from "./tokenize.js";
11
+ import { tokenize, parsePhrases, normalize } from "./tokenize.js";
11
12
  import { rankBM25L } from "./rank.js";
12
- /** Execute a search against a mounted pack. The query string can contain
13
- * quoted phrases; unquoted terms are treated individually. The `topK`
14
- * parameter controls how many results are returned. If `requirePhrases`
15
- * contains strings, those phrases must appear verbatim in candidate blocks.
16
- */
13
+ import { minCoverSpan, proximityMultiplier } from "./quality/proximity.js";
14
+ import { diversifyAndDedupe } from "./quality/diversify.js";
15
+ import { knsSignature, knsDistance } from "./quality/signature.js";
17
16
  export function query(pack, q, opts = {}) {
18
17
  const topK = opts.topK ?? 10;
18
+ // --- Query parsing
19
19
  const normTokens = tokenize(q).map((t) => t.term);
20
- const phraseTerms = parsePhrases(q);
21
- // Include explicitly provided phrases
22
- if (opts.requirePhrases) {
23
- for (const p of opts.requirePhrases) {
24
- const terms = p.split(/\s+/).filter(Boolean);
25
- if (terms.length > 0)
26
- phraseTerms.push(terms);
27
- }
28
- }
29
- // Translate tokens to term IDs. Terms not in the lexicon are skipped.
20
+ // Normalize quoted phrases from q
21
+ const quotedRaw = parsePhrases(q); // arrays of raw terms
22
+ const quoted = quotedRaw.map(seq => seq.map(t => normalize(t)).flatMap(s => s.split(/\s+/)).filter(Boolean));
23
+ // Normalize requirePhrases the same way
24
+ const extraReq = (opts.requirePhrases ?? [])
25
+ .map(s => tokenize(s).map(t => t.term)) // <<< normalize via tokenizer
26
+ .filter(arr => arr.length > 0);
27
+ const requiredPhrases = [...quoted, ...extraReq];
28
+ // --- Term ids for the free (unquoted) tokens in q
30
29
  const termIds = normTokens
31
30
  .map((t) => pack.lexicon.get(t))
32
31
  .filter((id) => id !== undefined);
33
- // Map from blockId to candidate data (term frequencies, flags)
32
+ // If there are no free tokens but there ARE required phrases, we'll fill candidates from phrases later.
33
+ const termSet = new Set(termIds);
34
+ // --- Candidate map
34
35
  const candidates = new Map();
35
- // Scan postings list. The format is [termId, blockId, pos... 0, blockId, pos... 0, 0, termId, ...]
36
- const p = pack.postings;
37
- let i = 0;
38
- while (i < p.length) {
39
- const tid = p[i++];
40
- if (tid === 0)
41
- continue;
42
- const relevant = termIds.includes(tid);
43
- let bid = p[i++];
44
- while (bid !== 0) {
45
- let pos = p[i++];
46
- const positions = [];
47
- while (pos !== 0) {
48
- positions.push(pos);
49
- pos = p[i++];
50
- }
51
- if (relevant) {
52
- let entry = candidates.get(bid);
53
- if (!entry) {
54
- entry = { tf: new Map() };
55
- candidates.set(bid, entry);
36
+ // Helper to harvest postings for a given set of termIds into candidates
37
+ function scanForTermIds(idSet) {
38
+ const p = pack.postings;
39
+ let i = 0;
40
+ while (i < p.length) {
41
+ const tid = p[i++];
42
+ if (tid === 0)
43
+ continue;
44
+ const relevant = idSet.has(tid);
45
+ let bid = p[i++];
46
+ while (bid !== 0) {
47
+ let pos = p[i++];
48
+ const positions = [];
49
+ while (pos !== 0) {
50
+ positions.push(pos);
51
+ pos = p[i++];
56
52
  }
57
- // accumulate tf per termId; positions array length is tf
58
- entry.tf.set(tid, positions.length);
53
+ if (relevant) {
54
+ let entry = candidates.get(bid);
55
+ if (!entry) {
56
+ entry = { tf: new Map(), pos: new Map() };
57
+ candidates.set(bid, entry);
58
+ }
59
+ entry.tf.set(tid, positions.length);
60
+ entry.pos.set(tid, positions);
61
+ }
62
+ bid = p[i++];
63
+ }
64
+ }
65
+ }
66
+ // 1) Scan using tokens from q (if any)
67
+ if (termSet.size > 0) {
68
+ scanForTermIds(termSet);
69
+ }
70
+ // 2) Phrase-first rescue:
71
+ // If nothing matched the free tokens, but we do have required phrases,
72
+ // build a fallback term set from ALL tokens that appear in those phrases and scan again.
73
+ if (candidates.size === 0 && requiredPhrases.length > 0) {
74
+ const phraseTokenIds = new Set();
75
+ for (const seq of requiredPhrases) {
76
+ for (const t of seq) {
77
+ const id = pack.lexicon.get(t);
78
+ if (id !== undefined)
79
+ phraseTokenIds.add(id);
59
80
  }
60
- bid = p[i++];
61
81
  }
62
- // end of term section; skip trailing zero already consumed
82
+ if (phraseTokenIds.size > 0) {
83
+ scanForTermIds(phraseTokenIds);
84
+ }
85
+ }
86
+ // --- Phrase enforcement (now that we have some candidates)
87
+ if (requiredPhrases.length > 0) {
88
+ for (const [bid, data] of [...candidates]) {
89
+ const text = pack.blocks[bid] || "";
90
+ const ok = requiredPhrases.every((seq) => containsPhrase(text, seq));
91
+ if (!ok)
92
+ candidates.delete(bid);
93
+ else
94
+ data.hasPhrase = true;
95
+ }
63
96
  }
64
- // Check phrases on candidate texts. If a candidate does not contain all
65
- // required phrases, it will be filtered out in ranking.
66
- for (const [bid, data] of candidates) {
67
- const text = pack.blocks[bid] || '';
68
- data.hasPhrase = phraseTerms.some((seq) => containsPhrase(text, seq));
97
+ else if (quoted.length > 0) {
98
+ for (const [bid, data] of candidates) {
99
+ const text = pack.blocks[bid] || "";
100
+ data.hasPhrase = quoted.some((seq) => containsPhrase(text, seq));
101
+ }
102
+ }
103
+ // If still nothing, bail early
104
+ if (candidates.size === 0)
105
+ return [];
106
+ // --- Heading overlap
107
+ if (pack.headings?.length) {
108
+ const qset = new Set(normTokens);
109
+ const qUniqueCount = new Set(normTokens).size || 1;
110
+ for (const [bid, data] of candidates) {
111
+ const h = pack.headings[bid] ?? "";
112
+ const hTerms = tokenize(h || "").map((t) => t.term);
113
+ const overlap = new Set(hTerms.filter((t) => qset.has(t))).size;
114
+ data.headingScore = overlap / qUniqueCount;
115
+ }
69
116
  }
70
- // Compute average block length for ranking normalization
71
- const avgLen = pack.blocks.length
72
- ? pack.blocks.reduce((sum, b) => sum + tokenize(b).length, 0) / pack.blocks.length
73
- : 1;
74
- const ranked = rankBM25L(candidates, avgLen);
75
- return ranked.slice(0, topK).map((res) => ({
76
- blockId: res.blockId,
77
- score: res.score,
78
- text: pack.blocks[res.blockId] || '',
79
- }));
117
+ // --- Rank with proximity bonus
118
+ const avgLen = pack.meta?.stats?.avgBlockLen ??
119
+ (pack.blocks.length
120
+ ? pack.blocks.reduce((s, b) => s + tokenize(b).length, 0) / pack.blocks.length
121
+ : 1);
122
+ const prelim = rankBM25L(candidates, avgLen, {
123
+ proximityBonus: (cand) => proximityMultiplier(minCoverSpan(cand.pos)),
124
+ });
125
+ if (prelim.length === 0)
126
+ return [];
127
+ // --- KNS tie-breaker + de-dup/MMR
128
+ const qSig = knsSignature(normalize(q));
129
+ const pool = prelim.slice(0, topK * 5).map((r) => {
130
+ const text = pack.blocks[r.blockId] || "";
131
+ const boost = 1 + 0.02 * (1 - knsDistance(qSig, knsSignature(text)));
132
+ return {
133
+ blockId: r.blockId,
134
+ score: r.score * boost,
135
+ text,
136
+ source: pack.docIds?.[r.blockId] ?? undefined,
137
+ };
138
+ });
139
+ const finalHits = diversifyAndDedupe(pool, { k: topK });
140
+ return finalHits;
80
141
  }
81
- /** Determine whether the given sequence of terms appears in order within the
82
- * text. The algorithm tokenizes the text and performs a sliding window
83
- * comparison. This is case‑insensitive and uses the same normalization as
84
- * other parts of the system.
85
- */
142
+ /** Ordered phrase check using the SAME tokenizer/normalizer path as the index. */
86
143
  function containsPhrase(text, seq) {
87
144
  if (seq.length === 0)
88
145
  return false;
146
+ // normalize seq via tokenizer to be extra safe (handles diacritics/case)
147
+ const seqNorm = tokenize(seq.join(" ")).map(t => t.term);
89
148
  const toks = tokenize(text).map((t) => t.term);
90
- outer: for (let i = 0; i <= toks.length - seq.length; i++) {
91
- for (let j = 0; j < seq.length; j++) {
92
- if (toks[i + j] !== seq[j])
149
+ outer: for (let i = 0; i <= toks.length - seqNorm.length; i++) {
150
+ for (let j = 0; j < seqNorm.length; j++) {
151
+ if (toks[i + j] !== seqNorm[j])
93
152
  continue outer;
94
153
  }
95
154
  return true;
package/dist/rank.d.ts CHANGED
@@ -3,15 +3,16 @@ export type RankOptions = {
3
3
  b?: number;
4
4
  headingBoost?: number;
5
5
  phraseBoost?: number;
6
+ proximityBonus?: (cand: {
7
+ tf: Map<number, number>;
8
+ pos?: Map<number, number[]>;
9
+ hasPhrase?: boolean;
10
+ headingScore?: number;
11
+ }) => number;
6
12
  };
7
- /**
8
- * Rank a set of candidate blocks using BM25L. Each candidate carries a term
9
- * frequency (tf) map keyed by termId. Additional properties may include
10
- * `hasPhrase` and `headingScore` to apply multiplicative boosts. The average
11
- * document length (avgLen) is required for BM25L normalization.
12
- */
13
13
  export declare function rankBM25L(candidates: Map<number, {
14
14
  tf: Map<number, number>;
15
+ pos?: Map<number, number[]>;
15
16
  hasPhrase?: boolean;
16
17
  headingScore?: number;
17
18
  }>, avgLen: number, opts?: RankOptions): Array<{
package/dist/rank.js CHANGED
@@ -1,18 +1,6 @@
1
1
  /*
2
2
  * rank.ts
3
- *
4
- * Implements a simple BM25L ranker with optional boosts for headings and
5
- * phrase matches. The inputs to the ranker are a map of block IDs to term
6
- * frequency maps and additional boolean flags. The outputs are sorted by
7
- * descending relevance score. This ranking algorithm can be replaced or
8
- * augmented in the future without impacting the public API of the query
9
- * function.
10
- */
11
- /**
12
- * Rank a set of candidate blocks using BM25L. Each candidate carries a term
13
- * frequency (tf) map keyed by termId. Additional properties may include
14
- * `hasPhrase` and `headingScore` to apply multiplicative boosts. The average
15
- * document length (avgLen) is required for BM25L normalization.
3
+ * BM25L ranker with optional heading/phrase boosts and a proximity bonus hook.
16
4
  */
17
5
  export function rankBM25L(candidates, avgLen, opts = {}) {
18
6
  const k1 = opts.k1 ?? 1.5;
@@ -24,18 +12,17 @@ export function rankBM25L(candidates, avgLen, opts = {}) {
24
12
  const len = Array.from(data.tf.values()).reduce((sum, tf) => sum + tf, 0) || 1;
25
13
  let score = 0;
26
14
  for (const [, tf] of data.tf) {
27
- const idf = 1; // placeholder; no document frequency available in v0
15
+ const idf = 1; // v0: no DF; can be extended later
28
16
  const numer = tf * (k1 + 1);
29
17
  const denom = tf + k1 * (1 - b + b * (len / avgLen));
30
18
  score += idf * (numer / denom);
31
19
  }
32
- // Apply boosts multiplicatively
33
- if (data.hasPhrase) {
20
+ if (opts.proximityBonus)
21
+ score *= opts.proximityBonus(data) ?? 1;
22
+ if (data.hasPhrase)
34
23
  score *= 1 + phraseBoost;
35
- }
36
- if (data.headingScore) {
24
+ if (data.headingScore)
37
25
  score *= 1 + headingBoost * data.headingScore;
38
- }
39
26
  results.push({ blockId: bid, score });
40
27
  }
41
28
  results.sort((a, b2) => b2.score - a.score);
@@ -0,0 +1,8 @@
1
+ export type TextDecoderLike = {
2
+ decode: (u8: Uint8Array) => string;
3
+ };
4
+ export type TextEncoderLike = {
5
+ encode: (s: string) => Uint8Array;
6
+ };
7
+ export declare function getTextDecoder(): TextDecoderLike;
8
+ export declare function getTextEncoder(): TextEncoderLike;
@@ -0,0 +1,72 @@
1
+ // src/utils/utf8.ts
2
+ // Small, dependency-free UTF-8 encoder/decoder that works in RN/Hermes.
3
+ export function getTextDecoder() {
4
+ try {
5
+ // eslint-disable-next-line no-new
6
+ const td = new TextDecoder();
7
+ return td;
8
+ }
9
+ catch {
10
+ return {
11
+ decode: (u8) => {
12
+ let out = '';
13
+ for (let i = 0; i < u8.length;) {
14
+ const a = u8[i++];
15
+ if (a < 0x80) {
16
+ out += String.fromCharCode(a);
17
+ }
18
+ else if ((a & 0xe0) === 0xc0) {
19
+ const b = u8[i++] & 0x3f;
20
+ const cp = ((a & 0x1f) << 6) | b;
21
+ out += String.fromCharCode(cp);
22
+ }
23
+ else if ((a & 0xf0) === 0xe0) {
24
+ const b = u8[i++] & 0x3f;
25
+ const c = u8[i++] & 0x3f;
26
+ const cp = ((a & 0x0f) << 12) | (b << 6) | c;
27
+ out += String.fromCharCode(cp);
28
+ }
29
+ else {
30
+ const b = u8[i++] & 0x3f;
31
+ const c = u8[i++] & 0x3f;
32
+ const d = u8[i++] & 0x3f;
33
+ let cp = ((a & 0x07) << 18) | (b << 12) | (c << 6) | d;
34
+ cp -= 0x10000;
35
+ out += String.fromCharCode(0xd800 + (cp >> 10), 0xdc00 + (cp & 0x3ff));
36
+ }
37
+ }
38
+ return out;
39
+ },
40
+ };
41
+ }
42
+ }
43
+ export function getTextEncoder() {
44
+ try {
45
+ // eslint-disable-next-line no-new
46
+ const te = new TextEncoder();
47
+ return te;
48
+ }
49
+ catch {
50
+ return {
51
+ encode: (s) => {
52
+ const out = [];
53
+ for (let i = 0; i < s.length; i++) {
54
+ let cp = s.charCodeAt(i);
55
+ if (cp >= 0xd800 && cp <= 0xdbff && i + 1 < s.length) {
56
+ const next = s.charCodeAt(++i);
57
+ cp = 0x10000 + ((cp - 0xd800) << 10) + (next - 0xdc00);
58
+ }
59
+ if (cp < 0x80)
60
+ out.push(cp);
61
+ else if (cp < 0x800)
62
+ out.push(0xc0 | (cp >> 6), 0x80 | (cp & 0x3f));
63
+ else if (cp < 0x10000)
64
+ out.push(0xe0 | (cp >> 12), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f));
65
+ else
66
+ out.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f));
67
+ }
68
+ return new Uint8Array(out);
69
+ },
70
+ };
71
+ }
72
+ }
package/package.json CHANGED
@@ -1,15 +1,29 @@
1
1
  {
2
2
  "name": "knolo-core",
3
- "version": "0.1.4",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "Local-first knowledge packs for small LLMs.",
6
- "keywords": ["llm", "knowledge-base", "rag", "local", "expo"],
7
- "author": "Your Name",
8
- "license": "MIT",
6
+ "keywords": [
7
+ "llm",
8
+ "knowledge-base",
9
+ "rag",
10
+ "local",
11
+ "expo"
12
+ ],
13
+ "author": "Sam Paniagua",
14
+ "license": "Apache-2.0",
9
15
  "main": "./dist/index.js",
10
16
  "types": "./dist/index.d.ts",
11
- "files": ["dist", "bin", "README.md", "LICENSE"],
12
- "bin": { "knolo": "./bin/knolo.mjs" },
17
+ "files": [
18
+ "dist",
19
+ "bin",
20
+ "README.md",
21
+ "LICENSE",
22
+ "DOCS.md"
23
+ ],
24
+ "bin": {
25
+ "knolo": "./bin/knolo.mjs"
26
+ },
13
27
  "exports": {
14
28
  ".": {
15
29
  "import": "./dist/index.js",
@@ -18,7 +32,8 @@
18
32
  },
19
33
  "scripts": {
20
34
  "build": "tsc -p tsconfig.json",
21
- "prepublishOnly": "npm run build"
35
+ "prepublishOnly": "npm run build",
36
+ "smoke": "node scripts/smoke.mjs"
22
37
  },
23
38
  "devDependencies": {
24
39
  "typescript": "^5.5.0",