@arraypress/search 0.0.0-stage → 0.2.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.
@@ -0,0 +1,16 @@
1
+ import type { Prepared } from './record.js';
2
+ import type { FieldPlan } from './fields.js';
3
+ import type { Term } from '../query/terms.js';
4
+
5
+ /** What scoring needs: the fields worth more than `anywhere`, those worth more than `prefix`, and the two scores. */
6
+ export interface Scoring {
7
+ ranked: readonly FieldPlan[];
8
+ prefixed: readonly FieldPlan[];
9
+ prefix: number;
10
+ anywhere: number;
11
+ }
12
+
13
+ /** Whether a record has every term: what was typed at the start of a word, or one of its phrases as a whole word. */
14
+ export function matches(doc: Prepared, terms: readonly Term[]): boolean;
15
+ /** How well a record answers the terms: per term, the best of its phrases over the fields, added up. */
16
+ export function score(doc: Prepared, terms: readonly Term[], scoring: Scoring): number;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @module @arraypress/search/match/score
3
+ *
4
+ * Whether a prepared record answers a query's terms, and how well.
5
+ */
6
+
7
+ /**
8
+ * Whether a record has every term: what was typed at the start of a word (it
9
+ * may still be being typed), or one of its phrases as a whole word ("dance"
10
+ * isn't found in "guidance", nor a synonym "cab" in "cabin").
11
+ *
12
+ * @param {import('./record.js').Prepared} doc The record.
13
+ * @param {readonly import('../query/terms.js').Term[]} terms The terms.
14
+ * @returns {boolean} Whether it matches.
15
+ */
16
+ export const matches = (doc, terms) => terms.every((t) => t.start.test(doc.text) || t.whole.some((r) => r.test(doc.text)));
17
+
18
+ /**
19
+ * Whether a pattern is found in a field's text (in any one phrase of a list field).
20
+ *
21
+ * @param {RegExp} r The pattern.
22
+ * @param {string | string[]} text The field's text.
23
+ * @returns {boolean} Whether it is found.
24
+ */
25
+ const foundIn = (r, text) => (Array.isArray(text) ? text.some((w) => r.test(w)) : r.test(text));
26
+
27
+ /**
28
+ * How well a record answers the terms: per term, the best any of its phrases
29
+ * scores, added up. A phrase scores a field's weight when it is a whole word
30
+ * of it (a list field's `exact` when it is one of its phrases outright), and
31
+ * `anywhere` when it is a whole word anywhere in the record. When that comes
32
+ * to less than `prefix`, what was typed at the start of a word of a field
33
+ * weighted above `prefix` scores `prefix`.
34
+ *
35
+ * @param {import('./record.js').Prepared} doc The record.
36
+ * @param {readonly import('../query/terms.js').Term[]} terms The terms.
37
+ * @param {import('./score.js').Scoring} scoring The fields and scores.
38
+ * @returns {number} The score.
39
+ */
40
+ export function score(doc, terms, { ranked, prefixed, prefix, anywhere }) {
41
+ let total = 0;
42
+ for (const t of terms) {
43
+ let best = 0;
44
+ t.whole.forEach((r, k) => {
45
+ for (const f of ranked) {
46
+ const text = doc.texts[f.key];
47
+ if (f.exact > best && Array.isArray(text) && text.includes(t.phrases[k])) best = f.exact;
48
+ if (f.weight > best && foundIn(r, text)) best = f.weight;
49
+ }
50
+ if (anywhere > best && r.test(doc.text)) best = anywhere;
51
+ });
52
+ if (best < prefix && prefixed.some((f) => foundIn(t.start, doc.texts[f.key]))) best = prefix;
53
+ total += best;
54
+ }
55
+ return total;
56
+ }
@@ -0,0 +1,4 @@
1
+ /** Records as columns (an absent value as null), with a string table for each column that is smaller as one. */
2
+ export function toColumns(records: readonly Record<string, unknown>[]): { columns: Record<string, unknown[]>; tables: Record<string, string[]> };
3
+ /** Columns back as records (a null value is left out). */
4
+ export function fromColumns(n: number, columns: Readonly<Record<string, readonly unknown[]>>, tables?: Readonly<Record<string, readonly string[]>>): Record<string, unknown>[];
@@ -0,0 +1,78 @@
1
+ /**
2
+ * @module @arraypress/search/pack/columns
3
+ *
4
+ * Records as columns, one per field, for a compact JSON index. A column of
5
+ * strings (or lists of strings) whose values repeat is stored as numbers into
6
+ * a table of its strings, most used first, when that comes out smaller.
7
+ */
8
+
9
+ /**
10
+ * Whether every value of a column is a string, a list of strings or absent (null).
11
+ *
12
+ * @param {readonly unknown[]} values The column.
13
+ * @returns {boolean} Whether its strings can go in a table.
14
+ */
15
+ const stringy = (values) =>
16
+ values.every((v) => v === null || typeof v === 'string' || (Array.isArray(v) && v.every((s) => typeof s === 'string')));
17
+
18
+ /**
19
+ * A column of strings as a table of its strings (most used first) and each value as numbers into it.
20
+ *
21
+ * @param {readonly (string | string[] | null)[]} values The column.
22
+ * @returns {{ table: string[], values: (number | number[] | null)[] }} The table and the column.
23
+ */
24
+ function tabled(values) {
25
+ /** @type {Map<string, number>} */
26
+ const uses = new Map();
27
+ for (const v of values) for (const s of v === null ? [] : [v].flat()) uses.set(s, (uses.get(s) ?? 0) + 1);
28
+ const table = [...uses.keys()].sort((a, b) => /** @type {number} */ (uses.get(b)) - /** @type {number} */ (uses.get(a)));
29
+ const at = new Map(table.map((s, i) => [s, i]));
30
+ const num = (/** @type {string} */ s) => /** @type {number} */ (at.get(s));
31
+ return { table, values: values.map((v) => (v === null ? null : Array.isArray(v) ? v.map(num) : num(v))) };
32
+ }
33
+
34
+ /**
35
+ * Records as columns: one per field (in the order fields first appear), an
36
+ * absent value as null, and a table for each column that is smaller as one.
37
+ *
38
+ * @param {readonly Record<string, unknown>[]} records The records.
39
+ * @returns {{ columns: Record<string, unknown[]>, tables: Record<string, string[]> }} The columns and their tables.
40
+ */
41
+ export function toColumns(records) {
42
+ /** @type {Record<string, unknown[]>} */
43
+ const columns = {};
44
+ /** @type {Record<string, string[]>} */
45
+ const tables = {};
46
+ for (const k of new Set(records.flatMap((r) => Object.keys(r)))) {
47
+ const values = records.map((r) => r[k] ?? null);
48
+ const t = stringy(values) ? tabled(/** @type {(string | string[] | null)[]} */ (values)) : null;
49
+ if (t && JSON.stringify(t).length < JSON.stringify(values).length) {
50
+ columns[k] = t.values;
51
+ tables[k] = t.table;
52
+ } else columns[k] = values;
53
+ }
54
+ return { columns, tables };
55
+ }
56
+
57
+ /**
58
+ * Columns back as records: a null value is left out.
59
+ *
60
+ * @param {number} n How many records.
61
+ * @param {Readonly<Record<string, readonly unknown[]>>} columns The columns.
62
+ * @param {Readonly<Record<string, readonly string[]>>} [tables] Their tables.
63
+ * @returns {Record<string, unknown>[]} The records.
64
+ */
65
+ export function fromColumns(n, columns, tables = {}) {
66
+ const entries = Object.entries(columns);
67
+ return Array.from({ length: n }, (_, i) => {
68
+ /** @type {Record<string, unknown>} */
69
+ const r = {};
70
+ for (const [k, values] of entries) {
71
+ const v = values[i];
72
+ if (v === null || v === undefined) continue;
73
+ const table = tables[k];
74
+ r[k] = !table ? v : Array.isArray(v) ? v.map((j) => table[j]) : table[/** @type {number} */ (v)];
75
+ }
76
+ return r;
77
+ });
78
+ }
@@ -0,0 +1,22 @@
1
+ import type { Search, SearchOptions } from '../search.js';
2
+
3
+ /** A prebuilt index: records as columns, and how they are searched. Plain JSON. */
4
+ export interface Packed<T = Record<string, unknown>> {
5
+ /** The format's version: 1. */
6
+ v: 1;
7
+ /** How many records. */
8
+ n: number;
9
+ /** The search options, less any functions. */
10
+ options: SearchOptions<T>;
11
+ /** One column per field: each record's value, null when absent (numbers into `tables[field]` when it has one). */
12
+ columns: Record<string, unknown[]>;
13
+ /** The string tables of the columns stored as numbers. */
14
+ tables: Record<string, string[]>;
15
+ }
16
+
17
+ /** The format's version: 1. */
18
+ export const PACK_VERSION: 1;
19
+ /** Records and their search options as a JSON-ready index (function options aren't packed: pass them to `unpack`). */
20
+ export function pack<T extends object>(records: readonly T[], options: SearchOptions<T>): Packed<T>;
21
+ /** A packed index back as a ready search; throws when it is from another version of the format. */
22
+ export function unpack<T extends object>(packed: Packed<T>, options?: Partial<SearchOptions<T>>): Search<T>;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @module @arraypress/search/pack
3
+ *
4
+ * A prebuilt index: records and search options as compact JSON, built once
5
+ * (at build time, in Node) and loaded anywhere (the browser).
6
+ */
7
+
8
+ import { createSearch } from '../search.js';
9
+ import { toColumns, fromColumns } from './columns.js';
10
+
11
+ /** The format's version. */
12
+ export const PACK_VERSION = 1;
13
+
14
+ /**
15
+ * Records and their search options as a JSON-ready index. Options that are
16
+ * functions (a `plural` or `tie` of your own) aren't packed: pass them to
17
+ * `unpack` again.
18
+ *
19
+ * @template {object} T
20
+ * @param {readonly T[]} records The records (strings, numbers, lists: anything JSON holds).
21
+ * @param {import('../search.js').SearchOptions<T>} options How they are searched.
22
+ * @returns {import('./index.js').Packed<T>} The index.
23
+ */
24
+ export function pack(records, options) {
25
+ const { plural, tie, ...rest } = options;
26
+ const { columns, tables } = toColumns(/** @type {Record<string, unknown>[]} */ (/** @type {unknown} */ (records)));
27
+ return {
28
+ v: PACK_VERSION,
29
+ n: records.length,
30
+ options: { ...rest, ...(typeof plural === 'boolean' && { plural }), ...(Array.isArray(tie) && { tie }) },
31
+ columns,
32
+ tables,
33
+ };
34
+ }
35
+
36
+ /**
37
+ * A packed index back as a ready search.
38
+ *
39
+ * @template {object} T
40
+ * @param {import('./index.js').Packed<T>} packed The index (parsed JSON).
41
+ * @param {Partial<import('../search.js').SearchOptions<T>>} [options] Options to add or change (a `plural` or `tie` function, stop words).
42
+ * @returns {import('../search.js').Search<T>} The search.
43
+ * @throws {Error} When the index is from another version of the format.
44
+ */
45
+ export function unpack(packed, options = {}) {
46
+ if (packed?.v !== PACK_VERSION) throw new Error(`@arraypress/search: index version ${packed?.v}, expected ${PACK_VERSION}`);
47
+ const records = /** @type {T[]} */ (/** @type {unknown} */ (fromColumns(packed.n, packed.columns, packed.tables)));
48
+ return createSearch(records, { ...packed.options, ...options });
49
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The singular of a plain English plural ("plumbers" → "plumber", "bakeries" → "bakery"); '' when it isn't one.
3
+ * Words of three letters or fewer, and words ending in "ss", are left alone.
4
+ */
5
+ export function singular(word: string): string;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @module @arraypress/search/query/plural
3
+ *
4
+ * Plural folding for English: a plain plural's singular, so "plumbers" also
5
+ * finds "plumber" and "bakeries" finds "bakery".
6
+ */
7
+
8
+ /**
9
+ * The singular of a plain English plural: "plumbers" → "plumber",
10
+ * "bakeries" → "bakery". Words of three letters or fewer, and words ending in
11
+ * "ss", are left alone.
12
+ *
13
+ * @param {string} word The word (or phrase: its last word is folded), lower case.
14
+ * @returns {string} Its singular, or '' when it isn't a plain plural.
15
+ */
16
+ export const singular = (word) =>
17
+ word.length > 3 && /[^s]s$/.test(word) ? (word.endsWith('ies') ? `${word.slice(0, -3)}y` : word.slice(0, -1)) : '';
@@ -0,0 +1,28 @@
1
+ /** One term of a query: a word, or a synonym phrase read as one. */
2
+ export interface Term {
3
+ /** What was typed, lower case ("estate agents"). */
4
+ typed: string;
5
+ /** The phrases that count for it: what was typed, its synonym group, its singular. */
6
+ phrases: string[];
7
+ /** What was typed, at the start of a word. */
8
+ start: RegExp;
9
+ /** Each phrase as a whole word, in the order of `phrases`. */
10
+ whole: RegExp[];
11
+ }
12
+
13
+ /** How a query is read. */
14
+ export interface TermOptions {
15
+ /** Synonym lookup (`synonymMap`). */
16
+ synonyms: ReadonlyMap<string, readonly string[]>;
17
+ /** A word's singular, or '' (plural folding off: always ''). */
18
+ singular: (word: string) => string;
19
+ /** The most words a synonym phrase can have. Default 3. */
20
+ phrase?: number;
21
+ /** Single words left out of a query ("a", "the", "logo"). */
22
+ stopWords?: ReadonlySet<string>;
23
+ }
24
+
25
+ /** Synonym groups as a lookup: each phrase → every phrase of its group (a later group wins a shared phrase). */
26
+ export function synonymMap(groups?: readonly (readonly string[])[]): Map<string, readonly string[]>;
27
+ /** Reads a query as terms: lower case, synonym phrases of up to `phrase` words read as one, stop words left out. */
28
+ export function termsOf(query: string, options: TermOptions): Term[];
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @module @arraypress/search/query/terms
3
+ *
4
+ * A query read as terms. Each term is a word, or a synonym phrase of up to
5
+ * three words read as one ("estate agents"), with the phrases that count for
6
+ * it too: its synonym group and its singular.
7
+ */
8
+
9
+ import { atStart, asWord } from '../util/regex.js';
10
+
11
+ /**
12
+ * Synonym groups as a lookup: each phrase → every phrase of its group. A
13
+ * phrase in two groups belongs to the later one.
14
+ *
15
+ * @param {readonly (readonly string[])[]} [groups] The groups, lower case.
16
+ * @returns {Map<string, readonly string[]>} The lookup.
17
+ */
18
+ export function synonymMap(groups = []) {
19
+ /** @type {Map<string, readonly string[]>} */
20
+ const map = new Map();
21
+ for (const group of groups) for (const p of group) map.set(p, group);
22
+ return map;
23
+ }
24
+
25
+ /**
26
+ * Reads a query as terms. Words are split on white space and lower-cased; up
27
+ * to `phrase` words in a row are read as one term when they make a synonym
28
+ * phrase (or its plural). Single-word stop words are left out.
29
+ *
30
+ * @param {string} query What was typed.
31
+ * @param {import('./terms.js').TermOptions} options The synonyms, plural folding, phrase length and stop words.
32
+ * @returns {import('./terms.js').Term[]} The terms, in order.
33
+ */
34
+ export function termsOf(query, { synonyms, singular, phrase = 3, stopWords }) {
35
+ const groupOf = (/** @type {string} */ p) => synonyms.get(p) ?? synonyms.get(singular(p));
36
+ const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
37
+ /** @type {import('./terms.js').Term[]} */
38
+ const out = [];
39
+ for (let i = 0; i < words.length; ) {
40
+ let n = Math.min(phrase, words.length - i);
41
+ while (n > 1 && !groupOf(words.slice(i, i + n).join(' '))) n--;
42
+ const typed = words.slice(i, i + n).join(' ');
43
+ i += n;
44
+ if (n === 1 && stopWords?.has(typed)) continue;
45
+ const phrases = [...new Set([typed, ...(groupOf(typed) ?? []), singular(typed)].filter(Boolean))];
46
+ out.push({ typed, phrases, start: atStart(typed), whole: phrases.map(asWord) });
47
+ }
48
+ return out;
49
+ }
@@ -0,0 +1,48 @@
1
+ /** Record keys as a list, or space-separated. */
2
+ export type Keys = string | readonly string[];
3
+
4
+ /** What looking at a group's records changed. */
5
+ export interface ReviewEdit {
6
+ /** Records the rules found that don't belong. */
7
+ drop?: Keys;
8
+ /** Records no rule found that do: they go in at the pick score. */
9
+ add?: Keys;
10
+ /** Rules fits to hold past the `perGroup` cap. */
11
+ keep?: Keys;
12
+ }
13
+
14
+ /** The review, per group. */
15
+ export type Review = Readonly<Record<string, ReviewEdit>>;
16
+
17
+ /** How records are assigned. */
18
+ export interface AssignOptions<T> {
19
+ /** A record's key (its slug or id). */
20
+ key: (record: T) => string;
21
+ /** A record's score for a group (0 when it doesn't fit): `scoreRule` over your own rules, or your own scoring. */
22
+ score: (record: T, group: string) => number;
23
+ /** The lowest score that counts as a fit. Default 1. */
24
+ min?: number;
25
+ /** The most groups a record keeps. Default no cap. */
26
+ perRecord?: number;
27
+ /** The most rules fits a group keeps (its review keeps and adds come on top). Default no cap. */
28
+ perGroup?: number;
29
+ /** Hand picks: a record's groups, best first. These records skip the rules; each pick scores `pick`, a little more the earlier it is listed. */
30
+ picks?: Readonly<Record<string, Keys>>;
31
+ /** An add's score: above any rule's, so caps keep it. Default 1000. */
32
+ pick?: number;
33
+ /** Per group: drops, adds and keeps. */
34
+ review?: Review;
35
+ }
36
+
37
+ /** What `assign` decided. */
38
+ export interface Assigned {
39
+ /** Each group's records and scores, best first. */
40
+ byGroup: Record<string, [key: string, score: number][]>;
41
+ /** Each record that fits anything: its groups, best first, at most `perRecord`. Keys in alphabetical order. */
42
+ byRecord: Record<string, string[]>;
43
+ /** Review and pick keys that aren't records (they were skipped). */
44
+ missing: { group: string; key: string }[];
45
+ }
46
+
47
+ /** Assigns records to groups: rule scores with caps, then the review's drops and adds, then each record's best groups. */
48
+ export function assign<T>(records: readonly T[], groups: readonly string[], options: AssignOptions<T>): Assigned;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * @module @arraypress/search/rules/assign
3
+ *
4
+ * Assigning records to groups (marks to industries, products to categories)
5
+ * by rule scores, with caps, hand picks and a review.
6
+ *
7
+ * 1. Rules. Every record is scored against every group; it keeps its best
8
+ * `perRecord` groups scoring `min` or more (ties: group order). Each group
9
+ * keeps its best `perGroup` records, plus any the review says to `keep`.
10
+ * Picked records skip this pass.
11
+ * 2. Review. Per group, `drop` removes records, and `add` puts records in at
12
+ * the pick score (`pick`), above any rule's. Each record's `picks` are adds
13
+ * on those groups, scored a little higher the earlier they are listed.
14
+ * 3. Each record's groups, best first, at most `perRecord`.
15
+ */
16
+
17
+ /**
18
+ * Space-separated keys, or a list, as a list.
19
+ *
20
+ * @param {string | readonly string[] | undefined} v The keys.
21
+ * @returns {string[]} The list.
22
+ */
23
+ const keysOf = (v) => (typeof v === 'string' ? v.split(/\s+/).filter(Boolean) : [...(v ?? [])]);
24
+
25
+ /**
26
+ * The review with every pick added to its groups' `add`s (after the review's own).
27
+ *
28
+ * @param {import('./assign.js').Review} review The review.
29
+ * @param {Readonly<Record<string, readonly string[]>>} picks The picks.
30
+ * @returns {Record<string, { drop: string[], add: string[], keep: string[] }>} The review, as lists.
31
+ */
32
+ function withPicks(review, picks) {
33
+ /** @type {Record<string, { drop: string[], add: string[], keep: string[] }>} */
34
+ const out = {};
35
+ for (const [g, e] of Object.entries(review)) out[g] = { drop: keysOf(e.drop), add: keysOf(e.add), keep: keysOf(e.keep) };
36
+ for (const [key, groups] of Object.entries(picks)) for (const g of groups) (out[g] ??= { drop: [], add: [], keep: [] }).add.push(key);
37
+ return out;
38
+ }
39
+
40
+ /**
41
+ * Assigns records to groups by rule score, caps, picks and review.
42
+ *
43
+ * @template T
44
+ * @param {readonly T[]} records The records.
45
+ * @param {readonly string[]} groups The groups, in order (the tie-break).
46
+ * @param {import('./assign.js').AssignOptions<T>} options How records score, the caps, picks and review.
47
+ * @returns {import('./assign.js').Assigned} Each group's records and each record's groups, best first.
48
+ * @throws {Error} When the review names a group that isn't one.
49
+ */
50
+ export function assign(records, groups, options) {
51
+ const { key, score, min = 1, perRecord = Infinity, perGroup = Infinity, pick = 1000, review = {} } = options;
52
+ /** @type {Record<string, string[]>} */
53
+ const picks = Object.fromEntries(Object.entries(options.picks ?? {}).map(([k, v]) => [k, keysOf(v)]));
54
+ const edits = withPicks(review, picks);
55
+ // a pick scores `pick`, plus a tenth for each place it is ahead of the record's last possible group
56
+ const pickScore = (/** @type {string} */ k, /** @type {string} */ g) => {
57
+ const at = picks[k]?.indexOf(g) ?? -1;
58
+ return at < 0 ? pick : pick + ((Number.isFinite(perRecord) ? perRecord : picks[k].length) - at) / 10;
59
+ };
60
+
61
+ // 1. rules: each record its best perRecord groups, each group its best perGroup records (and its keeps)
62
+ /** @type {Record<string, [string, number][]>} */
63
+ const byGroup = Object.fromEntries(groups.map((g) => [g, []]));
64
+ for (const r of records) {
65
+ const k = key(r);
66
+ if (picks[k]) continue;
67
+ const fits = groups
68
+ .map((g, order) => ({ g, s: score(r, g), order }))
69
+ .filter((f) => f.s >= min)
70
+ .sort((a, b) => b.s - a.s || a.order - b.order)
71
+ .slice(0, perRecord);
72
+ for (const f of fits) byGroup[f.g].push([k, f.s]);
73
+ }
74
+ for (const g of groups) {
75
+ const all = byGroup[g].sort((a, b) => b[1] - a[1]);
76
+ const keep = new Set(edits[g]?.keep);
77
+ byGroup[g] = [...all.slice(0, perGroup), ...all.slice(perGroup).filter(([k]) => keep.has(k))];
78
+ }
79
+
80
+ // 2. review: drops, then adds at the pick score
81
+ const known = new Set(records.map(key));
82
+ /** @type {{ group: string, key: string }[]} */
83
+ const missing = [];
84
+ for (const [g, { drop, add, keep }] of Object.entries(edits)) {
85
+ if (!byGroup[g]) throw new Error(`review: no group "${g}"`);
86
+ for (const k of [...drop, ...add, ...keep]) if (!known.has(k)) missing.push({ group: g, key: k });
87
+ const out = new Set(drop);
88
+ const picked = new Set(add);
89
+ const list = byGroup[g].filter(([k]) => !out.has(k)).map(([k, s]) => /** @type {[string, number]} */ ([k, picked.has(k) ? pickScore(k, g) : s]));
90
+ const have = new Set(list.map(([k]) => k));
91
+ for (const k of add) if (known.has(k) && !have.has(k)) list.push([k, pickScore(k, g)]);
92
+ byGroup[g] = list.sort((a, b) => b[1] - a[1]);
93
+ }
94
+
95
+ // 3. each record's groups, best first (ties: group order)
96
+ const order = new Map(groups.map((g, i) => [g, i]));
97
+ /** @type {Map<string, [string, number][]>} */
98
+ const fits = new Map();
99
+ for (const [g, list] of Object.entries(byGroup)) for (const [k, s] of list) fits.set(k, [...(fits.get(k) ?? []), [g, s]]);
100
+ const byRecord = Object.fromEntries(
101
+ [...fits]
102
+ .sort(([a], [b]) => a.localeCompare(b))
103
+ .map(([k, list]) => [
104
+ k,
105
+ list
106
+ .sort((a, b) => b[1] - a[1] || /** @type {number} */ (order.get(a[0])) - /** @type {number} */ (order.get(b[0])))
107
+ .slice(0, perRecord)
108
+ .map(([g]) => g),
109
+ ]),
110
+ );
111
+ return { byGroup, byRecord, missing };
112
+ }
@@ -0,0 +1,4 @@
1
+ export type { Words, Rule } from './score.js';
2
+ export { found, wordScore, pointsFor, scoreRule } from './score.js';
3
+ export type { Keys, ReviewEdit, Review, AssignOptions, Assigned } from './assign.js';
4
+ export { assign } from './assign.js';
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @module @arraypress/search/rules
3
+ *
4
+ * Rule-based tagging: score records against weighted word rules, then assign
5
+ * them to groups with caps, hand picks and a review.
6
+ */
7
+
8
+ export { found, wordScore, pointsFor, scoreRule } from './score.js';
9
+ export { assign } from './assign.js';
@@ -0,0 +1,21 @@
1
+ /** Word patterns and what each is worth, strongest first: the first one found counts. */
2
+ export type Words = readonly (readonly [pattern: RegExp | string, score: number])[];
3
+
4
+ /** A rule: what makes a record fit one group. */
5
+ export interface Rule {
6
+ /** Words that veto the rule outright. */
7
+ not?: RegExp | string;
8
+ /** Word patterns and what each is worth, strongest first. */
9
+ words?: Words;
10
+ /** Points per value, per field: `{ style: { iso: 1 }, mood: { calm: 2 } }`. A list field adds each of its values. */
11
+ points?: Readonly<Record<string, Readonly<Record<string, number>>>>;
12
+ }
13
+
14
+ /** Whether a pattern is found in a text (no pattern: never; a string is read as a regular expression). */
15
+ export function found(pattern: RegExp | string | undefined, text: string): boolean;
16
+ /** The score of the first word pattern found in a text, or 0. */
17
+ export function wordScore(words: Words | undefined, text: string): number;
18
+ /** Points for values: each value adds what the table gives it. */
19
+ export function pointsFor(table: Readonly<Record<string, number>> | undefined, values: unknown): number;
20
+ /** One record's score against one rule: 0 on a veto word, else the first word pattern found plus each field's points. */
21
+ export function scoreRule(rule: Rule, text: string, record?: Readonly<Record<string, unknown>>): number;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @module @arraypress/search/rules/score
3
+ *
4
+ * Scoring one record against one rule: weighted word patterns over its text,
5
+ * plus points for the values it has, and words that veto the rule outright.
6
+ */
7
+
8
+ /**
9
+ * Whether a pattern is found in a text (no pattern: never).
10
+ *
11
+ * @param {RegExp | string | undefined} pattern The pattern (a string is read as a regular expression).
12
+ * @param {string} text The text.
13
+ * @returns {boolean} Whether it is found.
14
+ */
15
+ export const found = (pattern, text) => (pattern === undefined ? false : (typeof pattern === 'string' ? new RegExp(pattern) : pattern).test(text));
16
+
17
+ /**
18
+ * The score of the first word pattern found in a text, strongest listed first.
19
+ *
20
+ * @param {import('./score.js').Words | undefined} words The patterns and what each is worth.
21
+ * @param {string} text The text.
22
+ * @returns {number} The first found pattern's score, or 0.
23
+ */
24
+ export const wordScore = (words, text) => words?.find(([pattern]) => found(pattern, text))?.[1] ?? 0;
25
+
26
+ /**
27
+ * Points for values: each value the record has adds what the table gives it.
28
+ *
29
+ * @param {Readonly<Record<string, number>> | undefined} table Points per value.
30
+ * @param {unknown} values The record's value, or list of values.
31
+ * @returns {number} The points, added up.
32
+ */
33
+ export const pointsFor = (table, values) =>
34
+ (Array.isArray(values) ? values : [values]).reduce((s, v) => s + (table?.[String(v)] ?? 0), 0);
35
+
36
+ /**
37
+ * One record's score against one rule: 0 when the text has a veto word (`not`);
38
+ * otherwise the first word pattern found (`words`), plus the points (`points`)
39
+ * of each field's values.
40
+ *
41
+ * @param {import('./score.js').Rule} rule The rule.
42
+ * @param {string} text The record's text, as the patterns read it (lower case).
43
+ * @param {Readonly<Record<string, unknown>>} [record] The record, for `points`.
44
+ * @returns {number} The score.
45
+ */
46
+ export function scoreRule(rule, text, record = {}) {
47
+ if (found(rule.not, text)) return 0;
48
+ return Object.entries(rule.points ?? {}).reduce((s, [k, table]) => s + pointsFor(table, record[k]), wordScore(rule.words, text));
49
+ }
@@ -0,0 +1,79 @@
1
+ import type { Term } from './query/terms.js';
2
+ import type { Filter } from './filter/filter.js';
3
+ import type { Tie } from './filter/tie.js';
4
+
5
+ /** A field's scoring: a number is its weight. */
6
+ export type Field =
7
+ | number
8
+ | {
9
+ /** A phrase found in it as a whole word. Default 0. */
10
+ weight?: number;
11
+ /** A list field: a phrase that is one of its items outright. Default 0. */
12
+ exact?: number;
13
+ /** Its items are separate phrases (search words: "nail salon", "cafe"), each matched on its own. */
14
+ list?: boolean;
15
+ };
16
+
17
+ /** The fields that make a record's text, in order, with their scoring. */
18
+ export type Fields = { readonly [field: string]: Field };
19
+
20
+ /** How a search reads, matches and ranks. */
21
+ export interface SearchOptions<T> {
22
+ /** The fields searched, in order: `{ name: 4, keywords: { weight: 2.5, exact: 4, list: true }, description: 1 }`. */
23
+ fields: Fields;
24
+ /** Synonym groups, lower case: a phrase from one searches for every phrase of its group. */
25
+ synonyms?: readonly (readonly string[])[];
26
+ /** What was typed, at the start of a word of a field weighted above this, scores this. Default 2. */
27
+ prefix?: number;
28
+ /** A phrase found as a whole word anywhere in the record. Default 1. */
29
+ anywhere?: number;
30
+ /** Plural folding: English (`true`, the default), none (`false`), or a word's singular ('' for none). */
31
+ plural?: boolean | ((word: string) => string);
32
+ /** The most words a synonym phrase can have. Default 3. */
33
+ phrase?: number;
34
+ /** Single words left out of a query ("a", "the", "logo"). */
35
+ stopWords?: readonly string[];
36
+ /** Tie-breaks at an equal score: fields whose records with a value go first, or a comparison. Record order comes last. */
37
+ tie?: Tie<T>;
38
+ }
39
+
40
+ /** What one search narrows to and returns. */
41
+ export interface SearchQuery<T> {
42
+ /** Field values a record must have (a list field: contain), or a test. */
43
+ filter?: Filter<T>;
44
+ /** The most hits to return (all of them by default). */
45
+ limit?: number;
46
+ }
47
+
48
+ /** One record found. */
49
+ export interface Hit<T> {
50
+ item: T;
51
+ /** Its place in the records. */
52
+ index: number;
53
+ /** How well it answers the query (0 with no words). */
54
+ score: number;
55
+ }
56
+
57
+ /** A search's answer. */
58
+ export interface Result<T> {
59
+ /** How many records matched (more than `hits` when the limit cut it). */
60
+ total: number;
61
+ /** Best first; in record order when the query has no words. */
62
+ hits: Hit<T>[];
63
+ /** The query as read. */
64
+ terms: Term[];
65
+ }
66
+
67
+ /** A built search. */
68
+ export interface Search<T> {
69
+ readonly records: readonly T[];
70
+ /** Every record that passes the filter and has every term of the query, best first. */
71
+ search(query?: string, options?: SearchQuery<T>): Result<T>;
72
+ /** How a query is read: its terms, their synonyms and singulars. */
73
+ terms(query: string): Term[];
74
+ /** Each field's values among the records a query and filter find, with counts, most first. */
75
+ facets<K extends string>(fields: readonly K[], options?: { query?: string; filter?: Filter<T> }): Record<K, Record<string, number>>;
76
+ }
77
+
78
+ /** Builds a search over records: each is prepared once, then searched by words, filtered and ranked. */
79
+ export function createSearch<T extends object>(records: readonly T[], options: SearchOptions<T>): Search<T>;