diff --git a/backend/fix-globi-pollinator-category.cjs b/backend/fix-globi-pollinator-category.cjs new file mode 100644 index 0000000..9cdf989 --- /dev/null +++ b/backend/fix-globi-pollinator-category.cjs @@ -0,0 +1,69 @@ +#!/usr/bin/env node +/** + * fix-globi-pollinator-category.cjs — retroactively apply the `hasHost` pollinator + * guard (lib/globi-classify.js) to already-loaded GloBI claims. + * + * ROOT CAUSE: GloBI uses `hasHost` broadly. The generic rule "invertebrate has + * host plant -> pest_pressure" branded every BEE->plant hasHost record a crop + * pest, because a bee IS an invertebrate and its "host plant" is its FORAGE + * plant. That produced the pollinator-as-pest artifact (~410 bee claims, and a + * wider pollinator tail) which then polluted the emergent-biocontrol discovery + * layer (a "pest" node that is actually a pollinator, preyed on by a beewolf). + * + * METHOD: rather than duplicate the mapping logic, this re-runs the (now fixed) + * classifyTriple() over the affected candidate set and writes back only where the + * derived category CHANGES. That makes it self-limiting — a genuine herbivore + * re-derives to pest_pressure and is left alone — and keeps one source of truth. + * + * Dry-run by default; --apply writes + revision_logs every field change. + */ +'use strict'; +const D = require('better-sqlite3'); +const { CORPUS_DB } = require('./lib/db-paths.cjs'); +const { classifyTriple } = require('./lib/globi-classify'); +const { logRevisions } = require('./lib/revision-log'); + +const APPLY = process.argv.includes('--apply'); +const RAW_TERM = 'hasHost'; +const FROM_CAT = 'pest_pressure'; + +const db = new D(CORPUS_DB); + +// entity lookup (the exact fields the loader feeds the classifier) +const ents = new Map(); +for (const e of db.prepare('SELECT id, scientific_name, primary_role, bio_category, family FROM entities').all()) ents.set(e.id, e); + +const rows = db.prepare( + 'SELECT id, subject_entity_id, object_entity_id, interaction_category, effect_direction, applied_weight, resolution_path FROM claims WHERE interaction_type_raw = ? AND interaction_category = ?' +).all(RAW_TERM, FROM_CAT); + +let changed = 0, unchanged = 0, skipped = 0; +const newCatDist = {}; +const run = db.transaction(() => { + for (const c of rows) { + const src = ents.get(c.subject_entity_id), tgt = ents.get(c.object_entity_id); + if (!src || !tgt) { skipped++; continue; } + let r; try { r = classifyTriple(src, tgt, RAW_TERM); } catch (e) { skipped++; continue; } + if (!r || !r.category || r.category === c.interaction_category) { unchanged++; continue; } + changed++; newCatDist[r.category] = (newCatDist[r.category] || 0) + 1; + if (APPLY) { + db.prepare('UPDATE claims SET interaction_category = ?, effect_direction = ?, applied_weight = ?, resolution_path = ? WHERE id = ?') + .run(r.category, r.effect ?? c.effect_direction, r.weight ?? c.applied_weight, r.path ?? c.resolution_path, c.id); + logRevisions(db, { targetType: 'claim', targetId: c.id, + changes: [ + { field: 'interaction_category', before: c.interaction_category, after: r.category }, + { field: 'effect_direction', before: c.effect_direction, after: r.effect ?? c.effect_direction }, + ], + changedBy: 'globi-pollinator-fix', method: 'hasHost-pollinator-guard', + reason: 'bee/pollinator host-PLANT is forage, not pest association' }); + } + } +}); +run(); + +console.log(`=== GloBI pollinator-as-pest re-derive ${APPLY ? '(APPLIED)' : '(DRY-RUN)'} ===`); +console.log(`candidates (${RAW_TERM} + ${FROM_CAT}): ${rows.length}`); +console.log(` changed: ${changed} | unchanged (correctly pest): ${unchanged} | skipped (missing entity): ${skipped}`); +console.log(' new categories:', JSON.stringify(newCatDist)); +if (!APPLY) console.log('\n(dry-run — re-run with --apply)'); +db.close(); diff --git a/backend/lib/globi-classify.js b/backend/lib/globi-classify.js index d14cd19..418d1c0 100644 --- a/backend/lib/globi-classify.js +++ b/backend/lib/globi-classify.js @@ -76,6 +76,14 @@ const VARIABLE_TYPES = new Set([ ]); const ANIMAL_CATEGORIES = new Set(['invertebrate', 'vertebrate']); const PEST_CATEGORIES = new Set(['invertebrate', 'fungi', 'microbe']); +// Bee families. A bee's "host plant" (GloBI `hasHost`) is the plant it FORAGES +// on — never a pest association. Family is the fallback signal because the +// family-floor role pass left much of the corpus primary_role='unclassified', +// so a role-only guard would miss most of them. +const POLLINATOR_FAMILIES = new Set([ + 'apidae', 'halictidae', 'andrenidae', 'megachilidae', 'colletidae', + 'melittidae', 'stenotritidae', +]); const GENBANK_RE = /^[A-Z]{2}\d{6}/; function isGarbage(name) { @@ -128,6 +136,8 @@ function resolveVariable(itype, src, tgt) { const tgtIsPlant = tgtBio === 'plantae'; const srcIsPestCategory = PEST_CATEGORIES.has(srcBio); // invertebrate, fungi, microbe const tgtIsPestCategory = PEST_CATEGORIES.has(tgtBio); + const srcIsPollinator = (src.primary_role || '').toLowerCase() === 'pollinator' + || POLLINATOR_FAMILIES.has((src.family || '').toLowerCase()); switch (itype) { @@ -237,6 +247,13 @@ function resolveVariable(itype, src, tgt) { path: `${srcBio} hosting ${tgtBio} → neutral` }; case 'hasHost': + // A POLLINATOR's "host plant" is its FORAGE plant — GloBI uses `hasHost` + // broadly, so this MUST be checked before the generic invertebrate rule + // below, which would otherwise brand every bee→plant record a pest + // (the pollinator-as-pest artifact: ~410 bee claims). + if (srcIsPollinator && tgtIsPlant) + return { category: 'pollination', effect: 'beneficial', weight: 2.0, confidence: 'resolved', + path: `pollinator has host plant → pollination/foraging` }; // Fungi/microbe has host plant = pathogen pressure if ((srcBio === 'fungi' || srcBio === 'microbe') && tgtIsPlant) return { category: 'pathogen_pressure', effect: 'harmful', weight: -3.0, confidence: 'resolved', diff --git a/backend/lib/globi-classify.test.js b/backend/lib/globi-classify.test.js index 3ce27a3..53261b0 100644 --- a/backend/lib/globi-classify.test.js +++ b/backend/lib/globi-classify.test.js @@ -23,6 +23,39 @@ test('neutral co-occurrence returns null (skip)', () => { assert.equal(classifyTriple(plant, plant, 'adjacentTo'), null); }); +// ── hasHost + pollinator: the pollinator-as-pest artifact ──────────────────── +// GloBI uses `hasHost` broadly. For a bee, its "host plant" is the plant it +// FORAGES on, not one it pests. Without a pollinator guard the generic +// "invertebrate has host plant -> pest_pressure" rule turned every bee->plant +// hasHost record into a harmful pest claim (the 410-claim artifact). +const bee = { id: 10, scientific_name: 'Lasioglossum zephyrus', bio_category: 'invertebrate', family: 'Halictidae', primary_role: 'pollinator' }; +const beeUnclassified = { id: 11, scientific_name: 'Andrena nasonii', bio_category: 'invertebrate', family: 'Andrenidae', primary_role: 'unclassified' }; + +test('hasHost: pollinator -> plant is FORAGE (pollination/beneficial), never pest_pressure', () => { + const r = classifyTriple(bee, plant, 'hasHost'); + assert.equal(r.category, 'pollination'); + assert.equal(r.effect, 'beneficial'); +}); + +test('hasHost: bee recognised by FAMILY even when primary_role is unclassified', () => { + // The family-floor role work left much of the corpus 'unclassified', so the + // guard must not depend on primary_role alone. + const r = classifyTriple(beeUnclassified, plant, 'hasHost'); + assert.equal(r.category, 'pollination'); + assert.equal(r.effect, 'beneficial'); +}); + +test('hasHost: a GENUINE herbivore -> plant still maps to pest_pressure (no over-correction)', () => { + const r = classifyTriple(bug, plant, 'hasHost'); + assert.equal(r.category, 'pest_pressure'); + assert.equal(r.effect, 'harmful'); +}); + +test('hasHost: fungal pathogen -> plant still maps to pathogen_pressure', () => { + const fungus = { id: 12, scientific_name: 'Puccinia graminis', bio_category: 'fungi', family: 'Pucciniaceae' }; + assert.equal(classifyTriple(fungus, plant, 'hasHost').category, 'pathogen_pressure'); +}); + test('hasVector: plant → animal is seed/pollen dispersal, NOT disease_vector', () => { const bat = { id: 4, scientific_name: 'Carollia perspicillata', bio_category: 'vertebrate', family: 'Phyllostomidae' }; const ant = { id: 5, scientific_name: 'Formica fusca', bio_category: 'invertebrate', family: 'Formicidae' };