ガイド 第32

マッチング & ディレクトリ

@event/networking

@event/networking は名鑑(ディレクトリ)を支える3系統の純ライブラリである: entity-linking(自由テキスト/名称 → 既知エンティティ)・privacy 境界(consent gating / 公開射影 / k匿名)・registry スキーマ factory(PII 最小の drizzle テーブル)。UI と I/O は同梱しない — 辞書も subject もすべて呼び出し側が注入する。spikes-lp の SPIKES GUILD(全国学生団体DB)から guild 固有語彙を剥がして一般化した版である。

entity-linking — なぜ辞書に閉じるか

自由記述の氏名・団体名を既知エンティティへ解決する(名寄せ)。核となる安全要件は 候補を辞書に限定すること。未知の文字列を新規エンティティとして自動採用しない。buildDictionary(entries)DictEntry[]id / canonicalName / aliases? / contextTerms?)から検索用 Dictionary を作り、matchName(rawName, dict, opts) が 1 件を解決する。

正規化 normalizeName が土台で、NFKC → 法人格トークン除去(DEFAULT_LEGAL_TOKENS: 株式会社/一般社団法人/㈱…)→ 記号除去 → 空白畳みで key(厳格)と loose(装飾語も除去)を作る。照合は多段で走る:

  • exact / alias — 正規化キー完全一致(canonical か alias か)。score 100
  • loose — 装飾語除去後の一致(「○○起業部」↔「○○」)。score 92
  • substring — 一方が他方を内包。長さ比で 60–95 に減点
  • fuzzyfuzzyScore = Jaro-Winkler と Dice の重み付き平均(0.55jw + 0.45dice)。JW は接頭辞・タイプミスに強く、Dice(bigram 重なり)は語順・挿入に頑健なので補完的に混ぜる

スコア(0–100)で3関門に振り分ける:

score ≥ AUTO_MIN(85)         → decision "auto"   (自動採用)
REVIEW_MIN(40) ≤ score < 85  → decision "review" (人手レビュー = review queue へ)
score < REVIEW_MIN(40)       → decision "discard"(破棄)

matchName の戻り値 MatchResult{ query, best, candidates, decision, ambiguous, reason }reason は人が読める判定根拠で、そのまま review queue の表示に使う。

誤マッチを止める3つの不変条件

  1. 候補は辞書限定。未知エンティティは決して auto にしない。
  2. ambiguous 短名(King / MEC 等、正規化キー長 ≤ SHORT_KEY_MAX_LEN(4)ambiguousKeys に登録)は、完全一致でも文脈証拠が無ければ auto にしない。AMBIGUOUS_MIN(90) 以上 かつ 辞書側 contextTerms と入力 opts.contextTerms が交差して初めて auto になる。文脈が無ければ最大でも review に降格する。
  3. 氏名/自由テキストだけで確定しないlinkFromText(text, dict) は本文を正規化して辞書表記の部分文字列一致を厳格に見つつ lowTrust を強制する。完全一致以外は最大 review、短い完全一致(key 長 < 4)の auto も review へ降格、複数ヒットは同点ガードで review に落とす。

同点ガード: 上位2候補のスコア差が 3 以内で両方 review 以上なら「確定不能」として必ず review。これで「山田」が複数エンティティに僅差で当たったケースを自動採用しない。

review queue の位置づけ

decision === "review" の結果を 承認前の関門として貯める。registry スキーマの match テーブル(createRegistrySchema({ prefix }).match)がその永続先で、subject / candidateOrgId / method / score / status(pending|approved|rejected) / detail を持つ。自動マッチは公開 DB を直接汚さない — pending として積み、人が approve して初めて確定する。in-memory で使うなら MatchResult をそのまま UI に並べれば足りる(本章の admin デモは辞書を毎回組んで matchName/linkFromText を実演する)。

privacy 最小化 — 三層防御

名鑑は PII の塊なので、privacy.ts は役割の違う3層を分けて重ねる。1層でも通れば漏れる構造にしない。

1) gating(consent + 状態フラグ) — 「載せて良いか」を返す純関数群。isEligibleForListingsuppressed / minor(18歳未満、ADULT_AGE)/ matchMethod === "name_norm"(氏名だけの低確度マッチ)を fail-closed で弾く。その上に canListWithNameConsentScope.LIST_WITH_NAME の同意が必要)・canContactcanShowCohort が乗る。hasGrantedConsent は scope 一致(null/空 scope は包括同意)を見る。evaluateListingGrants が person×consent を1度だけ評価して ListingGrants にまとめる。

2) 公開射影(ホワイトリスト物理ガード)sanitizeSubjectsForPublic(subjects, project)isSubjectPublishablepublishState==="public" ∧ not suppressed ∧ not minor ∧ not name_norm)と slug 非 null を確認し、通ったものだけ project 関数で 公開フィールドを新オブジェクトに cherry-pick する。...spread で行全体をコピーしない設計なので、入力に連絡先や審査メモが紛れても 出力に物理的に存在し得ないpickKeys / sanitizeMeta は jsonb を allowlist で絞る補助。gating(1層)とは独立した第二の物理ガードで、consent を通っても publishState が public でなければ落ちる(多層防御)。

3) k匿名(集計出口)K_MIN(5) 未満の実数を出さない。applyKAnonymity(cells)count >= K_MIN の行だけ残し(0 件セルも「存在を漏らさない」ため非公開)、maskKAnonymity は行を残して count を null に潰す。単一総数は safeScalar補集合漏れガード applyComplementSafe は「総数 − 内訳の和」で1セルを復元される事故を防ぐ — 秘匿セルが1個だけ残ったら最小セルをもう1つ落とす。集計に person_id/slug を一切載せないのが前提。

配線(Neon 不要)

dev は content/directory.jsoncontentStore.readResourceFresh で読み、sanitizeSubjectsForPublic + applyKAnonymity で公開射影するだけで動く。RegistrySubject は slug / publishState / suppressed / minor / matchMethod の最小構造なので、氏名・avatar・bio は保持しない(4S からライブ取得する前提)。本番で DB を使う場合も createRegistrySchemaperson 行が RegistrySubject を構造的に満たすため(registry.ts のコンパイル時アサートで保証)、gating/射影/k匿名のコードはそのまま流用できる。手順は add-networking レシピ参照。