Skip to content

Commit 51dbf18

Browse files
Merge pull request #41 from StepenkoAnatoli/feat/mirror-detection
corroboration counts documents, because counting hosts inverted on a mirror
2 parents 79a671a + 90f22c2 commit 51dbf18

8 files changed

Lines changed: 422 additions & 5 deletions

File tree

‎docs/ARCHITECTURE.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ else, and it never touches the corpus:
146146
| `dimensions.mjs` | The universal, domain-agnostic subtopic checklist and its mechanical presence-matching (`coverageOfUniversals`). Recipes add on top of it; nothing replaces it. | — |
147147
| `provenance.mjs` | The tamper-evident fetch ledger: `appendFetch`, `verifyLedger`, `rebuildLedger` (the one metadata-migration path — guarded, per-entry `migrations` records, chained `op: 'migration'` boundary), and the overrides log writer. Also **the collector's exclusive section** (`withLock`), acquired with `O_EXCL` (`flag: 'wx'`) so the kernel refuses a second creator instead of letting it overwrite; staleness is judged only *after* EEXIST proves someone holds the file, and a fresh lock that names no pid — the window `O_EXCL` leaves between create and write — is waited for, never broken. Reentrant in-process, so `appendFetch` can serialize its own read-modify-write inside a run that already holds the lock, and release removes only the acquisition it made (pid + nonce). Every writer of the chain — append, tail repair, rebuild — goes through that one lock. `verifyLedger` also **classifies** a body hash that does not recompute (`isLineEndingRewrite`, `hashText`): folding CRLF back to LF either restores the recorded hash or it does not, so `body-unmodified` problems carry `kind: 'line-endings' | 'modified'` and the consumer can name the right cause instead of guessing (ADR-0020). | ADR-0003 (corpus-adjacent), ADR-0020 | **Release is ownership-positive (fixed 2026-09-21):** the lock is unlinked only when the nonce POSITIVELY matches. An unreadable lock is left alone — while a collector works its lock can be truncated, recovered as stale by a second collector and replaced by that collector's own, and unlinking then would delete a lock this process does not hold and admit a third writer to the section. Failing to release is recoverable by the staleness path; deleting someone else's is not.
148148
| `checks.mjs` | The **ordered contract-check registry**: discovery-contract, citations, provenance, transport-provenance, gate-integrity, unknown-closure, subtopic-coverage, capture-completeness, **evidence-supersession**, collection-attempts, hygiene, corpus-shape — twelve, in a pinned order. Every check is a pure function of the corpus snapshot + options; none reads the filesystem. **`supersededRows(corpus)`** is the one shared answer to "which rows have been replaced by a fresher capture of the same URL": `evidence-supersession` refuses an unknown still resting on a replaced row, `transport-provenance` goes quiet about a replaced row nothing relies on (but not about one still cited), and `hygiene` stops calling a refresh a duplicate while still catching two rows of one URL fetched the same day. Those last two are ADR-0026-aware: they warn about the state that ADR requires. | ADR-0004, ADR-0026 |
149+
| `similarity.mjs` | **Near-duplicate detection between two captured pages**, and nothing else. Word shingles (5-word, case- and punctuation-folded, URLs stripped) reduced to a 128-hash MinHash `sketch`; `similarity` estimates Jaccard, `isMirror` compares it to `MIRROR_THRESHOLD`, and `documentGroups` single-link-clusters rows into distinct DOCUMENTS. Exists because `corroboration` counted HOSTS and therefore **inverted** on a mirror: a republished copy on an unrelated host scored `independent` - the registry's best grade - while carrying one witness plus a staleness `freshness` cannot see, since that check grades when a page was FETCHED. `MIRROR_THRESHOLD = 0.25` is **chosen from measurement, not taste**: all 1081 capture pairs in this repository were compared on 2026-09-21, the known stale mirror (`nodejs.org` x `lira.epac.to`, six majors apart) scored 0.3879 and the highest-scoring genuinely different cross-host pair scored 0.0275 - a fourteen-fold gap, so the line is not delicate. `sketch` returns **null** below `MIN_SHINGLES`, and every caller must read null as "cannot tell" rather than "different", which is why two unjudgeable rows stay two documents instead of merging into an invented mirror. Hashing is seeded FNV-1a so a sketch is identical across runs and platforms - a fingerprint that decides a gate finding must grade the same corpus the same way everywhere. Judges no meaning and cannot tell a correct page from a wrong one (ADR-0013). | ADR-0036 |
149150
| `preflight.mjs` | **The verdict** and the **verdict context**. Assembles findings from CHECKS into pass/fail; one function, three callers (commit gate, edit gate, CLI) so they cannot disagree. Owns the verdict's input unit: `verdictContext(root)` reads the corpus, the gate state (`readGateState`), and the operator's `evidencePolicy` together — same snapshot, same verdict, by construction. | ADR-0004 |
150151
| `gate.mjs` | Gate policy: `isGated` (the four markers), `evaluate` (commit/edit), the GATE_OFF override recording, and the architecture-map rule (`architectureMapBreach` over `loadGateConfig` — guarded paths are project configuration, `research/kit.json`, defaults `src/lib/bin/scripts/app`). Contains no repo-specific path. **One deliberate exception to "no CLI parsing"**: the commit gate's staged-path list arrives as *data on a pipe* (`stagedPathsFromStdin` / `splitPathList`), because argv was O(n²) to build and bounded besides. It returns **null for "stdin could not be read" and `[]` for "read, and empty"** — conflating them would hand the verdict an empty staged set, which it allows, i.e. a gate that silently stops gating. `stdinIsReadable()` asks `isatty(0)` rather than inferring from the file type, which gets two cases wrong in opposite directions (a socketpair is not a FIFO; `/dev/null` is a character device but reads as immediate EOF). | ADR-0002, ADR-0007, ADR-0008, ADR-0020 | **An unreadable index BLOCKS (fixed 2026-09-21).** The commit gate judges staged bytes (ADR-0024); when `git checkout-index` fails it used to judge the working tree and note that it had. Noting is not enough — the two differ exactly when it matters, so an environmental git failure silently reinstated the defect ADR-0024 exists to prevent. It now blocks with `judged: 'nothing'` and names `--no-verify` as the recorded override. A project that is not a git repository still judges the tree: there is no index to fail to read. Index materialisation is also **chunked** (250 paths per call) rather than spreading every tracked path into one argv, which on Windows hit the ~32k command-line cap for a large corpus and produced exactly that git failure.
151152
| `doctor.mjs` | Read-only diagnostics: machine + project + gate + chain health in one report. Consumes the verdict context (`verdictContext` / `readGateState`) for the chain-verified corpus and the gate-state triple; adds one machine read of its own (the git-resolved `hooksPathEffective`) for report. **Role-aware** (ADR-0010): the Firecrawl findings are informational on a `builder`, and the handoff findings (ADR-0011) are blockers there — the collection entrypoints' refusal is what makes that split honest. The handoff findings come from the owner, not from a second composition: doctor passes the corpus it already read to `verifyHandoff(root, { corpus })`, so the arrival question has one implementation and one set of pins. One deliberate write: the repository-local hooksPath override log (the third override is silent by nature — the bypassed hook cannot report it). `gateHealth` and `runDoctor` accept explicit Git/config/settings paths and a child environment so diagnostics can run against disposable machine fixtures without consulting host state. | ADR-0010, ADR-0011 |
@@ -433,7 +434,7 @@ Counted against the filesystem, not estimated:
433434

434435
| | |
435436
|---|---|
436-
| `lib/` modules | **48** (44, plus the 4 extracted into `lib/release/`) — every one has a row above |
437+
| `lib/` modules | **49** (45, plus the 4 extracted into `lib/release/`) — every one has a row above |
437438
| `bin/` entrypoints | **29**, of which **4 are Python**: 3 conformance runners and the module they share |
438439
| `schemas/` | **12** |
439440
| `conformance/` vector packets | **3** |

‎docs/adr/0036-the-gate-can-see-single-sourcing.md‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,9 @@ be one witness; ADR-0036 already said that. It is that two hosts can be one witn
160160
staleness the freshness check cannot see*, because `freshness` grades when the page was
161161
**fetched** and this one was fetched today.
162162

163+
> **Superseded the same day — the "no code changes" conclusion below was wrong, and is
164+
> now implemented as `lib/similarity.mjs`. See the amendment that follows.**
165+
163166
**No code changes.** Detecting mirrors means comparing document content or parsing version
164167
strings out of vendor pages, and a check that guessed at either would be the reasoning-grader
165168
ADR-0013 refused. What changes is what a reviewer is told to do: **`independent` is a prompt
@@ -170,6 +173,72 @@ Recorded live rather than reasoned about: the corpus that found it
170173
`single-source` warnings instead — the correct trade, and the one the check's grades
171174
currently push against.
172175

176+
## Amendment, 2026-09-21 — the mirror IS detectable, and "grading meaning" was a dodge
177+
178+
The amendment above concluded that a mirror could only be caught by "comparing document
179+
content … which would be the reasoning-grader ADR-0013 refused". **The first half named the
180+
solution and the second half talked itself out of it.**
181+
182+
Comparing document content is not grading meaning. It asks whether two byte streams are
183+
substantially the same text, which is arithmetic. It judges nothing, cannot tell a correct
184+
page from a wrong one, and does not try — `lib/similarity.mjs` says so at the top. ADR-0013
185+
refuses checks that grade *how an agent reasoned*; it does not refuse checks that measure.
186+
Reaching for it here was an argument for doing nothing.
187+
188+
### What was built
189+
190+
`corroboration` no longer counts hosts. It groups an unknown's rows into distinct
191+
**documents** first, then counts hosts among those:
192+
193+
| rule | when | severity |
194+
|---|---|---|
195+
| `single-source` | one row | `warn` |
196+
| `one-voice` | several rows, one host after grouping | `warn` |
197+
| `mirror` | several hosts, but all one document | `warn` |
198+
| `independent` | several distinct documents, several hosts | `pass` |
199+
200+
A three-row unknown where two rows mirror each other still counts the third, and the
201+
`independent` detail now states how many rows were folded — so three rows buying two
202+
witnesses is visible rather than absorbed.
203+
204+
### The threshold is measured, not chosen
205+
206+
Every capture pair in this repository was compared — 47 captures, 1081 pairs:
207+
208+
| | Jaccard |
209+
|---|---|
210+
| the known stale mirror (`nodejs.org` × `lira.epac.to`, six majors apart) | **0.3879** |
211+
| highest-scoring genuinely *different* cross-host pair | **0.0275** |
212+
| median cross-host pair | 0.0000 |
213+
214+
A fourteen-fold gap. `MIRROR_THRESHOLD = 0.25` sits ~9× above the strongest false-positive
215+
candidate and below the hardest true positive — and the *stale* mirror is the hard case,
216+
because a current one scores ~0.99. The 128-hash sketch tracks exact Jaccard to within
217+
0.048 across all 1081 pairs, verified rather than assumed.
218+
219+
### Two properties that matter more than the threshold
220+
221+
**`null` never means "different".** A document below `MIN_SHINGLES` is unjudgeable, and two
222+
unjudgeable rows stay two documents rather than merging into an invented mirror. The
223+
conservative direction for a check that can only ever *reduce* apparent corroboration.
224+
225+
**Hashing is seeded FNV-1a, not `Math.random`.** A fingerprint that decides a gate finding
226+
has to grade the same corpus identically on every machine, or a corpus passes on one
227+
developer's laptop and fails in CI for reasons nobody can reproduce.
228+
229+
### What it changed in this repository
230+
231+
Nothing, and that is the finding. All five corpora report identical verdicts — no existing
232+
citation was secretly a mirror. The check was built for a hole found by collection, not for
233+
a defect already in the corpus, and it now stands in front of the corroboration sweep that
234+
will add ~30 second sources, where a mirror slipping in as `independent` is a live risk
235+
rather than a hypothetical one.
236+
237+
One existing assertion moved: `corroboration: two hosts is independent support` matched
238+
`2 rows across 2 hosts` and now matches `2 distinct documents across 2 hosts`. Its two rows
239+
name a capture the fixture does not have, so neither has a sketch — which makes it the
240+
regression test for the `null` rule as well.
241+
173242
## What this ADR does not claim
174243

175244
That two hosts make a claim true. Two vendors can repeat one another, and a specification

‎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-
829 tests, offline, no key and no network. The runner **awaits** every test, so `ok` means
398+
832 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/lib/checks.mjs‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import { hostOf, PATHS, resolve, exists, ageInDays } from './core.mjs';
1111
import { captureOf, traceOf, citedIds } from './corpus.mjs';
12+
import { documentGroups } from './similarity.mjs';
1213
import { coverageOfUniversals } from './dimensions.mjs';
1314
import { verifyLedger } from './provenance.mjs';
1415

@@ -534,8 +535,31 @@ function corroboration(corpus) {
534535
continue;
535536
}
536537

538+
// Several hosts is where the host heuristic used to stop and say `independent`. A
539+
// mirror passes that test while carrying LESS than either genuine page, so the rows
540+
// are grouped into distinct DOCUMENTS before the hosts are counted again.
541+
const groups = documentGroups(rows.map((row) => corpus.captures.sketches?.get(captureOf(corpus, row)?.file) ?? null));
542+
if (groups.length === 1) {
543+
out.push(finding('warn', 'corroboration', 'mirror',
544+
`${unknown.id} cites ${rows.length} rows across ${hosts.size} hosts but they are the same document - a republished copy is one witness, and the copy may be the stale one`,
545+
{ row: unknown.id, line: unknown.line }));
546+
continue;
547+
}
548+
549+
// Hosts that survive as DISTINCT documents. A three-row unknown where two rows mirror
550+
// each other still counts the third, so this reports what is actually independent.
551+
const distinctHosts = new Set(groups.map((g) => hostOf(rows[g[0]].url)).filter(Boolean));
552+
if (distinctHosts.size === 1) {
553+
out.push(finding('warn', 'corroboration', 'one-voice',
554+
`${unknown.id} cites ${rows.length} rows across ${hosts.size} hosts, but after grouping republished copies only ${[...distinctHosts][0]} remains - one voice`,
555+
{ row: unknown.id, line: unknown.line }));
556+
continue;
557+
}
558+
559+
const mirrored = rows.length - groups.length;
537560
out.push(finding('pass', 'corroboration', 'independent',
538-
`${unknown.id} rests on ${rows.length} rows across ${hosts.size} hosts`,
561+
`${unknown.id} rests on ${groups.length} distinct documents across ${distinctHosts.size} hosts`
562+
+ (mirrored ? ` (${mirrored} of ${rows.length} rows are republished copies and were not counted twice)` : ''),
539563
{ row: unknown.id, line: unknown.line }));
540564
}
541565

‎research-kit/lib/corpus.mjs‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
PATHS, HEADERS, resolve, relative, exists, isDirectory, readText, readJson,
1414
writeText, appendLine, listFiles, sha256File, ageInDays, hostOf,
1515
} from './core.mjs';
16+
import { sketch } from './similarity.mjs';
1617

1718
// ---------------------------------------------------------------- table machinery
1819

@@ -161,6 +162,7 @@ export function readCaptures(root) {
161162
const dir = resolve(root, PATHS.raw);
162163
const entries = [];
163164
const problems = [];
165+
const sketches = new Map();
164166
for (const name of listFiles(dir).sort()) {
165167
if (name.startsWith('.')) continue;
166168
const abs = path.join(dir, name);
@@ -172,6 +174,7 @@ export function readCaptures(root) {
172174
if (!front.url) {
173175
problems.push({ kind: 'capture-no-url', file: rel, detail: 'capture has no url in its front-matter' });
174176
}
177+
sketches.set(rel, sketch(body));
175178
entries.push(captureEntry({
176179
file: rel,
177180
url: front.url,
@@ -193,7 +196,10 @@ export function readCaptures(root) {
193196
const held = byUrl.get(entry.url);
194197
if (!held || String(entry.retrieved) >= String(held.retrieved)) byUrl.set(entry.url, entry);
195198
}
196-
return { entries, byUrl, byFile, problems };
199+
// Sketches ride alongside the index rather than inside `captureEntry`, because an entry
200+
// is serialised into artifact manifests and a 128-value fingerprint per capture would
201+
// bloat every package to answer a question only one check asks (ADR-0036 amendment).
202+
return { entries, byUrl, byFile, sketches, problems };
197203
}
198204

199205
/** The one writer of the in-memory index, for a URL collected mid-run. */

0 commit comments

Comments
 (0)