crawldna 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.
@@ -0,0 +1,236 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (C) 2026 Bogdan Marian Vasaiu
3
+ // The measurement primitives for step #12 of TODO.md — the "measurement harness".
4
+ //
5
+ // These turn the project's promises ("nothing hidden is missed", "the output is
6
+ // all-and-only what the task asked", "here is where the tokens go") into NUMBERS you
7
+ // can put side by side BEFORE and AFTER a change, instead of trusting an estimate.
8
+ //
9
+ // Every function here is PURE and DEPENDENCY-FREE (only the project's own url helper),
10
+ // so it is deterministic and verifiable offline — no model, no browser. The parts that
11
+ // actually run a crawl live in the runner (scripts/eval.mjs); THIS file only scores an
12
+ // output that already exists.
13
+ //
14
+ // The honest limits, kept front-of-mind (see TODO.md §"Previsioni oneste"):
15
+ // - Absolute completeness is NOT provable from a single crawl (academic result). What
16
+ // we CAN measure are PROXIES: did KNOWN hidden content survive (revealCoverage), and
17
+ // how much of the sitemap did we keep (sitemapCoverage).
18
+ // - "Task respect" is scored SWDE-style against a GOLDEN SET the user supplies: recall
19
+ // = the expected things are present, precision = the known-irrelevant things are not.
20
+ // It measures the crawl against a ground truth; it cannot invent one.
21
+
22
+ import { normalizeUrl } from '../lib/url.mjs';
23
+
24
+ /** Lowercase + collapse all whitespace to single spaces + trim — so a substring check
25
+ * ignores incidental formatting (line wraps, indentation, doubled spaces). */
26
+ export function normalizeText(s) {
27
+ return String(s || '').replace(/\s+/g, ' ').trim().toLowerCase();
28
+ }
29
+
30
+ /** Canonical form of a URL for set comparison; falls back to a trimmed lowercase string
31
+ * when it cannot be parsed, so a comparison never silently drops an entry. */
32
+ function canonUrl(u) {
33
+ return normalizeUrl(u) || String(u || '').trim().toLowerCase();
34
+ }
35
+
36
+ /** Round to `d` decimals for stable, readable ratios. */
37
+ function round(n, d = 3) {
38
+ const f = 10 ** d;
39
+ return Math.round((Number(n) || 0) * f) / f;
40
+ }
41
+
42
+ /**
43
+ * (a)(iii) REVEAL RESIDUAL (#21d) — the closed loop's own completeness number, read from
44
+ * the crawl result itself (no golden set needed): for each kept page, how much text was
45
+ * STILL hidden in the main content when the reveal loop exited (`meta.revealResidualChars`,
46
+ * measured in-page — 0 = the page was drained). Complements revealCoverage: coverage
47
+ * checks KNOWN snippets survived, residual measures what provably did NOT come out.
48
+ *
49
+ * @param {Array<{url:string, meta?:{revealResidualChars?:number}}>} pages kept pages
50
+ * @returns {{ pages:number, withResidual:number, chars:number, words:number,
51
+ * worst:Array<{url:string,chars:number}> }}
52
+ */
53
+ export function revealResidual(pages = []) {
54
+ const rows = [];
55
+ let chars = 0;
56
+ for (const p of pages || []) {
57
+ const c = (p && p.meta && p.meta.revealResidualChars) || 0;
58
+ if (c > 0) {
59
+ rows.push({ url: p.url, chars: c });
60
+ chars += c;
61
+ }
62
+ }
63
+ rows.sort((a, b) => b.chars - a.chars);
64
+ return {
65
+ pages: (pages || []).length,
66
+ withResidual: rows.length,
67
+ chars,
68
+ words: Math.round(chars / 6),
69
+ worst: rows.slice(0, 5),
70
+ };
71
+ }
72
+
73
+ /**
74
+ * (a)(i) REVEAL COMPLETENESS — did the interaction-hidden content survive into the
75
+ * output? Each `expected` string is a snippet that a human confirmed is present on the
76
+ * page ONLY after a click (a tab's body, an accordion's text, a "load more" item). If it
77
+ * appears in the crawl output, the reveal engine did its job for that snippet.
78
+ *
79
+ * @param {string} outputText the crawl's Markdown (the whole scan, concatenated)
80
+ * @param {string[]} expected snippets that must appear iff reveal worked
81
+ * @returns {{ total:number, found:number, missing:string[], ratio:number }}
82
+ */
83
+ export function revealCoverage(outputText, expected = []) {
84
+ const hay = normalizeText(outputText);
85
+ const list = (expected || []).map((s) => String(s || '')).filter((s) => s.trim());
86
+ const missing = list.filter((s) => !hay.includes(normalizeText(s)));
87
+ const found = list.length - missing.length;
88
+ return { total: list.length, found, missing, ratio: list.length ? round(found / list.length) : 1 };
89
+ }
90
+
91
+ /**
92
+ * (a)(ii) PAGE COMPLETENESS — sitemap-coverage proxy. Of the URLs the site advertises in
93
+ * its sitemap, how many did the crawl keep? This is a PROXY (a crawl can legitimately
94
+ * skip off-task sitemap URLs, and can legitimately find pages NOT in the sitemap), so
95
+ * `missing` and `extra` are reported for inspection rather than as pass/fail.
96
+ *
97
+ * @param {string[]} keptUrls URLs the crawl produced (scan.pages[].url)
98
+ * @param {string[]} sitemapUrls URLs from the site's sitemap(s)
99
+ * @returns {{ total:number, covered:number, missing:string[], extra:string[], ratio:number }}
100
+ */
101
+ export function sitemapCoverage(keptUrls = [], sitemapUrls = []) {
102
+ const kept = new Set((keptUrls || []).map(canonUrl));
103
+ const sm = [...new Set((sitemapUrls || []).map(canonUrl))];
104
+ const smSet = new Set(sm);
105
+ const missing = sm.filter((u) => !kept.has(u));
106
+ const covered = sm.length - missing.length;
107
+ const extra = [...kept].filter((u) => !smSet.has(u));
108
+ return { total: sm.length, covered, missing, extra, ratio: sm.length ? round(covered / sm.length) : 1 };
109
+ }
110
+
111
+ /**
112
+ * (b) TASK RESPECT — "all-and-only", scored SWDE-style against a golden set:
113
+ * - RECALL = fraction of `mustInclude` snippets present → did we keep everything
114
+ * the task asked for? (a missing one = lost content).
115
+ * - PRECISION = fraction of `mustExclude` snippets ABSENT → did we drop the
116
+ * off-task/boilerplate the task did NOT ask for? (a present one = leaked
117
+ * chrome). Precision here is a PROXY: it can only see the known-bad
118
+ * markers the golden set lists, not every possible piece of junk.
119
+ * F1 is reported only when both lists are non-empty.
120
+ *
121
+ * @param {string} outputText
122
+ * @param {{ mustInclude?:string[], mustExclude?:string[] }} golden
123
+ */
124
+ export function taskRespect(outputText, { mustInclude = [], mustExclude = [] } = {}) {
125
+ const hay = normalizeText(outputText);
126
+ const inc = (mustInclude || []).map((s) => String(s || '')).filter((s) => s.trim());
127
+ const exc = (mustExclude || []).map((s) => String(s || '')).filter((s) => s.trim());
128
+
129
+ const missing = inc.filter((s) => !hay.includes(normalizeText(s))); // asked-for but absent
130
+ const leaked = exc.filter((s) => hay.includes(normalizeText(s))); // off-task but present
131
+
132
+ const recall = inc.length ? round((inc.length - missing.length) / inc.length) : null;
133
+ const precision = exc.length ? round((exc.length - leaked.length) / exc.length) : null;
134
+ const f1 =
135
+ recall != null && precision != null && recall + precision > 0
136
+ ? round((2 * recall * precision) / (recall + precision))
137
+ : null;
138
+
139
+ return {
140
+ recall,
141
+ precision,
142
+ f1,
143
+ includeTotal: inc.length,
144
+ includeFound: inc.length - missing.length,
145
+ missing,
146
+ excludeTotal: exc.length,
147
+ leaked,
148
+ };
149
+ }
150
+
151
+ /**
152
+ * (a)(ii) RUN DIFF — compare two runs of the same site (e.g. before vs after a change,
153
+ * or re-crawl freshness). Pages are matched by canonical URL; a page present in both with
154
+ * a different byte size is "changed".
155
+ *
156
+ * @param {Array<{url:string, bytes?:number}>} a the baseline run's pages
157
+ * @param {Array<{url:string, bytes?:number}>} b the new run's pages
158
+ */
159
+ export function diffRuns(a = [], b = []) {
160
+ const toMap = (pages) => {
161
+ const m = new Map();
162
+ for (const p of pages || []) {
163
+ const u = canonUrl(p && p.url);
164
+ if (!u) continue;
165
+ m.set(u, Number((p && p.bytes) || 0));
166
+ }
167
+ return m;
168
+ };
169
+ const A = toMap(a);
170
+ const B = toMap(b);
171
+
172
+ const added = [...B.keys()].filter((u) => !A.has(u));
173
+ const removed = [...A.keys()].filter((u) => !B.has(u));
174
+ const changed = [];
175
+ for (const [u, bytesA] of A) {
176
+ if (!B.has(u)) continue;
177
+ const bytesB = B.get(u);
178
+ if (bytesA !== bytesB) changed.push({ url: u, fromBytes: bytesA, toBytes: bytesB, delta: bytesB - bytesA });
179
+ }
180
+ const sum = (m) => [...m.values()].reduce((n, v) => n + v, 0);
181
+ return {
182
+ added,
183
+ removed,
184
+ changed,
185
+ pagesA: A.size,
186
+ pagesB: B.size,
187
+ bytesA: sum(A),
188
+ bytesB: sum(B),
189
+ bytesDelta: sum(B) - sum(A),
190
+ };
191
+ }
192
+
193
+ /**
194
+ * (c) TOKENS PER CALL TYPE — turn the metered `tokens.byKind` into a ranked table so you
195
+ * can see WHERE the tokens actually go (reveal vs scope vs links vs nav-plan), not just
196
+ * the grand total. Each row's `total` is input+output; `share` is its fraction of the
197
+ * grand total. Rows are sorted biggest-first.
198
+ *
199
+ * @param {{ calls?:number, inputTokens?:number, outputTokens?:number, byKind?:object }} tokens
200
+ */
201
+ export function tokenBreakdown(tokens = {}) {
202
+ const byKind = (tokens && tokens.byKind) || {};
203
+ const rows = Object.entries(byKind).map(([kind, k]) => {
204
+ const input = Number(k.inputTokens || 0);
205
+ const output = Number(k.outputTokens || 0);
206
+ return {
207
+ kind,
208
+ calls: Number(k.calls || 0),
209
+ inputTokens: input,
210
+ outputTokens: output,
211
+ cachedInputTokens: Number(k.cachedInputTokens || 0),
212
+ total: input + output,
213
+ };
214
+ });
215
+ const grand = rows.reduce((n, r) => n + r.total, 0);
216
+ for (const r of rows) r.share = grand ? round(r.total / grand) : 0;
217
+ rows.sort((x, y) => y.total - x.total);
218
+
219
+ const totalInput = Number(tokens.inputTokens || 0);
220
+ const totalOutput = Number(tokens.outputTokens || 0);
221
+ const totalCached = Number(tokens.cachedInputTokens || 0);
222
+ return {
223
+ total: {
224
+ calls: Number(tokens.calls || 0),
225
+ inputTokens: totalInput,
226
+ outputTokens: totalOutput,
227
+ // #4: the slice of input served from the provider's prompt cache (~10× cheaper);
228
+ // cachedShare is its fraction of ALL input — the number that should GROW after
229
+ // the first calls of each kind when prefix caching is working.
230
+ cachedInputTokens: totalCached,
231
+ cachedShare: totalInput ? round(totalCached / totalInput) : 0,
232
+ total: totalInput + totalOutput,
233
+ },
234
+ rows,
235
+ };
236
+ }
@@ -0,0 +1,149 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (C) 2026 Bogdan Marian Vasaiu
3
+ // Assemble the individual metrics (metrics.mjs) into ONE report for a crawl result,
4
+ // and render it as a readable before/after table. Pure: it scores a result + a golden
5
+ // spec that already exist; the runner (scripts/eval.mjs) is what produces them.
6
+ //
7
+ // A "golden spec" is the ground truth for one site, supplied by the user (see
8
+ // eval/README.md):
9
+ // { name, url, task,
10
+ // expect: { revealContent?: string[], mustInclude?: string[],
11
+ // mustExclude?: string[], sitemapUrls?: string[] } }
12
+
13
+ import { revealCoverage, revealResidual, sitemapCoverage, taskRespect, diffRuns, tokenBreakdown } from './metrics.mjs';
14
+
15
+ /** Concatenate a scan's output into one text blob to score against. Prefers the
16
+ * consolidated output files; falls back to the raw page Markdown. */
17
+ function scanText(scan) {
18
+ const files = (scan.files || []).map((f) => f.markdown || '').filter(Boolean);
19
+ if (files.length) return files.join('\n\n');
20
+ return (scan.pages || []).map((p) => p.markdown || '').filter(Boolean).join('\n\n');
21
+ }
22
+
23
+ /** Every kept page across all scans, as { url, bytes, meta } — for coverage,
24
+ * diff and the reveal-residual audit (#21d, reads meta.revealResidualChars). */
25
+ function keptPages(result) {
26
+ const out = [];
27
+ for (const scan of result.scans || []) {
28
+ for (const p of scan.pages || []) {
29
+ out.push({ url: p.url, bytes: (p.meta && p.meta.bytes) || 0, meta: p.meta });
30
+ }
31
+ }
32
+ return out;
33
+ }
34
+
35
+ /**
36
+ * Score a finished crawl against a golden spec.
37
+ *
38
+ * @param {object} a
39
+ * @param {import('../index.mjs').Result|any} a.result the crawlDocs result
40
+ * @param {object} a.spec the golden spec ({ name, url, task, expect })
41
+ * @param {string[]} [a.sitemapUrls] sitemap URLs (from the spec or fetched live)
42
+ * @param {Array<{url,bytes}>} [a.baselinePages] a previous run's pages, for a diff
43
+ * @returns {object} a structured report (feed to formatReport)
44
+ */
45
+ export function evaluate({ result, spec = {}, sitemapUrls = null, baselinePages = null }) {
46
+ const expect = spec.expect || {};
47
+ const text = (result.scans || []).map(scanText).filter(Boolean).join('\n\n');
48
+ const pages = keptPages(result);
49
+ const sm = Array.isArray(sitemapUrls) ? sitemapUrls : Array.isArray(expect.sitemapUrls) ? expect.sitemapUrls : null;
50
+
51
+ const report = {
52
+ name: spec.name || spec.url || '(unnamed)',
53
+ url: spec.url || '',
54
+ task: spec.task || '',
55
+ pages: pages.length,
56
+ durationMs: (result.stats && result.stats.durationMs) || 0,
57
+ reveal: expect.revealContent && expect.revealContent.length ? revealCoverage(text, expect.revealContent) : null,
58
+ residual: revealResidual(pages),
59
+ task_respect:
60
+ (expect.mustInclude && expect.mustInclude.length) || (expect.mustExclude && expect.mustExclude.length)
61
+ ? taskRespect(text, expect)
62
+ : null,
63
+ sitemap: sm ? sitemapCoverage(pages.map((p) => p.url), sm) : null,
64
+ diff: baselinePages ? diffRuns(baselinePages, pages) : null,
65
+ tokens: tokenBreakdown((result.stats && result.stats.tokens) || {}),
66
+ warnings: (result.warnings || []).length,
67
+ };
68
+ return report;
69
+ }
70
+
71
+ const pct = (r) => (r == null ? ' n/a' : `${(r * 100).toFixed(1)}%`);
72
+ const bar = (r) => {
73
+ if (r == null) return '';
74
+ const n = Math.round(Math.max(0, Math.min(1, r)) * 20);
75
+ return '[' + '#'.repeat(n) + '-'.repeat(20 - n) + ']';
76
+ };
77
+
78
+ /** Render a report (from {@link evaluate}) as a readable text block. */
79
+ export function formatReport(report) {
80
+ const L = [];
81
+ L.push(`━━ ${report.name} ━━`);
82
+ L.push(` task: ${report.task}`);
83
+ L.push(` url: ${report.url}`);
84
+ L.push(` pages: ${report.pages} kept · ${report.durationMs} ms · ${report.warnings} warning(s)`);
85
+ L.push('');
86
+
87
+ // (a)(i) reveal completeness
88
+ if (report.reveal) {
89
+ const r = report.reveal;
90
+ L.push(`(a) reveal completeness ${bar(r.ratio)} ${pct(r.ratio)} (${r.found}/${r.total} hidden snippets present)`);
91
+ if (r.missing.length) for (const m of r.missing) L.push(` MISSING: ${JSON.stringify(m.slice(0, 80))}`);
92
+ }
93
+
94
+ // (a)(iii) reveal residual — measured directly on the result, no golden set needed
95
+ if (report.residual && report.residual.pages) {
96
+ const r = report.residual;
97
+ const ratio = r.pages ? (r.pages - r.withResidual) / r.pages : 1;
98
+ L.push(`(a) reveal residual ${bar(ratio)} ${pct(ratio)} (${r.pages - r.withResidual}/${r.pages} pages fully drained · ~${r.words} words still hidden)`);
99
+ for (const w of r.worst) L.push(` RESIDUAL: ${w.url} (~${Math.round(w.chars / 6)} words)`);
100
+ }
101
+
102
+ // (a)(ii) sitemap coverage
103
+ if (report.sitemap) {
104
+ const s = report.sitemap;
105
+ L.push(`(a) sitemap coverage ${bar(s.ratio)} ${pct(s.ratio)} (${s.covered}/${s.total} sitemap URLs kept · ${s.extra.length} beyond sitemap)`);
106
+ if (s.missing.length) L.push(` ${s.missing.length} sitemap URL(s) not kept (proxy — some may be legitimately off-task)`);
107
+ }
108
+
109
+ // (b) task respect
110
+ if (report.task_respect) {
111
+ const t = report.task_respect;
112
+ if (t.recall != null) {
113
+ L.push(`(b) task recall ${bar(t.recall)} ${pct(t.recall)} (${t.includeFound}/${t.includeTotal} expected present)`);
114
+ for (const m of t.missing) L.push(` MISSING: ${JSON.stringify(m.slice(0, 80))}`);
115
+ }
116
+ if (t.precision != null) {
117
+ L.push(`(b) task precision ${bar(t.precision)} ${pct(t.precision)} (${t.excludeTotal - t.leaked.length}/${t.excludeTotal} off-task absent)`);
118
+ for (const m of t.leaked) L.push(` LEAKED: ${JSON.stringify(m.slice(0, 80))}`);
119
+ }
120
+ if (t.f1 != null) L.push(`(b) task F1 ${bar(t.f1)} ${pct(t.f1)}`);
121
+ }
122
+
123
+ // run diff
124
+ if (report.diff) {
125
+ const d = report.diff;
126
+ L.push(
127
+ ` run diff vs baseline +${d.added.length} added · -${d.removed.length} removed · ~${d.changed.length} changed · ` +
128
+ `${d.bytesDelta >= 0 ? '+' : ''}${d.bytesDelta} bytes`,
129
+ );
130
+ }
131
+
132
+ // (c) tokens per call type
133
+ L.push('');
134
+ const tk = report.tokens;
135
+ const cached = tk.total.cachedInputTokens
136
+ ? ` · ${tk.total.cachedInputTokens.toLocaleString()} in cached ${pct(tk.total.cachedShare)}`
137
+ : '';
138
+ L.push(`(c) tokens: ${tk.total.total.toLocaleString()} total (${tk.total.inputTokens.toLocaleString()} in · ${tk.total.outputTokens.toLocaleString()} out · ${tk.total.calls} calls${cached})`);
139
+ const pad = (s, n) => String(s).padEnd(n);
140
+ const padL = (s, n) => String(s).padStart(n);
141
+ L.push(` ${pad('kind', 10)} ${padL('calls', 6)} ${padL('in', 9)} ${padL('out', 9)} ${padL('total', 10)} ${padL('share', 7)}`);
142
+ for (const r of tk.rows) {
143
+ L.push(
144
+ ` ${pad(r.kind, 10)} ${padL(r.calls, 6)} ${padL(r.inputTokens.toLocaleString(), 9)} ` +
145
+ `${padL(r.outputTokens.toLocaleString(), 9)} ${padL(r.total.toLocaleString(), 10)} ${padL(pct(r.share), 7)}`,
146
+ );
147
+ }
148
+ return L.join('\n');
149
+ }