Files
gbrain/test/by-mention.test.ts
T
0413c93e72 feat: CJK entity extraction for Chinese/Japanese/Korean names (#1637)
* feat: CJK entity extraction for Chinese/Japanese/Korean names

- Add hasCJK() / cjkCharCount() detection helpers
- Lower min name length for CJK entities from 4 to 2 chars
- Fix tokenizeTitle() to handle pure CJK titles as single tokens
  (was returning [] for CJK-only titles, excluding them from gazetteer)
- Add CJK substring matching pass in findMentionedEntities
- NER extraction works without schema pack (plain mentions fallback)

Verified: gbrain extract links --by-mention creates 27 links from
456 pages with 3 CJK entity pages in gazetteer.

* feat: Chinese link type inference + timeline date formats

Link types:
- CN_FOUNDED_RE: 创立/创办/成立/创建 → founded
- CN_INVESTED_RE: 投资/入股/融资 → invested_in
- CN_ADVISES_RE: 顾问/咨询/指导 → advises
- CN_WORKS_AT_RE: 任职/就职/担任 → works_at
- CN_CITED_RE: 引用/提到/提及 → cited

Timeline:
- TIMELINE_LINE_RE_CN: YYYY年M月D日 | event
- Auto-normalizes to YYYY-MM-DD format
- Falls through to English format if CN doesn't match

* fix: CJK tokenizer uses char-level tokens (reviewer feedback)

Addresses all 4 concerns from review of PR #1637:

1. tokenizeForScan now emits CJK characters as individual tokens
   — normal scan path reaches CJK gazetteer entries naturally,
     eliminating the separate O(P×C×N) substring fallback pass.

2. tokenizeTitle splits pure CJK titles into individual chars
   — e.g. '纳瓦尔' → ['纳','瓦','尔'], matching body-level CJK tokens.

3. Removed O(P×C×N) CJK substring pass — no longer needed.
   Performance now O(P × N_tokens) for both ASCII and CJK.

4. Renamed CN_*_RE → ZH_*_RE in link-extraction.ts with a comment
   clarifying these are Chinese-only (entity NAME extraction in
   by-mention.ts covers CJK scripts, link TYPE extraction is zh only).

Added 12 CJK-specific tests (10 pure + 2 engine integration).
All 51 existing + new tests pass.

* review-repair(#1637): scope CN timeline regex to 年月日, revert off-scope extract-ner no-pack change, cosmetics

- TIMELINE_LINE_RE_CN required only [年-] separators, so non-bold ASCII
  dates (- 2020-01-02 - text) started parsing as timeline entries — an
  English-default regression. Now requires the 年/月 markers.
- Dropped the dead 'm = cm as any' assignment.
- src/core/extract-ner.ts reverted to origin/master: the no-pack →
  plain-mentions walk was off-scope for a CJK PR, duplicated the
  existing --by-mention pass, and hardcoded pack_unavailable:false
  (breaking the CLI hint).
- by-mention.ts: fixed stray indentation + restored EOF newline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Time Attakc <89218912+time-attack@users.noreply.github.com>
2026-07-27 18:03:31 -07:00

525 lines
22 KiB
TypeScript

/**
* Unit tests for src/core/by-mention.ts.
*
* Pure-function coverage of `findMentionedEntities` + `buildGazetteer`.
* Hermetic via PGLite for buildGazetteer (needs engine); pure-fn cases
* for findMentionedEntities (no engine needed).
*
* Covers all 20 cases enumerated in the v0.42.0.0 plan:
* 1. Single-token title match
* 2. Multi-word phrase pass ("Acme Corp" matches "Acme Corp" not "Acme")
* 3. Case folding
* 4. Whole-word boundary
* 5. Possessive form
* 6. Code-block stripping
* 7. Min-length filter
* 8. Ignore-list at gazetteer build (Apple suppressed when no page)
* 9. Ignore-list inverse (Apple matches when page exists)
* 10. First-mention-only cap
* 11. Empty gazetteer
* 12. Empty text
* 13. All entity pages soft-deleted → empty gazetteer
* 14. Multi-word shared first token (longest-match wins)
* 15. Determinism across 10 calls
* 16. Self-link guard (D13)
* 17. Cross-source guard
* 18. Hardcoded type filter (meeting NOT in gazetteer)
* 19. Min-length + ignore-list interaction
* 20. Code-block + token interaction
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import {
buildGazetteer,
findMentionedEntities,
LINKABLE_ENTITY_TYPES,
type Gazetteer,
type GazetteerEntry,
} from '../src/core/by-mention.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
}, 60_000);
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await engine.executeRaw('DELETE FROM links');
await engine.executeRaw('DELETE FROM pages');
});
// Tiny gazetteer builder for pure-fn cases that don't need engine.
function gazetteerFromEntries(entries: Omit<GazetteerEntry, 'tokens'>[]): Gazetteer {
const TOKEN_RE = /[a-zA-Z0-9]+/g;
const isCJK = (s: string): boolean => {
const cp = s.codePointAt(0) ?? 0;
return (cp >= 0x4e00 && cp <= 0x9fff) || (cp >= 0x3400 && cp <= 0x4dbf) ||
(cp >= 0x3040 && cp <= 0x309f) || (cp >= 0x30a0 && cp <= 0x30ff) ||
(cp >= 0xac00 && cp <= 0xd7af);
};
const hasCJKTitle = (s: string): boolean => [...s].some(isCJK);
const tokenize = (s: string): string[] => {
TOKEN_RE.lastIndex = 0;
if (!hasCJKTitle(s)) {
const out: string[] = [];
let m: RegExpExecArray | null;
while ((m = TOKEN_RE.exec(s)) !== null) out.push(m[0].toLowerCase());
return out;
}
// CJK: split into individual characters, lowercased.
return [...s].map(c => isCJK(c) ? c.toLowerCase() : '').filter(Boolean);
};
const g: Gazetteer = new Map();
for (const raw of entries) {
const tokens = tokenize(raw.title);
if (tokens.length === 0) continue;
const key = tokens[0]!;
const entry: GazetteerEntry = { ...raw, tokens };
const bucket = g.get(key);
if (bucket) bucket.push(entry);
else g.set(key, [entry]);
}
for (const bucket of g.values()) bucket.sort((a, b) => b.tokens.length - a.tokens.length);
return g;
}
// ============================================================
// findMentionedEntities — pure unit tests
// ============================================================
describe('findMentionedEntities — pure cases', () => {
test('1. single-token title match', () => {
const g = gazetteerFromEntries([
{ slug: 'companies/acme', source_id: 'default', title: 'Acme' },
]);
const mentions = findMentionedEntities('Acme launched today.', g, {
fromSlug: 'writing/post-1', fromSourceId: 'default',
});
expect(mentions).toHaveLength(1);
expect(mentions[0]!.slug).toBe('companies/acme');
expect(mentions[0]!.name).toBe('Acme');
expect(mentions[0]!.offset).toBe(0);
});
test('2. multi-word phrase pass — "Acme Corp" matches multi-word, not single', () => {
const g = gazetteerFromEntries([
{ slug: 'companies/acme', source_id: 'default', title: 'Acme' },
{ slug: 'companies/acme-corp', source_id: 'default', title: 'Acme Corp' },
]);
const mentions = findMentionedEntities('We met with Acme Corp last week.', g, {
fromSlug: 'writing/post-1', fromSourceId: 'default',
});
// longest-match wins → only the multi-word target
expect(mentions).toHaveLength(1);
expect(mentions[0]!.slug).toBe('companies/acme-corp');
});
test('3. case folding — "iOS Engineer" title matches "ios engineer" in body', () => {
const g = gazetteerFromEntries([
{ slug: 'people/ios-engineer', source_id: 'default', title: 'iOS Engineer' },
]);
const mentions = findMentionedEntities('Looking to hire an ios engineer.', g, {
fromSlug: 'writing/post-1', fromSourceId: 'default',
});
expect(mentions).toHaveLength(1);
expect(mentions[0]!.slug).toBe('people/ios-engineer');
});
test('4. whole-word boundary — "Acme" matches "Acme." but NOT "Acmecorp"', () => {
const g = gazetteerFromEntries([
{ slug: 'companies/acme', source_id: 'default', title: 'Acme' },
]);
// Should match (sentence-ending dot is a token break)
const m1 = findMentionedEntities('We bought Acme.', g, {
fromSlug: 'writing/post-1', fromSourceId: 'default',
});
expect(m1).toHaveLength(1);
// Should NOT match — "Acmecorp" tokenizes as single token "acmecorp"
const m2 = findMentionedEntities('Acmecorp is unrelated.', g, {
fromSlug: 'writing/post-1', fromSourceId: 'default',
});
expect(m2).toHaveLength(0);
});
test('5. possessive form — "Acme\'s growth" → Acme matches', () => {
const g = gazetteerFromEntries([
{ slug: 'companies/acme', source_id: 'default', title: 'Acme' },
]);
const mentions = findMentionedEntities("Acme's growth is impressive.", g, {
fromSlug: 'writing/post-1', fromSourceId: 'default',
});
expect(mentions).toHaveLength(1);
expect(mentions[0]!.slug).toBe('companies/acme');
});
test('6. code-block stripping — mentions inside ``` blocks ignored', () => {
const g = gazetteerFromEntries([
{ slug: 'companies/acme', source_id: 'default', title: 'Acme' },
]);
const body = '```\nAcme code\n```\nNothing here.';
const mentions = findMentionedEntities(body, g, {
fromSlug: 'writing/post-1', fromSourceId: 'default',
});
expect(mentions).toHaveLength(0);
});
test('10. first-mention-only cap — 5 body mentions of same entity → 1 link', () => {
const g = gazetteerFromEntries([
{ slug: 'companies/acme', source_id: 'default', title: 'Acme' },
]);
const body = 'Acme one. Acme two. Acme three. Acme four. Acme five.';
const mentions = findMentionedEntities(body, g, {
fromSlug: 'writing/post-1', fromSourceId: 'default',
});
expect(mentions).toHaveLength(1);
});
test('11. empty gazetteer → empty result', () => {
const g: Gazetteer = new Map();
const mentions = findMentionedEntities('Anything goes here.', g, {
fromSlug: 'writing/post-1', fromSourceId: 'default',
});
expect(mentions).toEqual([]);
});
test('12. empty text → empty result', () => {
const g = gazetteerFromEntries([
{ slug: 'companies/acme', source_id: 'default', title: 'Acme' },
]);
const mentions = findMentionedEntities('', g, {
fromSlug: 'writing/post-1', fromSourceId: 'default',
});
expect(mentions).toEqual([]);
});
test('14. multi-word shared first token — longest-match wins', () => {
const g = gazetteerFromEntries([
{ slug: 'companies/acme', source_id: 'default', title: 'Acme' },
{ slug: 'companies/acme-corp', source_id: 'default', title: 'Acme Corp' },
{ slug: 'companies/acme-foundation', source_id: 'default', title: 'Acme Foundation' },
]);
const body = 'Acme Foundation announced. Then Acme Corp. Then plain Acme.';
const mentions = findMentionedEntities(body, g, {
fromSlug: 'writing/post-1', fromSourceId: 'default',
});
// First offset: "Acme Foundation" — longest match wins.
// Second occurrence of "Acme": multi-word "Acme Corp" matches → multi-word wins.
// Third: plain "Acme" alone — single-word match.
const slugs = mentions.map(m => m.slug);
expect(slugs).toContain('companies/acme-foundation');
expect(slugs).toContain('companies/acme-corp');
expect(slugs).toContain('companies/acme');
});
test('15. determinism — same body + same gazetteer → identical output across 10 calls', () => {
const g = gazetteerFromEntries([
{ slug: 'companies/acme', source_id: 'default', title: 'Acme' },
{ slug: 'companies/acme-corp', source_id: 'default', title: 'Acme Corp' },
{ slug: 'people/alice', source_id: 'default', title: 'Alice Smith' },
]);
const body = 'Acme Corp and Alice Smith and Acme met. Then Alice Smith again.';
const refs = new Set<string>();
for (let i = 0; i < 10; i++) {
const mentions = findMentionedEntities(body, g, {
fromSlug: 'writing/post-1', fromSourceId: 'default',
});
refs.add(JSON.stringify(mentions));
}
expect(refs.size).toBe(1);
});
test('16. self-link guard (D13) — entity page mentioning own title skips', () => {
const g = gazetteerFromEntries([
{ slug: 'companies/acme', source_id: 'default', title: 'Acme' },
]);
// Page IS the Acme page; body mentions "Acme" → self-link guard skips.
const mentions = findMentionedEntities('Acme has 500 customers.', g, {
fromSlug: 'companies/acme', fromSourceId: 'default',
});
expect(mentions).toEqual([]);
});
test('17. cross-source guard — page in source A mentions entity in source B → no link', () => {
const g = gazetteerFromEntries([
{ slug: 'companies/acme', source_id: 'team-b', title: 'Acme' },
]);
const mentions = findMentionedEntities('We met Acme today.', g, {
fromSlug: 'writing/post-1', fromSourceId: 'team-a', // different source
});
expect(mentions).toEqual([]);
});
test('20. code-block + token interaction — body text outside block linked, inside skipped', () => {
const g = gazetteerFromEntries([
{ slug: 'companies/acme', source_id: 'default', title: 'Acme' },
]);
// Single backtick inline-code blocks the inner mention; outer mention fires.
const body = 'Outside: Acme works. Inline `Acme inside` should skip. After.';
const mentions = findMentionedEntities(body, g, {
fromSlug: 'writing/post-1', fromSourceId: 'default',
});
expect(mentions).toHaveLength(1); // first-mention-only cap
expect(mentions[0]!.slug).toBe('companies/acme');
});
});
// ============================================================
// CJK — entity extraction tests
// ============================================================
describe('findMentionedEntities — CJK cases', () => {
test('CJK single-name match — "纳瓦尔" in body → matched', () => {
const g = gazetteerFromEntries([
{ slug: 'people/naval', source_id: 'default', title: '纳瓦尔' },
]);
const mentions = findMentionedEntities('我最近读了纳瓦尔的书。', g, {
fromSlug: 'writing/post-1', fromSourceId: 'default',
});
expect(mentions).toHaveLength(1);
expect(mentions[0]!.slug).toBe('people/naval');
expect(mentions[0]!.name).toBe('纳瓦尔');
});
test('CJK multi-name — two different CJK entities in one body', () => {
const g = gazetteerFromEntries([
{ slug: 'people/naval', source_id: 'default', title: '纳瓦尔' },
{ slug: 'people/shuang-xuetao', source_id: 'default', title: '双雪涛' },
]);
const mentions = findMentionedEntities('纳瓦尔和双雪涛都是作家。', g, {
fromSlug: 'writing/post-1', fromSourceId: 'default',
});
expect(mentions).toHaveLength(2);
const slugs = mentions.map(m => m.slug);
expect(slugs).toContain('people/naval');
expect(slugs).toContain('people/shuang-xuetao');
});
test('CJK first-mention-only — repeated name → single link', () => {
const g = gazetteerFromEntries([
{ slug: 'people/naval', source_id: 'default', title: '纳瓦尔' },
]);
const mentions = findMentionedEntities('纳瓦尔说过。然后纳瓦尔又说过。', g, {
fromSlug: 'writing/post-1', fromSourceId: 'default',
});
expect(mentions).toHaveLength(1);
});
test('CJK self-link guard — entity page mentioning itself is skipped', () => {
const g = gazetteerFromEntries([
{ slug: 'people/naval', source_id: 'default', title: '纳瓦尔' },
]);
const mentions = findMentionedEntities('纳瓦尔是一位投资人。', g, {
fromSlug: 'people/naval', fromSourceId: 'default',
});
expect(mentions).toEqual([]);
});
test('CJK cross-source guard — entity in different source skipped', () => {
const g = gazetteerFromEntries([
{ slug: 'people/naval', source_id: 'team-b', title: '纳瓦尔' },
]);
const mentions = findMentionedEntities('纳瓦尔写了这本书。', g, {
fromSlug: 'writing/post-1', fromSourceId: 'team-a',
});
expect(mentions).toEqual([]);
});
test('CJK code-block stripping — CJK name inside ``` is skipped, outside matched', () => {
const g = gazetteerFromEntries([
{ slug: 'people/naval', source_id: 'default', title: '纳瓦尔' },
]);
// "纳瓦尔" only appears inside code block → should be skipped.
const body = '```\n纳瓦尔\n```\n只有代码块里面有。';
const mentions = findMentionedEntities(body, g, {
fromSlug: 'writing/post-1', fromSourceId: 'default',
});
expect(mentions).toHaveLength(0);
});
test('CJK determinism — same output across 10 calls', () => {
const g = gazetteerFromEntries([
{ slug: 'people/naval', source_id: 'default', title: '纳瓦尔' },
{ slug: 'people/shuang-xuetao', source_id: 'default', title: '双雪涛' },
]);
const body = '纳瓦尔和双雪涛。纳瓦尔再说一次。';
const refs = new Set<string>();
for (let i = 0; i < 10; i++) {
const mentions = findMentionedEntities(body, g, {
fromSlug: 'writing/post-1', fromSourceId: 'default',
});
refs.add(JSON.stringify(mentions));
}
expect(refs.size).toBe(1);
});
test('CJK mixed body — CJK entity matched in body with ASCII around it', () => {
const g = gazetteerFromEntries([
{ slug: 'people/naval', source_id: 'default', title: '纳瓦尔' },
{ slug: 'companies/acme', source_id: 'default', title: 'Acme' },
]);
const mentions = findMentionedEntities('Acme was founded by 纳瓦尔 in 2020.', g, {
fromSlug: 'writing/post-1', fromSourceId: 'default',
});
expect(mentions).toHaveLength(2);
const slugs = mentions.map(m => m.slug);
expect(slugs).toContain('people/naval');
expect(slugs).toContain('companies/acme');
});
test('CJK empty gazetteer — no false positives', () => {
const g: Gazetteer = new Map();
const mentions = findMentionedEntities('纳瓦尔和双雪涛。', g, {
fromSlug: 'writing/post-1', fromSourceId: 'default',
});
expect(mentions).toEqual([]);
});
test('CJK empty text → empty result', () => {
const g = gazetteerFromEntries([
{ slug: 'people/naval', source_id: 'default', title: '纳瓦尔' },
]);
const mentions = findMentionedEntities('', g, {
fromSlug: 'writing/post-1', fromSourceId: 'default',
});
expect(mentions).toEqual([]);
});
});
// ============================================================
// buildGazetteer — engine-backed tests
// ============================================================
describe('buildGazetteer — engine integration', () => {
test('7. min-length filter — title "AI" (length 2) not in gazetteer', async () => {
await engine.putPage('companies/ai', {
type: 'company', title: 'AI', compiled_truth: 'b', timeline: '', frontmatter: {},
});
await engine.putPage('companies/acme', {
type: 'company', title: 'Acme', compiled_truth: 'b', timeline: '', frontmatter: {},
});
const g = await buildGazetteer(engine);
expect(g.has('acme')).toBe(true);
expect(g.has('ai')).toBe(false);
});
test('8. ignore-list at build — "Apple" suppressed when no companies/apple page', async () => {
// Seed a different entity page named "Apple" — but importantly NO
// companies/apple slug exists. Wait — actually the ignore-list keys
// on TITLE not slug. So even a non-companies slug with title="Apple"
// would be in the gazetteer because `existingTitles.has("Apple")` is true.
// The ignore list only fires when NO row has title="Apple". To exercise
// suppression: seed no entity with title="Apple" — duh, then there's
// nothing in the gazetteer for Apple anyway. The ignore-list rule is
// only meaningful if a HYPOTHETICAL entity named "Apple" would otherwise
// appear; in practice, the ignore-list short-circuits ANY row whose
// title is in the ignore set AND whose title isn't in existingTitles.
// For a deterministic test: seed one entity with title="Apple" and
// verify it IS in the gazetteer (per CK12 inverse rule); seed another
// run with no Apple entity and verify the ignore-list doesn't add one.
// Both behaviors covered by the existingTitles vs ignore_set logic.
await engine.putPage('people/alice', {
type: 'person', title: 'Alice Example', compiled_truth: 'b', timeline: '', frontmatter: {},
});
const g = await buildGazetteer(engine);
// No Apple entity seeded → 'apple' not a gazetteer key (trivially).
expect(g.has('apple')).toBe(false);
// Alice IS in gazetteer.
expect(g.has('alice')).toBe(true);
});
test('9. ignore-list inverse — title "Apple" matches when companies/apple exists (CK12)', async () => {
await engine.putPage('companies/apple', {
type: 'company', title: 'Apple', compiled_truth: 'b', timeline: '', frontmatter: {},
});
const g = await buildGazetteer(engine);
// existingTitles has "Apple" so the ignore-list does NOT suppress;
// gazetteer presence wins per CK12 rule.
expect(g.has('apple')).toBe(true);
expect(g.get('apple')![0]!.slug).toBe('companies/apple');
});
test('13. all entity pages soft-deleted → empty gazetteer', async () => {
await engine.putPage('people/alice', {
type: 'person', title: 'Alice Example', compiled_truth: 'b', timeline: '', frontmatter: {},
});
await engine.softDeletePage('people/alice');
const g = await buildGazetteer(engine);
expect(g.size).toBe(0);
});
test('18. hardcoded type filter — page with type=meeting NOT in gazetteer', async () => {
await engine.putPage('meetings/2026-01-15', {
type: 'meeting' as any, title: 'Weekly Sync',
compiled_truth: 'b', timeline: '', frontmatter: {},
});
await engine.putPage('people/bob', {
type: 'person', title: 'Robert Builder', compiled_truth: 'b', timeline: '', frontmatter: {},
});
const g = await buildGazetteer(engine);
expect(g.has('weekly')).toBe(false); // meeting type filtered out
expect(g.has('robert')).toBe(true); // person type included
});
test('19. min-length + ignore-list interaction — "YC" (2 chars) filtered by min-length BEFORE ignore-list', async () => {
// YC isn't in DEFAULT_IGNORE_LIST. But "Box" (3 chars) is. "Box" length
// = 3 < MIN_NAME_LENGTH (4), so it's filtered by min-length first. The
// ignore-list never fires. We test the regression that the min-length
// gate runs BEFORE the ignore-list (so adding Box to ignore-list
// doesn't accidentally change the filter ordering).
await engine.putPage('companies/box', {
type: 'company', title: 'Box', compiled_truth: 'b', timeline: '', frontmatter: {},
});
const g = await buildGazetteer(engine);
// "Box" is 3 chars → min-length filter drops it (whether or not in ignore-list).
expect(g.has('box')).toBe(false);
});
test('extraIgnore — user-supplied additional ignore tokens', async () => {
await engine.putPage('people/john', {
type: 'person', title: 'John', compiled_truth: 'b', timeline: '', frontmatter: {},
});
// No companies/john exists, so adding John to extraIgnore should suppress.
const g1 = await buildGazetteer(engine);
expect(g1.has('john')).toBe(true); // baseline: in gazetteer
const g2 = await buildGazetteer(engine, { extraIgnore: ['John'] });
// But title "John" IS the entity title — existingTitles.has('John') is true.
// Per CK12 rule, gazetteer presence wins → John IS still in.
expect(g2.has('john')).toBe(true);
});
test('LINKABLE_ENTITY_TYPES exposes the hardcoded contract', () => {
// Regression: if anyone changes the hardcoded type list, this test
// forces a deliberate change (and a corresponding test update).
expect(LINKABLE_ENTITY_TYPES).toEqual(['person', 'company', 'organization', 'entity']);
});
// CJK — engine-backed tests
test('CJK entity with 2-char title enters gazetteer with char-level tokens', async () => {
await engine.putPage('people/naval', {
type: 'person', title: '纳瓦尔', compiled_truth: 'b', timeline: '', frontmatter: {},
});
const g = await buildGazetteer(engine);
// "纳瓦尔" tokenized as ["纳","瓦","尔"] → key is "纳"
expect(g.has('纳')).toBe(true);
const bucket = g.get('纳')!;
expect(bucket.length).toBe(1);
expect(bucket[0]!.tokens).toEqual(['纳', '瓦', '尔']);
expect(bucket[0]!.slug).toBe('people/naval');
});
test('CJK single-char title (cjkCharCount < 2) excluded from gazetteer', async () => {
await engine.putPage('people/x', {
type: 'person', title: '谢', compiled_truth: 'b', timeline: '', frontmatter: {},
});
const g = await buildGazetteer(engine);
expect(g.size).toBe(0);
});
});