ligatura 0.0.1 → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bret Perry
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,105 @@
1
1
  # ligatura
2
2
 
3
- Ligature tool. Placeholder release; the real package is in progress.
3
+ Find where one text quotes, paraphrases or alludes to another.
4
+
5
+ ```
6
+ npx ligatura link imitation.jsonl gospels.jsonl > links.jsonl
7
+ ```
8
+
9
+ ligatura reads two texts as units (verses, sentences, paragraphs). For each unit of the first, it
10
+ finds the passages in the second that the unit echoes, and writes them as suggestions with their
11
+ evidence. A person confirms each one: ligatura finds candidates, it doesn't decide.
12
+
13
+ **The default is free and local.** Every model runs on your machine, downloads once, and needs no
14
+ account or API key.
15
+
16
+ ## How it works
17
+
18
+ 1. **Embed** both texts (nomic-embed-text-v1.5) and score every target unit by meaning.
19
+ 2. **Overlap:** score every target unit by shared rare words. This catches quotation, which
20
+ embeddings blur. Archaic forms (followeth, walketh) fold to their stems.
21
+ 3. **Fuse** the two rankings (reciprocal rank fusion).
22
+ 4. **Rerank (opt-in):** with `--rerank`, a reranker model reads the top 30 for each unit and
23
+ scores each pair. It's off by default: the in-process Qwen3-Reranker-0.6B scored below plain
24
+ retrieval on the test fixture (see Results). A reranker server or a chat model as judge may do
25
+ better; measure it with `eval` first.
26
+
27
+ ## Install
28
+
29
+ ```
30
+ npm install -g ligatura
31
+ ```
32
+
33
+ Or run it with `npx ligatura`. Needs Node 20 or later.
34
+
35
+ ## Inputs
36
+
37
+ - **JSONL**, one unit per line: `{"ref": "MAT.5.3", "text": "Blessed are the poor in spirit..."}`.
38
+ `ref` is any stable id: a verse, a Stephanus number, `book.chapter.sentence`.
39
+ - **Plain text**, split into sentences, or paragraphs or lines with `--split`. Refs are the unit
40
+ numbers.
41
+
42
+ ## Output
43
+
44
+ One JSON object per suggested link:
45
+
46
+ ```json
47
+ {"source":"4.14.5","target":"LUK.24.35","rank":1,"score":0.792,"dense":0.792,"lex":0.295,
48
+ "shared":["breaking","bread","way"],"status":"suggested","method":{"embed":"nomic-embed-text-v1.5","rerank":"none"},"date":"2026-11-08"}
49
+ ```
50
+
51
+ `score` is the reranker's P(yes) when a reranker ran, else the embedding cosine. `--top` (default
52
+ 3) and `--min-score` decide how many links reach review.
53
+
54
+ ## Models and servers
55
+
56
+ | Stage | In process | Or a server you run |
57
+ |---|---|---|
58
+ | Embed | `nomic-embed-text-v1.5`, `bge-small-en-v1.5`, `bge-large-en-v1.5` | `--embed openai:<base-url>#<model>`: any OpenAI-compatible `/embeddings` (Ollama, LM Studio, llama.cpp, vLLM) |
59
+ | Rerank | off by default; `--rerank qwen3-reranker-0.6b` | `--rerank api:<url>#<model>`: a Cohere/Jina-shaped `/rerank` endpoint (llama.cpp `llama-server`, vLLM, TEI, Infinity) |
60
+ | Judge | – | `--rerank chat:<base-url>#<model>`: a chat model picks the links (Ollama, LM Studio, `mlx_lm.server`) |
61
+
62
+ - **Speed:** the in-process reranker runs on the CPU at 0.5 to 0.75 s per pair. A 2,400-sentence
63
+ book at `--pool 30` takes many hours. A reranker server on a GPU is much faster: the same model
64
+ under MLX on an Apple M3 took 0.17 s per pair. Lowering `--pool` trades recall for time.
65
+ - **Keys:** a server that needs one gets `LIGATURA_API_KEY` as a bearer token.
66
+ - **Paid APIs bill your API account, not a chat subscription** such as Claude Pro or ChatGPT Plus.
67
+ ligatura has no paid backend built in. If you point it at one, you pay that provider per call.
68
+
69
+ ## Measure before you trust it
70
+
71
+ ```
72
+ ligatura eval fixtures/kempis-gospels --neighbours 1
73
+ ```
74
+
75
+ `eval` runs the pipeline on a fixture (`source.jsonl`, `target.jsonl`, and `gold.jsonl` of known
76
+ links) and prints recall at 1, 3, 5 and 10. Try a model change here before using it on new texts.
77
+
78
+ The fixture in this repo is The Imitation of Christ against the four Gospels, scored against the
79
+ edition's own footnotes (49 links; see `fixtures/kempis-gospels/README.md`):
80
+
81
+ | Stage | R@1 | R@3 | R@5 | R@10 |
82
+ |---|---|---|---|---|
83
+ | Retrieval (embedding + overlap) | 35 | 40 | 42 | 44 |
84
+ | Reranked, `--rerank qwen3-reranker-0.6b` (ONNX q8, generic task) | 28 | 38 | 43 | 44 |
85
+ | Reranked, q8, `--task` naming the two texts | 29 | 41 | 43 | 44 |
86
+
87
+ Recall counts a link found when the gold verse or its neighbour is in the top k (`--neighbours 1`),
88
+ of 49. Runs from 2026-11-08 on an Apple M3 CPU; the q8 reranker took 710 to 980 s for the 1,320 pairs.
89
+
90
+ Reranking costs R@1 here, so it's off by default. The cause is the q8 quantization of the only
91
+ ONNX export: an fp32 export of the same model, built locally, scores pairs within 0.006 of the
92
+ Python (MLX) run but still reached only 32 / 43 / 44 / 45.
93
+
94
+ ## Limits
95
+
96
+ - **English first.** The overlap scorer and the default embedder are English. Greek and Latin
97
+ need a multilingual embedder (`--embed openai:...`).
98
+ - **Allusions to a whole story** (the prodigal son), or sayings with no shared words, rarely reach
99
+ the top 30, so no reranker sees them.
100
+ - **Suggestions, not findings.** Check every link against the texts.
101
+
102
+ ## License
103
+
104
+ MIT. The fixture texts are public domain. Models download from Hugging Face under their own
105
+ licenses; check each model card before redistributing a model.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import { readFileSync } from 'node:fs';
4
+ import { LOCAL_EMBEDDERS } from './embed.js';
5
+ import { evaluate, formatEval } from './eval.js';
6
+ import { link, toLinks, DEFAULTS } from './link.js';
7
+ import { loadUnits } from './units.js';
8
+ import { setQuiet } from './util.js';
9
+ const HELP = `ligatura: find where one text quotes, paraphrases or alludes to another.
10
+
11
+ Usage
12
+ ligatura link <source> <target> [options] > links.jsonl
13
+ ligatura eval <fixture-dir> [options]
14
+
15
+ Inputs are JSONL ({"ref", "text"} per line) or plain text, split into sentences.
16
+ Every link is a suggestion for a person to confirm.
17
+
18
+ Options
19
+ --embed <model> ${Object.keys(LOCAL_EMBEDDERS).join(', ')} (in process; default ${DEFAULTS.embed}),
20
+ or openai:<base-url>#<model> for any OpenAI-compatible server
21
+ --rerank <model> none (default), qwen3-reranker-0.6b (in process), api:<url>#<model> for a
22
+ /v1/rerank server, or chat:<base-url>#<model> for a chat model as judge
23
+ --pool <n> candidates the reranker reads per unit (default ${DEFAULTS.pool})
24
+ --top <n> links kept per source unit (default 3)
25
+ --min-score <x> keep only links scoring at least x (reranker P(yes), or embedding cosine)
26
+ --task <text> the instruction the reranker reads
27
+ --split <mode> for plain text: sentence (default), paragraph or line
28
+ --neighbours <n> eval: also count a target n units away from the gold one (default 0)
29
+ --json eval: print JSON
30
+ --quiet no progress on stderr
31
+
32
+ Defaults run locally and need no account. Models download on first use and are cached.
33
+ API keys for remote servers come from LIGATURA_API_KEY.`;
34
+ const { values, positionals } = parseArgs({
35
+ allowPositionals: true,
36
+ options: {
37
+ embed: { type: 'string' }, rerank: { type: 'string' }, pool: { type: 'string' }, top: { type: 'string' },
38
+ 'min-score': { type: 'string' }, task: { type: 'string' }, split: { type: 'string' }, neighbours: { type: 'string' },
39
+ json: { type: 'boolean' }, quiet: { type: 'boolean' }, help: { type: 'boolean', short: 'h' }, version: { type: 'boolean', short: 'v' },
40
+ },
41
+ });
42
+ const num = (s) => (s === undefined ? undefined : Number(s));
43
+ const [cmd, ...args] = positionals;
44
+ setQuiet(!!values.quiet);
45
+ const opts = { embed: values.embed, rerank: values.rerank, pool: num(values.pool), task: values.task };
46
+ if (values.version) {
47
+ console.log(JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version);
48
+ }
49
+ else if (values.help || !cmd) {
50
+ console.log(HELP);
51
+ }
52
+ else if (cmd === 'link' && args.length === 2) {
53
+ const split = (values.split ?? 'auto');
54
+ const source = loadUnits(args[0], split);
55
+ const target = loadUnits(args[1], split);
56
+ for await (const r of link(source, target, opts)) {
57
+ for (const l of toLinks(r, target, { ...opts, top: num(values.top) ?? 3, minScore: num(values['min-score']) }))
58
+ process.stdout.write(`${JSON.stringify(l)}\n`);
59
+ }
60
+ }
61
+ else if (cmd === 'eval' && args.length === 1) {
62
+ const e = await evaluate(args[0], { ...opts, neighbours: num(values.neighbours) });
63
+ console.log(values.json ? JSON.stringify(e, null, 1) : formatEval(e));
64
+ }
65
+ else {
66
+ console.error(HELP);
67
+ process.exit(2);
68
+ }
@@ -0,0 +1,15 @@
1
+ export interface Embedder {
2
+ name: string;
3
+ embed(texts: string[], role: 'query' | 'doc'): Promise<Float32Array[]>;
4
+ }
5
+ interface LocalModel {
6
+ id: string;
7
+ pooling: 'cls' | 'mean' | 'last_token';
8
+ query?: string;
9
+ doc?: string;
10
+ }
11
+ export declare const LOCAL_EMBEDDERS: Record<string, LocalModel>;
12
+ export declare const DEFAULT_EMBEDDER = "nomic-embed-text-v1.5";
13
+ export declare function makeEmbedder(spec: string): Embedder;
14
+ export declare const auth: () => Record<string, string>;
15
+ export {};
package/dist/embed.js ADDED
@@ -0,0 +1,84 @@
1
+ // Embedding backends. Local models run in process (transformers.js, ONNX); `openai:` calls any
2
+ // OpenAI-compatible /v1/embeddings server (Ollama, LM Studio, llama.cpp, vLLM).
3
+ import { createHash } from 'node:crypto';
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { cacheDir, log } from './util.js';
7
+ // Query and document prefixes follow each model card.
8
+ export const LOCAL_EMBEDDERS = {
9
+ 'nomic-embed-text-v1.5': { id: 'nomic-ai/nomic-embed-text-v1.5', pooling: 'mean', query: 'search_query: ', doc: 'search_document: ' },
10
+ 'bge-small-en-v1.5': { id: 'Xenova/bge-small-en-v1.5', pooling: 'cls' },
11
+ 'bge-large-en-v1.5': { id: 'Xenova/bge-large-en-v1.5', pooling: 'cls', query: 'Represent this sentence for searching relevant passages: ' },
12
+ };
13
+ export const DEFAULT_EMBEDDER = 'nomic-embed-text-v1.5';
14
+ export function makeEmbedder(spec) {
15
+ const inner = spec.startsWith('openai:') ? openaiEmbedder(spec) : localEmbedder(spec);
16
+ return { name: inner.name, embed: (texts, role) => cached(inner, texts, role) };
17
+ }
18
+ // Vectors are cached per model, role and exact input, so a rerun embeds nothing.
19
+ async function cached(e, texts, role) {
20
+ const key = createHash('sha1').update(`${e.name}\0${role}\0${texts.join('\0')}`).digest('hex');
21
+ const dir = join(cacheDir(), 'vectors');
22
+ const file = join(dir, `${key}.json`);
23
+ if (existsSync(file))
24
+ return JSON.parse(readFileSync(file, 'utf8')).map((v) => Float32Array.from(v));
25
+ const out = await e.embed(texts, role);
26
+ mkdirSync(dir, { recursive: true });
27
+ writeFileSync(file, JSON.stringify(out.map((v) => Array.from(v))));
28
+ return out;
29
+ }
30
+ function localEmbedder(name) {
31
+ const cfg = LOCAL_EMBEDDERS[name];
32
+ if (!cfg)
33
+ throw new Error(`unknown embedder "${name}"; local models: ${Object.keys(LOCAL_EMBEDDERS).join(', ')}; or openai:<base-url>#<model>`);
34
+ let pipe;
35
+ return {
36
+ name,
37
+ async embed(texts, role) {
38
+ if (!pipe) {
39
+ const { pipeline } = await import('@huggingface/transformers');
40
+ pipe = (await pipeline('feature-extraction', cfg.id, { dtype: 'q8' }));
41
+ }
42
+ const prefix = cfg[role] ?? '';
43
+ const out = [];
44
+ for (let i = 0; i < texts.length; i += 32) {
45
+ const t = await pipe(texts.slice(i, i + 32).map((x) => prefix + x), { pooling: cfg.pooling, normalize: true });
46
+ out.push(...t.tolist().map((v) => Float32Array.from(v)));
47
+ log(`embed ${name} ${role}: ${Math.min(i + 32, texts.length)}/${texts.length}`, true);
48
+ }
49
+ return out;
50
+ },
51
+ };
52
+ }
53
+ // openai:http://localhost:11434/v1#nomic-embed-text sends {model, input} to <base>/embeddings.
54
+ // A key, if the server needs one, comes from LIGATURA_API_KEY.
55
+ function openaiEmbedder(spec) {
56
+ const [base, model] = spec.slice('openai:'.length).split('#');
57
+ if (!base || !model)
58
+ throw new Error('openai embedder needs openai:<base-url>#<model>');
59
+ return {
60
+ name: spec,
61
+ async embed(texts) {
62
+ const out = [];
63
+ for (let i = 0; i < texts.length; i += 64) {
64
+ const res = await fetch(`${base.replace(/\/$/, '')}/embeddings`, {
65
+ method: 'POST',
66
+ headers: { 'content-type': 'application/json', ...auth() },
67
+ body: JSON.stringify({ model, input: texts.slice(i, i + 64) }),
68
+ });
69
+ if (!res.ok)
70
+ throw new Error(`${base}/embeddings: ${res.status} ${await res.text()}`);
71
+ const body = (await res.json());
72
+ for (const d of body.data.sort((a, b) => a.index - b.index))
73
+ out.push(normalize(Float32Array.from(d.embedding)));
74
+ log(`embed ${model}: ${Math.min(i + 64, texts.length)}/${texts.length}`, true);
75
+ }
76
+ return out;
77
+ },
78
+ };
79
+ }
80
+ export const auth = () => (process.env.LIGATURA_API_KEY ? { authorization: `Bearer ${process.env.LIGATURA_API_KEY}` } : {});
81
+ function normalize(v) {
82
+ const n = Math.hypot(...v) || 1;
83
+ return v.map((x) => x / n);
84
+ }
package/dist/eval.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { type LinkOptions } from './link.js';
2
+ export interface EvalOptions extends LinkOptions {
3
+ neighbours?: number;
4
+ }
5
+ export declare function evaluate(dir: string, opts?: EvalOptions): Promise<{
6
+ gold: number;
7
+ scored: number;
8
+ missingTargets: string[];
9
+ fused: {
10
+ [k: string]: number;
11
+ };
12
+ final: {
13
+ [k: string]: number;
14
+ };
15
+ rows: {
16
+ source: string;
17
+ target: string;
18
+ fused: number;
19
+ final: number;
20
+ }[];
21
+ method: {
22
+ embed: string;
23
+ rerank: string;
24
+ pool: number;
25
+ neighbours: number;
26
+ };
27
+ }>;
28
+ export declare function formatEval(e: Awaited<ReturnType<typeof evaluate>>): string;
package/dist/eval.js ADDED
@@ -0,0 +1,59 @@
1
+ // Recall against a gold set: known links between two texts. A fixture directory holds
2
+ // source.jsonl and target.jsonl (units) and gold.jsonl ({"source", "target"} per line).
3
+ import { readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { link, DEFAULTS } from './link.js';
6
+ import { loadUnits } from './units.js';
7
+ const KS = [1, 3, 5, 10];
8
+ export async function evaluate(dir, opts = {}) {
9
+ const source = loadUnits(join(dir, 'source.jsonl'));
10
+ const target = loadUnits(join(dir, 'target.jsonl'));
11
+ const tIndex = new Map(target.map((u, i) => [u.ref, i]));
12
+ const gold = readFileSync(join(dir, 'gold.jsonl'), 'utf8').split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l));
13
+ const missing = gold.filter((g) => !tIndex.has(g.target));
14
+ const usable = gold.filter((g) => tIndex.has(g.target));
15
+ const wanted = new Set(usable.map((g) => g.source));
16
+ const units = source.filter((u) => wanted.has(u.ref));
17
+ const near = opts.neighbours ?? 0;
18
+ const rows = [];
19
+ for await (const r of link(units, target, opts)) {
20
+ const finalRank = new Map(r.ranked.map((c, k) => [c.index, k + 1]));
21
+ for (const g of usable.filter((x) => x.source === r.source.ref)) {
22
+ const t = tIndex.get(g.target);
23
+ const hits = [...Array(2 * near + 1).keys()].map((d) => t + d - near).filter((j) => j >= 0 && j < target.length);
24
+ rows.push({
25
+ source: g.source,
26
+ target: g.target,
27
+ fused: Math.min(...hits.map((j) => r.fusedRank[j])),
28
+ // Outside the pool, a target keeps its fused rank.
29
+ final: Math.min(...hits.map((j) => finalRank.get(j) ?? r.fusedRank[j])),
30
+ });
31
+ }
32
+ }
33
+ const recall = (key, k) => rows.filter((x) => x[key] <= k).length;
34
+ return {
35
+ gold: gold.length,
36
+ scored: rows.length,
37
+ missingTargets: missing.map((g) => g.target),
38
+ fused: Object.fromEntries(KS.map((k) => [k, recall('fused', k)])),
39
+ final: Object.fromEntries(KS.map((k) => [k, recall('final', k)])),
40
+ rows,
41
+ method: { embed: opts.embed ?? DEFAULTS.embed, rerank: opts.rerank ?? DEFAULTS.rerank, pool: opts.pool ?? DEFAULTS.pool, neighbours: near },
42
+ };
43
+ }
44
+ export function formatEval(e) {
45
+ const head = `| Stage | ${KS.map((k) => `R@${k}`).join(' | ')} |`;
46
+ const line = (name, r) => `| ${name} | ${KS.map((k) => r[k]).join(' | ')} |`;
47
+ const misses = e.rows.filter((r) => r.final > 10).sort((a, b) => a.final - b.final);
48
+ return [
49
+ `Gold links: ${e.scored} scored of ${e.gold}${e.missingTargets.length ? ` (${e.missingTargets.length} point at targets not in target.jsonl)` : ''}. Neighbours counted: ${e.method.neighbours}.`,
50
+ `Embed ${e.method.embed}, rerank ${e.method.rerank} on the top ${e.method.pool}.`,
51
+ '',
52
+ head,
53
+ `|---|${KS.map(() => '---|').join('')}`,
54
+ line('Retrieval (embedding + overlap)', e.fused),
55
+ ...(e.method.rerank === 'none' ? [] : [line('Reranked', e.final)]),
56
+ '',
57
+ ...(misses.length ? [`Outside the top 10: ${misses.map((m) => `${m.source} → ${m.target} (${m.final})`).join('; ')}`] : []),
58
+ ].join('\n');
59
+ }
@@ -0,0 +1,6 @@
1
+ export { link, toLinks, DEFAULTS, type Link, type LinkOptions, type UnitResult } from './link.js';
2
+ export { evaluate, formatEval } from './eval.js';
3
+ export { loadUnits, splitText, type Unit } from './units.js';
4
+ export { makeEmbedder, LOCAL_EMBEDDERS } from './embed.js';
5
+ export { makeReranker } from './rerank.js';
6
+ export { lexicalScorer, sharedWords } from './lexical.js';
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { link, toLinks, DEFAULTS } from './link.js';
2
+ export { evaluate, formatEval } from './eval.js';
3
+ export { loadUnits, splitText } from './units.js';
4
+ export { makeEmbedder, LOCAL_EMBEDDERS } from './embed.js';
5
+ export { makeReranker } from './rerank.js';
6
+ export { lexicalScorer, sharedWords } from './lexical.js';
@@ -0,0 +1,5 @@
1
+ export declare function words(s: string): Set<string>;
2
+ export declare function sharedWords(a: string, b: string): string[];
3
+ export declare function lexicalScorer(docs: string[]): {
4
+ scores: (query: string) => Float32Array<ArrayBuffer>;
5
+ };
@@ -0,0 +1,34 @@
1
+ // Word overlap weighted by rarity (tf-idf cosine). Catches quotation, which embeddings blur.
2
+ // The stop list and stem fold archaic English (thou, hath, followeth), common in older texts.
3
+ const STOP = new Set('the and of to that in a is he for it not be his they all which with as but i you thou thee thy shall unto this them me my him are have will by from or so what was on no who when if there their we then do hath doth'.split(' '));
4
+ export function words(s) {
5
+ return new Set(s.toLowerCase().match(/[a-z]+/g)?.filter((w) => w.length > 2 && !STOP.has(w)) ?? []);
6
+ }
7
+ const stem = (w) => w.replace(/(eth|est|ed|ing|s)$/, '');
8
+ const stems = (s) => [...words(s)].map(stem).filter((w) => w.length > 2);
9
+ export function sharedWords(a, b) {
10
+ const wb = words(b);
11
+ return [...words(a)].filter((w) => wb.has(w));
12
+ }
13
+ // Document frequencies come from the target texts; scores are cosines in [0, 1].
14
+ export function lexicalScorer(docs) {
15
+ const df = new Map();
16
+ for (const d of docs)
17
+ for (const w of new Set(stems(d)))
18
+ df.set(w, (df.get(w) ?? 0) + 1);
19
+ const idf = (w) => Math.log((docs.length + 1) / ((df.get(w) ?? 0) + 1));
20
+ const vec = (s) => {
21
+ const v = new Map();
22
+ for (const w of stems(s))
23
+ v.set(w, (v.get(w) ?? 0) + idf(w));
24
+ return { v, norm: Math.hypot(...v.values()) || 1 };
25
+ };
26
+ const cos = (a, b) => {
27
+ let sum = 0;
28
+ for (const [w, x] of a.v)
29
+ sum += x * (b.v.get(w) ?? 0);
30
+ return sum / (a.norm * b.norm);
31
+ };
32
+ const targets = docs.map(vec);
33
+ return { scores: (query) => { const q = vec(query); return Float32Array.from(targets, (t) => cos(q, t)); } };
34
+ }
package/dist/link.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ import type { Unit } from './units.js';
2
+ export interface LinkOptions {
3
+ embed?: string;
4
+ rerank?: string;
5
+ task?: string;
6
+ pool?: number;
7
+ }
8
+ export interface Scored {
9
+ index: number;
10
+ dense: number;
11
+ lex: number;
12
+ fusedRank: number;
13
+ rerank?: number;
14
+ }
15
+ export interface UnitResult {
16
+ source: Unit;
17
+ ranked: Scored[];
18
+ fusedRank: Int32Array;
19
+ }
20
+ export declare const DEFAULTS: {
21
+ embed: string;
22
+ rerank: string;
23
+ pool: number;
24
+ };
25
+ export declare function link(source: Unit[], target: Unit[], opts?: LinkOptions): AsyncGenerator<UnitResult>;
26
+ export interface Link {
27
+ source: string;
28
+ target: string;
29
+ rank: number;
30
+ score: number;
31
+ dense: number;
32
+ lex: number;
33
+ rerank?: number;
34
+ shared: string[];
35
+ status: 'suggested';
36
+ method: {
37
+ embed: string;
38
+ rerank: string;
39
+ };
40
+ date: string;
41
+ }
42
+ export declare function toLinks(r: UnitResult, target: Unit[], opts: LinkOptions & {
43
+ top: number;
44
+ minScore?: number;
45
+ }): Link[];
package/dist/link.js ADDED
@@ -0,0 +1,60 @@
1
+ // The pipeline: embed both texts, score every target unit two ways (embedding similarity and word
2
+ // overlap), fuse the two rankings, then let a reranker read the top of the fused list.
3
+ import { makeEmbedder, DEFAULT_EMBEDDER } from './embed.js';
4
+ import { lexicalScorer, sharedWords } from './lexical.js';
5
+ import { makeReranker, DEFAULT_RERANKER } from './rerank.js';
6
+ import { dot, fuse, log, ranks, topK } from './util.js';
7
+ export const DEFAULTS = { embed: DEFAULT_EMBEDDER, rerank: DEFAULT_RERANKER, pool: 30 };
8
+ export async function* link(source, target, opts = {}) {
9
+ const embedder = makeEmbedder(opts.embed ?? DEFAULTS.embed);
10
+ const reranker = makeReranker(opts.rerank ?? DEFAULTS.rerank, opts.task);
11
+ const pool = opts.pool ?? DEFAULTS.pool;
12
+ log(`embedding ${target.length} target and ${source.length} source units with ${embedder.name}`);
13
+ const tVec = await embedder.embed(target.map((u) => u.text), 'doc');
14
+ const sVec = await embedder.embed(source.map((u) => u.text), 'query');
15
+ const lex = lexicalScorer(target.map((u) => u.text));
16
+ const started = Date.now();
17
+ for (const [i, s] of source.entries()) {
18
+ const dense = Float32Array.from(tVec, (t) => dot(sVec[i], t));
19
+ const lexical = lex.scores(s.text);
20
+ const fused = fuse(dense, lexical);
21
+ const fusedRank = ranks(fused);
22
+ const top = topK(fused, pool);
23
+ const ranked = top.map((j) => ({ index: j, dense: dense[j], lex: lexical[j], fusedRank: fusedRank[j] }));
24
+ if (reranker) {
25
+ // Candidates go to the reranker in target order, so the fused rank can't lead it.
26
+ const ordered = [...ranked].sort((a, b) => a.index - b.index);
27
+ const scores = await reranker.score(s, ordered.map((c) => target[c.index]));
28
+ ordered.forEach((c, k) => { c.rerank = scores[k]; });
29
+ ranked.sort((a, b) => b.rerank - a.rerank || a.fusedRank - b.fusedRank);
30
+ }
31
+ if (reranker) {
32
+ const each = (Date.now() - started) / (i + 1);
33
+ log(`rerank ${reranker.name}: ${i + 1}/${source.length}, about ${Math.round((each * (source.length - i - 1)) / 60000)} min left`, true);
34
+ }
35
+ yield { source: s, ranked, fusedRank };
36
+ }
37
+ if (reranker)
38
+ log(`reranked ${source.length} units in ${Math.round((Date.now() - started) / 1000)} s`);
39
+ }
40
+ // The links a person reviews: the top n of each pool, at or above a score if one is set.
41
+ export function toLinks(r, target, opts) {
42
+ const date = new Date().toISOString().slice(0, 10);
43
+ const round = (x) => Math.round(x * 1000) / 1000;
44
+ return r.ranked
45
+ .slice(0, opts.top)
46
+ .filter((c) => opts.minScore === undefined || (c.rerank ?? c.dense) >= opts.minScore)
47
+ .map((c, k) => ({
48
+ source: r.source.ref,
49
+ target: target[c.index].ref,
50
+ rank: k + 1,
51
+ score: round(c.rerank ?? c.dense),
52
+ dense: round(c.dense),
53
+ lex: round(c.lex),
54
+ ...(c.rerank === undefined ? {} : { rerank: round(c.rerank) }),
55
+ shared: sharedWords(r.source.text, target[c.index].text),
56
+ status: 'suggested',
57
+ method: { embed: opts.embed ?? DEFAULTS.embed, rerank: opts.rerank ?? DEFAULTS.rerank },
58
+ date,
59
+ }));
60
+ }
@@ -0,0 +1,8 @@
1
+ import type { Unit } from './units.js';
2
+ export interface Reranker {
3
+ name: string;
4
+ score(query: Unit, candidates: Unit[]): Promise<number[]>;
5
+ }
6
+ export declare const DEFAULT_RERANKER = "none";
7
+ export declare const DEFAULT_TASK = "Given a passage from one text, decide whether it quotes, paraphrases or alludes to this passage from another text";
8
+ export declare function makeReranker(spec: string, task?: string): Reranker | undefined;
package/dist/rerank.js ADDED
@@ -0,0 +1,122 @@
1
+ // Rerank backends read each source unit with its candidate pool and score every candidate.
2
+ // qwen3-reranker-0.6b in process (transformers.js, ONNX q8); no server, slowest
3
+ // api:<url>#<model> a Cohere/Jina-shaped rerank endpoint: llama.cpp llama-server, vLLM, TEI, Infinity
4
+ // chat:<base-url>#<model> an OpenAI-compatible chat model as judge: Ollama, LM Studio, mlx_lm.server
5
+ // none keep the retrieval order (default)
6
+ import { auth } from './embed.js';
7
+ import { log } from './util.js';
8
+ // The q8 ONNX reranker scored below plain retrieval on the Kempis fixture (README, Results), so
9
+ // reranking is opt-in until a model beats retrieval there.
10
+ export const DEFAULT_RERANKER = 'none';
11
+ export const DEFAULT_TASK = 'Given a passage from one text, decide whether it quotes, paraphrases or alludes to this passage from another text';
12
+ export function makeReranker(spec, task = DEFAULT_TASK) {
13
+ if (spec === 'none')
14
+ return undefined;
15
+ if (spec === 'qwen3-reranker-0.6b')
16
+ return qwenLocal('onnx-community/Qwen3-Reranker-0.6B-ONNX', spec, task);
17
+ if (spec.startsWith('api:'))
18
+ return rerankApi(spec, task);
19
+ if (spec.startsWith('chat:'))
20
+ return chatJudge(spec, task);
21
+ throw new Error(`unknown reranker "${spec}"; use qwen3-reranker-0.6b, api:<url>#<model>, chat:<base-url>#<model> or none`);
22
+ }
23
+ // Qwen3-Reranker scores a pair as P("yes") for the next token after its fixed prompt.
24
+ const QWEN_PREFIX = '<|im_start|>system\nJudge whether the Document meets the requirements based on the Query and the Instruct provided. Note that the answer can only be "yes" or "no".<|im_end|>\n<|im_start|>user\n';
25
+ const QWEN_SUFFIX = '<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n';
26
+ function qwenLocal(id, name, task) {
27
+ let tok;
28
+ let model;
29
+ const batch = Number(process.env.LIGATURA_BATCH ?? 4);
30
+ return {
31
+ name,
32
+ async score(query, candidates) {
33
+ if (!tok || !model) {
34
+ const t = await import('@huggingface/transformers');
35
+ tok = (await t.AutoTokenizer.from_pretrained(id));
36
+ model = (await t.AutoModelForCausalLM.from_pretrained(id, { dtype: 'q8' }));
37
+ }
38
+ const [yes] = tok.encode('yes', { add_special_tokens: false });
39
+ const [no] = tok.encode('no', { add_special_tokens: false });
40
+ const texts = candidates.map((c) => `${QWEN_PREFIX}<Instruct>: ${task}\n<Query>: ${query.text}\n<Document>: ${c.text}${QWEN_SUFFIX}`);
41
+ const out = [];
42
+ for (let i = 0; i < texts.length; i += batch) {
43
+ const enc = tok(texts.slice(i, i + batch), { padding: true });
44
+ const { logits } = await model(enc);
45
+ const [rows, len, vocab] = logits.dims;
46
+ const mask = enc.attention_mask;
47
+ for (let r = 0; r < rows; r++) {
48
+ // The last real token of each row, whichever side the tokenizer pads.
49
+ let last = len - 1;
50
+ while (last > 0 && Number(mask.data[r * len + last]) === 0)
51
+ last--;
52
+ const base = (r * len + last) * vocab;
53
+ out.push(1 / (1 + Math.exp(logits.data[base + no] - logits.data[base + yes])));
54
+ }
55
+ }
56
+ return out;
57
+ },
58
+ };
59
+ }
60
+ // api:http://localhost:8080/v1/rerank#qwen3-reranker sends {model, query, documents} and reads
61
+ // results[].{index, relevance_score}, the shape Cohere, Jina, llama.cpp and vLLM share.
62
+ function rerankApi(spec, task) {
63
+ const [url, model] = spec.slice('api:'.length).split('#');
64
+ if (!url)
65
+ throw new Error('api reranker needs api:<url>#<model>');
66
+ return {
67
+ name: spec,
68
+ async score(query, candidates) {
69
+ const res = await fetch(url, {
70
+ method: 'POST',
71
+ headers: { 'content-type': 'application/json', ...auth() },
72
+ body: JSON.stringify({ model, query: `${task}\n${query.text}`, documents: candidates.map((c) => c.text) }),
73
+ });
74
+ if (!res.ok)
75
+ throw new Error(`${url}: ${res.status} ${await res.text()}`);
76
+ const body = (await res.json());
77
+ const out = new Array(candidates.length).fill(0);
78
+ for (const r of body.results)
79
+ out[r.index] = r.relevance_score;
80
+ return out;
81
+ },
82
+ };
83
+ }
84
+ const JUDGE = (task, query, candidates) => `${task}.
85
+
86
+ Below is a passage and candidate passages, listed in their own order, not by relevance. Pick the candidates the passage quotes, paraphrases, or alludes to: it takes their words, image, story or teaching. A shared common word or a general theme is NOT enough. Judge only from the texts given.
87
+
88
+ Return up to 5 refs, strongest first, or an empty list. Only use refs from the candidates. Reply with JSON only: {"refs": ["<ref>"], "why": "one short line per ref, ';'-separated, naming quote/paraphrase/allusion"}
89
+
90
+ Passage: ${query.text}
91
+
92
+ Candidates:
93
+ ${candidates.map((c) => `${c.ref}: ${c.text}`).join('\n')}`;
94
+ // chat:http://localhost:11434/v1#qwen3:8b asks the model to pick; picks score 1, 0.99, ...; the
95
+ // rest 0. Candidates go in their target order so retrieval rank doesn't lead the model.
96
+ function chatJudge(spec, task) {
97
+ const [base, model] = spec.slice('chat:'.length).split('#');
98
+ if (!base || !model)
99
+ throw new Error('chat judge needs chat:<base-url>#<model>');
100
+ return {
101
+ name: spec,
102
+ async score(query, candidates) {
103
+ const res = await fetch(`${base.replace(/\/$/, '')}/chat/completions`, {
104
+ method: 'POST',
105
+ headers: { 'content-type': 'application/json', ...auth() },
106
+ body: JSON.stringify({ model, temperature: 0, messages: [{ role: 'user', content: JUDGE(task, query, candidates) }] }),
107
+ });
108
+ if (!res.ok)
109
+ throw new Error(`${base}/chat/completions: ${res.status} ${await res.text()}`);
110
+ const body = (await res.json());
111
+ const text = body.choices[0]?.message.content.replace(/<think>[\s\S]*?<\/think>/, '') ?? '';
112
+ let refs = [];
113
+ try {
114
+ refs = JSON.parse(text.match(/\{[\s\S]*\}/)?.[0] ?? '{}').refs ?? [];
115
+ }
116
+ catch {
117
+ log(`judge: unreadable reply for ${query.ref}`);
118
+ }
119
+ return candidates.map((c) => { const k = refs.indexOf(c.ref); return k < 0 ? 0 : 1 - k / 100; });
120
+ },
121
+ };
122
+ }
@@ -0,0 +1,7 @@
1
+ export interface Unit {
2
+ ref: string;
3
+ text: string;
4
+ }
5
+ export type Split = 'auto' | 'sentence' | 'paragraph' | 'line';
6
+ export declare function loadUnits(path: string, split?: Split): Unit[];
7
+ export declare function splitText(raw: string, split?: Split): string[];
package/dist/units.js ADDED
@@ -0,0 +1,27 @@
1
+ // Units are the pieces a text is linked by: a verse, a sentence, a paragraph. Each has a stable ref.
2
+ import { readFileSync } from 'node:fs';
3
+ // JSONL ({"ref", "text"} per line) loads as is. Plain text splits into units numbered from 1:
4
+ // "auto" means sentences.
5
+ export function loadUnits(path, split = 'auto') {
6
+ const raw = readFileSync(path, 'utf8');
7
+ if (path.endsWith('.jsonl')) {
8
+ return raw.split('\n').filter((l) => l.trim()).map((l, i) => {
9
+ const o = JSON.parse(l);
10
+ const ref = o.ref ?? o.id;
11
+ if (ref === undefined || typeof o.text !== 'string')
12
+ throw new Error(`${path}:${i + 1}: each line needs "ref" and "text"`);
13
+ return { ref: String(ref), text: o.text };
14
+ });
15
+ }
16
+ return splitText(raw, split).map((text, i) => ({ ref: String(i + 1), text }));
17
+ }
18
+ export function splitText(raw, split = 'auto') {
19
+ const flat = (s) => s.replace(/\s+/g, ' ').trim();
20
+ if (split === 'line')
21
+ return raw.split('\n').map(flat).filter(Boolean);
22
+ const paras = raw.split(/\n\s*\n/).map(flat).filter(Boolean);
23
+ if (split === 'paragraph')
24
+ return paras;
25
+ // A sentence ends at . ; : ? or ! followed by a space and a capital or an opening quote.
26
+ return paras.flatMap((p) => p.split(/(?<=[.;:?!])\s+(?=[A-Z“"'‘])/)).map(flat).filter((s) => s.split(' ').length >= 3);
27
+ }
package/dist/util.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ export declare const cacheDir: () => string;
2
+ export declare const setQuiet: (q: boolean) => void;
3
+ export declare function log(msg: string, progress?: boolean): void;
4
+ export declare const dot: (a: Float32Array, b: Float32Array) => number;
5
+ export declare function ranks(scores: Float32Array): Int32Array;
6
+ export declare const RRF_K = 60;
7
+ export declare function fuse(...lists: Float32Array[]): Float32Array;
8
+ export declare const topK: (scores: Float32Array, k: number) => number[];
package/dist/util.js ADDED
@@ -0,0 +1,34 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ export const cacheDir = () => process.env.LIGATURA_CACHE ?? join(process.env.XDG_CACHE_HOME ?? join(homedir(), '.cache'), 'ligatura');
4
+ let quiet = false;
5
+ export const setQuiet = (q) => { quiet = q; };
6
+ // Progress goes to stderr so stdout stays clean JSONL. Progress lines overwrite each other on a TTY.
7
+ export function log(msg, progress = false) {
8
+ if (quiet)
9
+ return;
10
+ if (progress && process.stderr.isTTY)
11
+ process.stderr.write(`\r\x1b[K${msg}`);
12
+ else if (!progress)
13
+ process.stderr.write(`${process.stderr.isTTY ? '\r\x1b[K' : ''}${msg}\n`);
14
+ }
15
+ export const dot = (a, b) => {
16
+ let s = 0;
17
+ for (let i = 0; i < a.length; i++)
18
+ s += a[i] * b[i];
19
+ return s;
20
+ };
21
+ // Ranks from scores: 1 is best. Ties keep input order.
22
+ export function ranks(scores) {
23
+ const order = [...scores.keys()].sort((a, b) => scores[b] - scores[a]);
24
+ const r = new Int32Array(scores.length);
25
+ order.forEach((j, i) => { r[j] = i + 1; });
26
+ return r;
27
+ }
28
+ export const RRF_K = 60;
29
+ // Reciprocal rank fusion: each list adds 1 / (60 + rank).
30
+ export function fuse(...lists) {
31
+ const rs = lists.map(ranks);
32
+ return Float32Array.from(rs[0], (_, j) => rs.reduce((s, r) => s + 1 / (RRF_K + r[j]), 0));
33
+ }
34
+ export const topK = (scores, k) => [...scores.keys()].sort((a, b) => scores[b] - scores[a]).slice(0, k);
package/package.json CHANGED
@@ -1,8 +1,30 @@
1
1
  {
2
2
  "name": "ligatura",
3
- "version": "0.0.1",
4
- "description": "Ligature tool. Name reserved; first release coming.",
3
+ "version": "0.1.0",
4
+ "description": "Find where one text quotes, paraphrases or alludes to another. Local models by default; every link is a suggestion for a person to confirm.",
5
+ "keywords": ["intertextuality", "allusion", "text-reuse", "digital-humanities", "embeddings", "reranker", "bible", "cli"],
5
6
  "author": "Bret Perry",
6
7
  "license": "MIT",
7
- "files": ["README.md"]
8
+ "repository": { "type": "git", "url": "git+https://github.com/bretperry/ligatura.git" },
9
+ "homepage": "https://github.com/bretperry/ligatura#readme",
10
+ "bugs": "https://github.com/bretperry/ligatura/issues",
11
+ "type": "module",
12
+ "bin": { "ligatura": "dist/cli.js" },
13
+ "main": "dist/index.js",
14
+ "types": "dist/index.d.ts",
15
+ "exports": { ".": "./dist/index.js" },
16
+ "files": ["dist", "README.md", "LICENSE"],
17
+ "engines": { "node": ">=20" },
18
+ "scripts": {
19
+ "build": "tsc",
20
+ "test": "tsc && node --test 'test/*.test.js'",
21
+ "prepublishOnly": "npm test"
22
+ },
23
+ "dependencies": {
24
+ "@huggingface/transformers": "^3.8.1"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^22.0.0",
28
+ "typescript": "^5.9.0"
29
+ }
8
30
  }