pgroast 0.0.0-stage → 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.
Files changed (67) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +173 -2
  3. package/bin/pgroast.mjs +19 -0
  4. package/data/precedents.sql.gz +0 -0
  5. package/docs/corpus.md +352 -0
  6. package/docs/design.md +540 -0
  7. package/docs/eval.md +245 -0
  8. package/docs/haas-paranoia-notes.md +223 -0
  9. package/invariants/README.md +15 -0
  10. package/invariants/nbtree.md +14 -0
  11. package/package.json +51 -4
  12. package/prompts/brief.md +32 -0
  13. package/prompts/committer-paranoia.md +149 -0
  14. package/prompts/critic.md +56 -0
  15. package/prompts/enrich.md +35 -0
  16. package/prompts/pushback-map.md +58 -0
  17. package/prompts/pushback-reduce.md +72 -0
  18. package/prompts/reflect.md +20 -0
  19. package/prompts/relevance.md +20 -0
  20. package/prompts/roaster-already-exists.md +23 -0
  21. package/prompts/roaster-common.md +88 -0
  22. package/prompts/roaster-demand.md +28 -0
  23. package/prompts/roaster-maintenance-fit.md +22 -0
  24. package/prompts/roaster-prior-attempts.md +29 -0
  25. package/prompts/roaster-replication-ondisk.md +24 -0
  26. package/prompts/roaster-security.md +21 -0
  27. package/prompts/roaster-standards-compat.md +21 -0
  28. package/prompts/supervisor.md +31 -0
  29. package/prompts/synth-self.md +19 -0
  30. package/prompts/synth.md +140 -0
  31. package/prompts/tone.md +21 -0
  32. package/prompts/writing.md +98 -0
  33. package/sql/schema.sql +45 -0
  34. package/src/cli.ts +391 -0
  35. package/src/corpus/cf.ts +220 -0
  36. package/src/corpus/cli.ts +189 -0
  37. package/src/corpus/db.ts +119 -0
  38. package/src/corpus/fetch.ts +104 -0
  39. package/src/corpus/reverts.ts +133 -0
  40. package/src/corpus/search.ts +201 -0
  41. package/src/corpus/util.ts +133 -0
  42. package/src/corpus/wiki.ts +80 -0
  43. package/src/eval/cases.ts +118 -0
  44. package/src/eval/cli.ts +105 -0
  45. package/src/eval/judge.ts +136 -0
  46. package/src/eval/report.ts +268 -0
  47. package/src/eval/run.ts +153 -0
  48. package/src/eval/score.ts +275 -0
  49. package/src/roast/doctor.ts +109 -0
  50. package/src/roast/enrich.ts +335 -0
  51. package/src/roast/invariants.ts +65 -0
  52. package/src/roast/llm.ts +601 -0
  53. package/src/roast/material.ts +133 -0
  54. package/src/roast/page.ts +266 -0
  55. package/src/roast/pipeline.ts +981 -0
  56. package/src/roast/precedents.ts +308 -0
  57. package/src/roast/pushback.ts +664 -0
  58. package/src/roast/render.ts +386 -0
  59. package/src/roast/sanitize.ts +140 -0
  60. package/src/roast/schema.ts +714 -0
  61. package/src/roast/tone.ts +216 -0
  62. package/src/thread/fetch.ts +172 -0
  63. package/src/thread/load.ts +227 -0
  64. package/src/thread/local.ts +73 -0
  65. package/src/thread/parse.ts +243 -0
  66. package/src/thread/patchprose.ts +143 -0
  67. package/src/thread/types.ts +120 -0
package/docs/corpus.md ADDED
@@ -0,0 +1,352 @@
1
+ # Precedent corpus (milestone 1)
2
+
3
+ The precedent corpus is a Postgres-backed index of proposals that did not
4
+ land, or that landed and were then reverted. The PG Roast roaster searches it
5
+ to find earlier attempts at an idea. Every row comes from a fetched public
6
+ page or from `git` output. The builder never makes up a link (see
7
+ [Provenance](#provenance-no-fabricated-links)).
8
+
9
+ ```
10
+ bun run src/corpus/cli.ts build [--source cf|reverts|wiki|all] [--limit N] [--offline]
11
+ bun run src/corpus/cli.ts search "<query>" [--before YYYY-MM-DD] [--kind cf,revert,do_not_want] [-n 10] [--json] [--include-undated]
12
+ bun run src/corpus/cli.ts stats
13
+ bun run src/corpus/cli.ts selftest
14
+ bun test # parser unit tests (fixtures in test/fixtures/)
15
+ bunx tsc --noEmit # typecheck (strict)
16
+ ```
17
+
18
+ The same commands are available as package scripts: `bun run corpus:build`,
19
+ `corpus:search`, `corpus:selftest` and `corpus:stats`.
20
+
21
+ ## Layout
22
+
23
+ | path | what |
24
+ |---|---|
25
+ | `src/corpus/fetch.ts` | polite cached HTTP client |
26
+ | `src/corpus/cf.ts` | CommitFest archive, list and patch page parsers, crawl |
27
+ | `src/corpus/reverts.ts` | git revert extractor (reads `$PGROAST_PG_REPO`, default `../postgres` next to this repo) |
28
+ | `src/corpus/wiki.ts` | wiki `Not_Worth_Doing` parser |
29
+ | `src/corpus/util.ts` | HTML text, message-id canonicalisation, touch-point rules, record type |
30
+ | `src/corpus/db.ts` | DSN parsing, schema apply, idempotent upsert |
31
+ | `src/corpus/search.ts` | `searchPrecedents()` and the `Precedent` type (imported by the roaster) |
32
+ | `src/corpus/cli.ts` | CLI and selftest |
33
+ | `sql/schema.sql` | DDL, applied by `build` |
34
+ | `data/cache/<host>/…html` | raw fetched pages (git-ignored) |
35
+
36
+ Runtime: Bun 1.3.5 built-ins only (`Bun.sql`, global `fetch`, `Bun.spawn`).
37
+ The only packages are dev dependencies used for the typecheck
38
+ (`typescript`, `@types/bun`). The builder never calls an LLM.
39
+
40
+ ## Database
41
+
42
+ - Postgres database `pgroast`. The connection comes from `PGROAST_DSN`;
43
+ default `dbname=pgroast host=/tmp`.
44
+ - Both libpq keyword strings and URLs are accepted, for example
45
+ `postgres://localhost/pgroast?host=/tmp`.
46
+ - Bun 1.3.5 forwards an unknown URL parameter such as `?host=` to the server
47
+ as a startup GUC, and the connection fails. For this reason `db.ts` parses
48
+ the DSN itself and turns a socket directory into Bun's `path` option
49
+ (`/tmp/.s.PGSQL.5432`). A non-`/` host uses TCP.
50
+ - `build` creates the schema if it is missing, then upserts on
51
+ `(kind, raw_ref)`, so rebuilds are idempotent. A full (non-`--limit`) build
52
+ of a source also deletes rows of that kind that the source no longer
53
+ produces.
54
+ - `pg_trgm`: `build` runs `create extension if not exists pg_trgm` and, if
55
+ that works, creates a trigram GIN index on `title`. If the extension cannot
56
+ be created, search runs without the similarity signal. On this host
57
+ (2026-11-11) it cannot be created: the running server binary is 18.3, but
58
+ Homebrew already installed 18.6 contrib libraries, and loading fails with
59
+ `Symbol not found: _palloc0_mul`. A server restart onto 18.6 fixes this.
60
+ After that, re-run `build` (or create the extension) to enable it.
61
+
62
+ Export and ship the corpus as one file (committed as `data/precedents.sql.gz`, about 0.5 MiB):
63
+
64
+ ```
65
+ pg_dump -h /tmp -d pgroast --no-owner --no-privileges -t precedent -t build_log | gzip -9 > data/precedents.sql.gz
66
+ createdb pgroast && gunzip -c data/precedents.sql.gz | psql -d pgroast
67
+ ```
68
+
69
+ This is a plain SQL dump, not the custom format, so no role name is
70
+ recorded. The generated columns `tsv` and `tsv_kw` are recomputed on
71
+ restore. After restoring, run `build` once (with or without `--offline`) to
72
+ create pg_trgm if it is available. Only `precedent` and `build_log` are
73
+ dumped. Other tables in the `pgroast` database belong to other components
74
+ (for example `precedent_enrichment`), and `build` never drops or changes
75
+ them.
76
+
77
+ ## Sources
78
+
79
+ ### 1. CommitFest: `kind = 'cf'`
80
+
81
+ - `https://commitfest.postgresql.org/archive/` lists every CommitFest. On
82
+ 2026-11-11 the IDs were 3..63 (61 CFs, 2014-12 to PG20-4). IDs 1 and 2 do
83
+ not exist.
84
+ - For each CF, the builder fetches `/<cf>/?status=6` (Rejected), `?status=7`
85
+ (Returned with feedback) and `?status=8` (Withdrawn). That is 183 list
86
+ pages, crawled in this order: Rejected for all CFs, then RwF, then
87
+ Withdrawn. Rows whose badge does not match the requested status are
88
+ dropped (none did).
89
+ - List pages have the title, ID, tags, authors, reviewers and latest-mail
90
+ time. They have no thread links, no thread start and no close date. That
91
+ is not enough, so every patch page `/patch/<id>/` is fetched once (deduped
92
+ by patch ID). From the patch page:
93
+ - **Final status**: the per-CF status lines are listed newest first; the
94
+ final one is the line with the highest CF ID. A patch whose final status
95
+ is not Rejected, RwF or Withdrawn (later Committed, or still open) is
96
+ dropped and counted in the coverage report.
97
+ - `Emails` gives each thread's `https://www.postgresql.org/message-id/flat/<msgid>`
98
+ (copied from the page), its subject, and "First at <ts> by".
99
+ - `History` gives `date`: the newest line
100
+ "Closed in commitfest X with status: <final>".
101
+ - It also gives authors, reviewers, committer, target version, tags, and
102
+ `Links` (stored in `other_links`).
103
+ - `first_date` = the earliest "First at" across threads. If no thread is
104
+ attached, it is the record's `Created` time.
105
+ - `summary` = title + tags + thread subjects. `objection` and `objectors`
106
+ stay empty at this milestone.
107
+ - If a patch page cannot be fetched, the row is stored from the list page
108
+ only (no threads, no dates; `provenance.source` says so).
109
+
110
+ ### 2. Reverts: `kind = 'revert'`
111
+
112
+ - `git -C $PGROAST_PG_REPO log --grep='^Revert' -i` gives 586 commits on the
113
+ local master. The repo is read only.
114
+ - If the subject starts with "Revert", `status = 'Reverted'` (508 rows).
115
+ - Otherwise the commit is kept only if a commit hash within 120 characters
116
+ after a "revert" word resolves in the repo: `status = 'Partially reverted'`
117
+ (39 rows). The other 39 matches are skipped. In those, "revert…" only
118
+ starts a line in passing.
119
+ - Reverted commits:
120
+ - any 7..40-hex token that `git rev-parse --verify <x>^{commit}` resolves
121
+ (for partial reverts, only tokens near "revert");
122
+ - otherwise, for `Revert "<subject>"`, the latest earlier commit on
123
+ master with exactly that subject.
124
+
125
+ `provenance.reverted_commit_match` says which rule matched. 381 of 547 rows
126
+ resolved a target.
127
+ - `title` = the subject. Many CVS-era commits put the whole message on the
128
+ subject line; when the subject is over 160 characters, `title` is its
129
+ first sentence, and the full subject is still in `summary`.
130
+ - `url` = `https://git.postgresql.org/gitweb/?p=postgresql.git;a=commit;h=<sha>`.
131
+ `other_links` holds the same link for each reverted commit.
132
+ - `thread_urls`: all `postgr.es/m/<id>` and `postgresql.org/message-id/<id>`
133
+ links in the revert message and in the reverted commits' messages,
134
+ converted to `/message-id/flat/<id>`.
135
+ - `objection` = the revert message, word for word, minus the subject and the
136
+ trailers (Discussion:, Backpatch-through:, Reported-by: and so on).
137
+ - `date` = the revert commit date (UTC). `first_date` = the earliest
138
+ reverted commit's date, or the revert date if no target resolved.
139
+ - `authors` = the git authors of the reverted commits. The person who
140
+ reverted is `provenance.reverted_by`. `objectors` stays empty, because the
141
+ person who reverts is often not the person who objected.
142
+
143
+ ### 3. Wiki: `kind = 'do_not_want'`
144
+
145
+ - "Features We Do Not Want" is not on `wiki/Todo`. The Todo page says
146
+ retired items move to `https://wiki.postgresql.org/wiki/Not_Worth_Doing`,
147
+ and the section is `<h2 id="Features_We_Do_Not_Want">` there.
148
+ - One row per item (`<dl><dt>title</dt><dd>text</dd></dl>`):
149
+ - the 6 items under that heading get `status = 'Not wanted'`;
150
+ - the other 65 retired items on the page get `status = 'Not worth doing'`.
151
+ - `url` = `Not_Worth_Doing#<section anchor>`. Headings that contain "/"
152
+ use the URL-safe legacy anchor, for example `Optimizer_.2F_Executor`.
153
+ - `objection` = the item's description. Message-id links go to
154
+ `thread_urls`. Old `archives.postgresql.org/...msgNNNNN.php` links and wiki
155
+ links go to `other_links`.
156
+ - No dates (see `--before`).
157
+
158
+ ## Schema (`sql/schema.sql`)
159
+
160
+ | column | type | meaning |
161
+ |---|---|---|
162
+ | `id` | text PK | `cf:<patch id>`, `revert:<sha>`, `wiki:<anchor>:<n>` |
163
+ | `kind` | text | `cf`, `revert`, `do_not_want` |
164
+ | `raw_ref` | text | stable source key: `patch/<id>`, `<sha>`, `Not_Worth_Doing#<anchor>:<n>`; `unique (kind, raw_ref)` |
165
+ | `title`, `status` | text | status ∈ Rejected, Returned with feedback, Withdrawn, Reverted, Partially reverted, Not wanted, Not worth doing |
166
+ | `date` | date | decision: CF close (history line), revert commit (UTC) |
167
+ | `first_date` | date | thread start (CF), earliest reverted commit (revert) |
168
+ | `url` | text | CF patch page, gitweb commit, wiki anchor |
169
+ | `thread_urls` | text[] | `https://www.postgresql.org/message-id/flat/<msgid>` |
170
+ | `authors`, `reviewers`, `objectors` | text[] | `objectors` empty at this milestone |
171
+ | `tags` | text | CF tags; wiki section |
172
+ | `summary`, `objection` | text | see the sources above |
173
+ | `touch_points` | text[] | keyword rules (below) |
174
+ | `other_links` | text[] | other real URLs found in the source |
175
+ | `provenance` | jsonb | source, cache URL, exact timestamps (`decided_at`, `first_at`), CF history, reverted commits |
176
+ | `tsv` | tsvector, stored generated | `title` weight A, `summary \|\| tags` weight B, `objection` weight C (`english`); GIN index |
177
+ | `tsv_kw` | tsvector, stored generated | `title` in the `simple` config (keeps SQL keywords that `english` drops); GIN index |
178
+
179
+ `build_log(kind, built_at, coverage jsonb)` stores the coverage report of
180
+ the last build of each source.
181
+
182
+ **Touch points** come from fixed regexes over the title, tags and subjects
183
+ (plus the commit body for reverts and the description for wiki rows). The
184
+ buckets are pg_dump/restore, psql, GUC, WAL/replication, planner,
185
+ protocol/libpq, catalog, on-disk/pg_upgrade, security, SQL syntax,
186
+ ABI/extension, and hot-path performance. They are rough hints: for example,
187
+ "plan" and "join" trigger planner, and "slot" triggers replication.
188
+
189
+ ## Search
190
+
191
+ `searchPrecedents({query, before?, kinds?, limit?, includeUndated?})` in
192
+ `src/corpus/search.ts` returns `Precedent[]` (snake_case fields; dates as
193
+ `YYYY-MM-DD`; `match` ∈ `and | or | trgm`).
194
+
195
+ 1. **AND path**: `websearch_to_tsquery('english', q)`, ranked by
196
+ `ts_rank_cd(tsv, q, 1)`. All terms must match.
197
+ 2. **OR fallback**, if the AND path returns fewer than `limit` rows: an
198
+ IDF-weighted term-coverage score. The terms are:
199
+ - each `english` lexeme of the query, prefix-matched and expanded with a
200
+ small curated synonym map (`SYNONYMS`: planner↔optim(izer);
201
+ pooler/pool↔pgbouncer, pgpool; posit(ion)↔order, reorder, attnum;
202
+ timestamp→time, date). Weak words are dropped: add, support, allow, new,
203
+ option, use, make, patch, feature, and `pg` (from `pg_dump`, `pg_ctl`…);
204
+ - SQL keywords that `english` drops as stop words (where, having, between,
205
+ into, only, any, all, off, over), matched against `tsv_kw`, the title in
206
+ the `simple` config. Without this, "pg_dump WHERE" could never find
207
+ "pg_dump --where option";
208
+ - underscore identifiers in the query (`pg_dump`, `shared_buffers`), as a
209
+ phrase on `tsv_kw` (`'pg' <-> 'dump'`).
210
+
211
+ Score = Σ over terms of `ln((N+1)/(df+1))` × (matches anywhere + matches
212
+ in the title), plus `ts_rank_cd`. Rare terms such as "pooler" count more
213
+ than common ones such as "table". Terms are built only from lexemes,
214
+ keywords and identifiers matching `[a-z0-9]+`, so user text never reaches
215
+ tsquery syntax.
216
+
217
+ OR rows come after all AND rows. Their scores are on a different scale
218
+ from AND scores; only the order matters.
219
+ 3. **pg_trgm** (when installed): `0.5 * similarity(title, q)` is added to
220
+ the score. Titles with similarity > 0.3 fill any remaining slots.
221
+
222
+ The user query is always a bind parameter. Results are stored rows only, so
223
+ every URL printed was read from a source. If nothing matches, the CLI prints
224
+ "no precedent found".
225
+
226
+ ### The `--before` rule (leak-free evaluation)
227
+
228
+ With `--before D`, a row is kept only if **every date it has is strictly
229
+ before D**:
230
+
231
+ ```
232
+ coalesce(first_date, date) < D and coalesce(date, first_date) < D
233
+ ```
234
+
235
+ - A CF entry whose thread started before D but was closed on or after D is
236
+ excluded, because the rejection had not happened yet.
237
+ - A commit made before D but reverted on or after D is excluded.
238
+ - The proposal under evaluation is itself excluded, because its first date
239
+ is not before its own cutoff.
240
+ - Rows with **no date** (all wiki rows, and any CF row built from the list
241
+ page only) are excluded unless `--include-undated` / `includeUndated: true`
242
+ is given. The wiki does not say when an item was added.
243
+ - Dates are calendar dates. CF times are as shown on the site, which is
244
+ probably UTC. Git times are converted to UTC. The exact timestamps are in
245
+ `provenance.decided_at` / `first_at`.
246
+
247
+ ## Provenance: no fabricated links
248
+
249
+ Links are only ever built from an identifier read from a source:
250
+
251
+ - CF `url` = fixed prefix + a patch ID from an `href="/patch/<id>/"` on a
252
+ fetched list page;
253
+ - revert `url` = fixed prefix + a SHA printed by `git log`;
254
+ - wiki `url` = page URL + an `id="..."` anchor from the fetched HTML;
255
+ - `thread_urls` = `…/message-id/flat/` + a message-id copied from a link on
256
+ the fetched page or in the commit message. It is normalised by
257
+ percent-decoding and then re-quoting with one fixed safe set.
258
+
259
+ `selftest` checks the following:
260
+
261
+ 1. The DB is reachable. The schema is present: table, stored generated
262
+ `tsv`, GIN index, `unique (kind, raw_ref)`. There are rows of all three
263
+ kinds and no empty tsvectors.
264
+ 2. Search returns rows for "optimizer hints". A hostile query does not raise
265
+ an error. `--before 2010-01-01` returns only rows dated before the cutoff
266
+ and no undated rows.
267
+ 3. In SQL, `url` matches the per-kind regex and is consistent with
268
+ `raw_ref`. Every thread URL matches
269
+ `^https://www.postgresql.org/message-id/flat/<x>@<y>$`. Every
270
+ `other_links` value is an http(s) URL.
271
+ 4. Provenance, per row:
272
+ - CF: the patch ID appears in the cached page, and each thread
273
+ message-id appears as a `message-id/flat/` link on the cached patch
274
+ page;
275
+ - revert: `git show` of the commit and its reverted commits succeeds, and
276
+ each message-id appears in those messages;
277
+ - wiki: the anchor exists on the cached page, and each message-id appears
278
+ on it.
279
+
280
+ Negative test: on a scratch copy of the DB, a made-up thread URL, a wrong CF
281
+ patch URL and a non-wiki URL were injected. `selftest` reported all of them
282
+ and exited with status 1.
283
+
284
+ ## Crawl etiquette
285
+
286
+ - User-Agent `pgroast-research/0.1`, GET only, no sign-in, no cookies.
287
+ - At least 1.05 s between requests to the same host.
288
+ - Every 200 response is cached under `data/cache/<host>/` and is never
289
+ fetched again. A 404 leaves a `.404` marker.
290
+ - On 429 or 5xx: honour `Retry-After`, otherwise exponential back-off from
291
+ 5 s to 300 s, at most 6 tries.
292
+ - `--offline` builds from the cache only.
293
+ - The cache file names are the same as the earlier Python prototype's, so
294
+ pages it crawled were reused, not fetched again. The full crawl
295
+ (2026-11-11) made 2,118 requests in about 95 minutes, with no 429 or 5xx
296
+ responses and no failures.
297
+
298
+ ## Known gaps
299
+
300
+ - **No pgsql-hackers crawl yet.** Only threads linked from CF entries,
301
+ commits and the wiki are indexed, and thread text is not fetched.
302
+ `objection` and `objectors` are empty for CF rows.
303
+ - **CF coverage starts at 2014-12** (CF ID 3). Older ideas appear only
304
+ through reverts or the wiki.
305
+ - **Final-status filter:** a patch that was returned with feedback and later
306
+ committed or reopened is dropped, so the earlier feedback is lost.
307
+ - **Stale cache for open CFs:** list pages for open or in-progress CFs (60,
308
+ 62, 63) were cached on 2026-11-11. Delete those cache files to pick up later
309
+ closes.
310
+ - **Sparse CF tags:** old patches have none, because the CF app replaced
311
+ "Topic" with tags. `summary` is mostly the title + thread subjects.
312
+ - **Unresolved reverts:** 166 revert rows name no resolvable reverted
313
+ commit, mostly from before about 2014. One example is "This reverts commits
314
+ 24c35ec and 57169ad", where neither hash is on master. These rows keep
315
+ only the revert's own message and links.
316
+ - **Wiki rows are undated**, so `--before` hides them unless
317
+ `--include-undated` is given.
318
+ - **Touch points** are keyword hints. They both over-match and
319
+ under-match.
320
+ - **pg_trgm is unavailable on this host** until the server is restarted
321
+ (see [Database](#database)).
322
+ - **Lexical search only**, with no synonyms. "connection pooler" does not
323
+ find "pgbouncer" unless that word is in the text.
324
+
325
+ ## Build results (2026-11-11)
326
+
327
+ | kind | status | rows | with thread URL | first_date range | date range |
328
+ |---|---|---:|---:|---|---|
329
+ | cf | Rejected | 251 | 234 | 2011-10-07..2026-09-29 | 2015-01-12..2026-10-20 |
330
+ | cf | Returned with feedback | 1116 | 1002 | 2008-05-31..2026-08-05 | 2015-04-30..2026-11-09 |
331
+ | cf | Withdrawn | 562 | 538 | 2016-05-06..2026-11-08 | 2019-01-04..2026-11-10 |
332
+ | revert | Reverted | 508 | 255 | 1999-02-08..2026-08-25 | 1999-02-08..2026-08-25 |
333
+ | revert | Partially reverted | 39 | 28 | 1999-03-14..2026-03-30 | 2011-11-22..2026-05-15 |
334
+ | do_not_want | Not wanted | 6 | 2 | – | – |
335
+ | do_not_want | Not worth doing | 65 | 11 | – | – |
336
+ | **total** | | **2547** | **2070** | | |
337
+
338
+ CommitFest coverage:
339
+
340
+ - 61 CFs (IDs 3..63);
341
+ - 183 of 183 list pages;
342
+ - 1,933 unique patches;
343
+ - 1,933 of 1,933 patch pages fetched (0 failed, 0 unparsed).
344
+
345
+ 1,929 patches are stored. The other 4 have a final status of "Moved to
346
+ different CF" in their highest CF and are dropped.
347
+
348
+ Every CF row has `date` and `first_date`. 573 of the CF rows have at least
349
+ one touch point.
350
+
351
+ `selftest`: 2,547 rows; 0 URL violations; 2,464 thread links checked
352
+ against their source.