Skip to content

Commit 78f6998

Browse files
Merge pull request #64 from StepenkoAnatoli/feat/topic-match-signal
The topic signal is reported, because both thresholds for enforcing it were wrong
2 parents e423bcb + a5133af commit 78f6998

5 files changed

Lines changed: 137 additions & 5 deletions

File tree

‎docs/ARCHITECTURE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,7 @@ each line — is in the ADR named beside it and in
427427
| The collector lock is exclusive **on a local filesystem, and not promised on a network one**: Node states "the exclusive flag might not work with network file systems", a condition ADR-0020 never carried. Measured 2026-09-22 - 24 racers, 1 winner, 23 EEXIST on win32 - so the guarantee holds where it was tested and nothing detects where it is standing. Evidence: `docs/decisions/2026-09-22-wx-lock/` | ADR-0020 |
428428
| The SEARCH side is **selectable at dispatch**: collect.yml takes a search_transport input (auto | serpapi | firecrawl-cli) and collect-remote.mjs a matching --search-transport, passed as RESEARCH_KIT_SEARCH_TRANSPORT. ADR-0027 built the ladder and the collector never exposed it, so a dispatched run always took whatever auto-detect picked and two providers could not be compared on one topic through the kit at all. auto becomes the EMPTY STRING, falsy in the ladder, so the default is the previous behaviour exactly | ADR-0027 |
429429
| A **rate limit is an instruction to wait, not a failure**: `rateLimitWaitMs` (lib/firecrawl.mjs) reads the delay the vendor states in its own error text, and `collectOne` sleeps and retries, bounded at two. Firecrawl free tier allows 10 requests a minute and the collector did not throttle, so a run started inside a busy minute shed six of eight fetches and recorded them as ordinary failures. The vendor number is used rather than an invented backoff, for the reason the kit researched for Retry-After: when a server says when to come back, that is the number. Non-rate-limit errors are NOT retried - a 404 does not become a 200 if you wait | ADR-0005 |
430+
| A collection reports **how much of the topic own vocabulary reached the captures** - `topicMatch` in lib/research-run.mjs, printed by bin/research.mjs right after collecting and deciding nothing. It exists because a run returned eight captures about US financial regulation for a question about EU deforestation and passed every structural check: no check asks whether the evidence is about the topic. Two thresholds were measured and REJECTED - per-capture 0.5 flags RFC 9728 in the agent-interface corpus, which never says MCP and is an independent witness precisely because it does not; per-corpus at-least-one-strong flags delivery-architecture, which scores identically to the genuine failure because its topic is internal vocabulary and its evidence is GitHub docs. Telling those apart needs meaning (ADR-0013), so the number is surfaced at the moment a reviewer can act on it and the grade does not move | ADR-0013 |
430431
| A run reports **what landed separately from what it cost**: `collected` is captures on disk, `spent` is budget consumed (collected + failed). They were one number under the label `collected`, so a run that fetched 2 pages and lost 6 to a Firecrawl rate limit reported `collected 8` while its artifact carried 2. The free tier is 10 requests per minute and the collector does not throttle, so a paired run started inside the same minute sheds most of its fetches | ADR-0005 |
431432
| An entrypoint **refuses a flag it does not know** rather than ignoring it: `refuseUnknownFlags` in `lib/core.mjs`, called immediately after `parseFlags` in all eleven bins. `parseFlags` knows no flag names by design, so an entrypoint that skips the check gets silence — the flag is parsed, never read, and the command proceeds to its default. On 2026-09-22 that default was **a real collection**: `research.mjs --totally-made-up-flag` spent 26 credits against this repository's corpus, and `audit.mjs` wrote twelve files. The same survey found a dead `--max-scrapes` in two workflows, where the page bound people believed it set was really `plan.maxScrapes` — real in `collect.yml`, and **absent** in `live-collection.yml`, whose `max_pages` was validated and then ignored | ADR-0020 |
432433
| A capture that **says it failed to render** is reported, though it arrived whole: `completeness` grades the transport and cannot see a page built by script that arrives complete and empty. The rule matches sentences a page PRINTS, never a guess at whether its text looks substantial — emptiness itself was measured and found mechanically undetectable, in both directions ([ADR-0037](adr/0037-an-empty-capture-is-not-mechanically-detectable.md)) | [ADR-0037](adr/0037-an-empty-capture-is-not-mechanically-detectable.md) |

‎research-kit/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,7 @@ node research-kit/bin/selftest.mjs # all of it
395395
node research-kit/bin/selftest.mjs gate hook # just these files
396396
```
397397

398-
861 tests, offline, no key and no network. The runner **awaits** every test, so `ok` means
398+
864 tests, offline, no key and no network. The runner **awaits** every test, so `ok` means
399399
the assertions settled (ADR-0021), and each test is raced against a watchdog
400400
(`RESEARCH_KIT_TEST_TIMEOUT`, default 60s).
401401

‎research-kit/bin/research.mjs‎

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@
77
//
88
// `--dry-run` and `--status` still work there, because they spend nothing.
99

10-
import { parseFlags, flagList, refuseUnknownFlags } from '../lib/core.mjs';
10+
import { parseFlags, flagList, refuseUnknownFlags, resolve, readText } from '../lib/core.mjs';
1111
import { collectionPolicy, collectionRefusal } from '../lib/machine.mjs';
1212
import { selectTransport, TRANSPORT_NAMES, SEARCH_PROVIDER_NAMES } from '../lib/transport.mjs';
13-
import { runResearch, readPlan, usageSummary, DEPTHS, DEPTH_SCRAPES } from '../lib/research-run.mjs';
13+
import { runResearch, readPlan, usageSummary, topicMatch, DEPTHS, DEPTH_SCRAPES } from '../lib/research-run.mjs';
14+
import { parseCapture } from '../lib/corpus.mjs';
1415
import { heading } from '../lib/render.mjs';
1516

1617
const { flags } = parseFlags(process.argv.slice(2));
@@ -115,8 +116,25 @@ depth ${run.depth} (budget ${run.budget} scrapes)
115116
collected ${run.collected}
116117
cached ${run.cached}
117118
failed ${run.failed}
118-
spent ${run.spent} (budget consumed: collected + failed)
119-
`);
119+
spent ${run.spent} (budget consumed: collected + failed)`);
120+
121+
// The topic signal, printed at the one moment it helps: the pages are on disk and nobody
122+
// has read them yet. It decides nothing - see `topicMatch` for the two thresholds that were
123+
// measured and rejected - but a low number on a fresh collection is the prompt to check the
124+
// URLs, which is exactly the step that caught a wholly off-topic corpus by hand.
125+
//
126+
// Only what THIS run collected is measured. Judging the whole corpus would blend a bad
127+
// collection into whatever was already there and hide the thing worth seeing.
128+
const freshBodies = run.results
129+
.filter((r) => r.status === 'collected' && r.entry?.file)
130+
.map((r) => parseCapture(readText(resolve(root, r.entry.file)) ?? '').body);
131+
const match = topicMatch(readPlan(root, typeof flags.plan === 'string' ? flags.plan : '').topic, freshBodies);
132+
if (match) {
133+
process.stdout.write(`topic ${match.best.toFixed(2)} best of ${match.terms} topic terms, ${match.strong} capture(s) >= 0.50`
134+
+ (match.strong === 0 ? ' - NOTHING matched strongly; read the URLs before trusting this' : '')
135+
+ '\n');
136+
}
137+
process.stdout.write('\n');
120138
// The second meter reports separately, and a degradation is never silent: it means the
121139
// run quietly moved spend back onto the fetch budget.
122140
if (run.searchTransport !== run.transport) {

‎research-kit/lib/research-run.mjs‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,3 +344,62 @@ export function searchUsage(root, { now = new Date(), provider = '' } = {}) {
344344
caveat: 'counted from this machine\'s own runs; a repeat served from the provider\'s free cache is counted here but not billed',
345345
};
346346
}
347+
348+
/**
349+
* How much of the topic's own vocabulary appears in what was just collected.
350+
*
351+
* REPORTED, NEVER ENFORCED, and the measurement that settled that is worth keeping.
352+
*
353+
* It exists because a collection can return eight captures about the wrong subject and pass
354+
* every structural check in the kit. On 2026-09-22 a run on the EU Deforestation Regulation
355+
* returned SEC filings, CFPB regulations and California water boards - the search had
356+
* matched the word "regulation" - and `preflight` had nothing to say, because no check asks
357+
* whether the evidence is about the topic.
358+
*
359+
* Two thresholds were measured against that failure and every committed corpus here:
360+
*
361+
* PER CAPTURE, share >= 0.5: rejected. It flags RFC 9728 in the agent-interface corpus,
362+
* which never says "MCP" - and not saying it is exactly what makes it an independent
363+
* witness. The rule would punish the best evidence in the corpus.
364+
*
365+
* PER CORPUS, at least one capture >= 0.5: rejected too, and this is the decisive one.
366+
* `delivery-architecture` scores max 0.25 with zero strong captures - IDENTICAL to the
367+
* failure - because its topic is "How Research-Kit should be delivered to a non-technical
368+
* user" and its evidence is GitHub Actions documentation. Semantically related, lexically
369+
* disjoint. No threshold separates that from a corpus about the wrong subject entirely.
370+
*
371+
* Telling those apart needs meaning, which ADR-0013 refuses. So the number is printed at the
372+
* moment it helps - right after collecting, before anyone has read the pages - and decides
373+
* nothing. A reviewer seeing 0.25 on a fresh collection knows to check the URLs; on the EUDR
374+
* run that is precisely the step that caught it, performed by hand.
375+
*/
376+
const TOPIC_STOPWORDS = new Set(['the', 'a', 'an', 'of', 'for', 'and', 'or', 'to', 'in', 'on',
377+
'at', 'by', 'after', 'before', 'current', 'date', 'with', 'from', 'is', 'are', 'as', 'eu',
378+
'its', 'it', 'this', 'that', 'how', 'what', 'when', 'does', 'do', 'can', 'not', 'without',
379+
'versus', 'vs', 'should', 'be', 'was', 'were', 'has', 'have', 'than', 'they', 'their']);
380+
381+
export function topicTerms(topic) {
382+
return [...new Set(String(topic ?? '').toLowerCase()
383+
.replace(/[^a-z0-9/.-]+/g, ' ')
384+
.split(/\s+/)
385+
.filter(Boolean))]
386+
.filter((word) => word.length > 2 && !TOPIC_STOPWORDS.has(word));
387+
}
388+
389+
/**
390+
* `{ terms, best, strong }` over the given capture bodies, or `null` when the topic carries
391+
* too few distinctive terms to say anything - which is not the same as a low score.
392+
*/
393+
export function topicMatch(topic, bodies) {
394+
const terms = topicTerms(topic);
395+
if (terms.length < 3 || !bodies.length) return null;
396+
const shares = bodies.map((body) => {
397+
const lower = String(body ?? '').toLowerCase();
398+
return terms.filter((term) => lower.includes(term)).length / terms.length;
399+
});
400+
return {
401+
terms: terms.length,
402+
best: Math.max(...shares),
403+
strong: shares.filter((share) => share >= 0.5).length,
404+
};
405+
}

‎research-kit/test/collect.test.mjs‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { readCorpus, parseTable } from '../lib/corpus.mjs';
77
import { HEADERS } from '../lib/core.mjs';
88
import { collectOne, writeRaw, captureName, bodyHashOf } from '../lib/collect.mjs';
99
import { rateLimitWaitMs } from '../lib/firecrawl.mjs';
10+
import { topicMatch } from '../lib/research-run.mjs';
1011
import { verifyLedger } from '../lib/provenance.mjs';
1112
import { runResearch, readPlan, rankCandidate, selectCandidates, DEPTH_SCRAPES, usageSummary } from '../lib/research-run.mjs';
1213

@@ -331,3 +332,56 @@ test('collectOne gives up after a bounded number of rate-limit retries', () => {
331332
assert.equal(outcome.status, 'failed');
332333
assert.match(outcome.reason, /Rate limit exceeded/);
333334
});
335+
336+
// ---------------------------------------------------------------- topic match
337+
338+
test('topicMatch separates the off-topic collection from the on-topic one', () => {
339+
// The real pair, from the same query on 2026-09-22: SerpApi returned eight pages about US
340+
// financial regulation, Firecrawl returned eight about the EU Deforestation Regulation.
341+
// The off-topic corpus passed every structural check in this kit.
342+
const topic = 'EU Deforestation Regulation 2023/1115 application date large operators SMEs current after delay';
343+
344+
const offTopic = [
345+
'Regulation D exempt offerings for small businesses. The application date for filing...',
346+
'Regulation Z truth in lending. Large creditors must apply...',
347+
'California water efficiency legislation and conservation regulation...',
348+
];
349+
const onTopic = [
350+
'The EUDR, Regulation (EU) 2023/1115 on deforestation-free products. Application for large operators and SMEs...',
351+
'EU Deforestation Regulation 2023/1115: application postponed. Large operators and SMEs face a delay...',
352+
];
353+
354+
const off = topicMatch(topic, offTopic);
355+
const on = topicMatch(topic, onTopic);
356+
assert.equal(off.strong, 0, 'nothing in the off-topic set should match strongly');
357+
assert.ok(on.strong > 0, 'the on-topic set must match');
358+
assert.ok(on.best > off.best, `on-topic must outscore off-topic: ${on.best} vs ${off.best}`);
359+
});
360+
361+
test('topicMatch declines to judge a topic with too few distinctive terms', () => {
362+
// Refusing is not scoring zero. A two-word topic makes the share 0, 0.5 or 1 and the
363+
// number means nothing - reporting it would invite a reader to act on noise.
364+
assert.equal(topicMatch('the EU', ['anything at all']), null);
365+
assert.equal(topicMatch('', ['x']), null);
366+
assert.equal(topicMatch('EU Deforestation Regulation dates', []), null, 'no captures, nothing to measure');
367+
});
368+
369+
test('topicMatch REPORTS and never decides - the thresholds that were rejected', () => {
370+
// Pinned so nobody promotes this into a gate later. Measured 2026-09-22 across every
371+
// corpus in this repository:
372+
//
373+
// per-capture >= 0.5 flags RFC 9728 in the agent-interface corpus, which never says
374+
// "MCP" - and not saying it is what makes it an independent witness.
375+
//
376+
// per-corpus "at least one strong capture" flags delivery-architecture, whose topic is
377+
// "How Research-Kit should be delivered to a non-technical user" and whose evidence is
378+
// GitHub Actions docs. It scores max 0.25 with zero strong captures - IDENTICAL to the
379+
// genuine failure. No threshold separates them.
380+
//
381+
// So the shape of the return value is the contract: numbers, no verdict.
382+
const m = topicMatch('EU Deforestation Regulation 2023/1115 operators', ['deforestation regulation for operators']);
383+
assert.deepEqual(Object.keys(m).sort(), ['best', 'strong', 'terms']);
384+
assert.equal(typeof m.best, 'number');
385+
assert.equal(m.pass, undefined, 'a pass/fail field would make this a gate');
386+
assert.equal(m.severity, undefined);
387+
});

0 commit comments

Comments
 (0)