From c2e6b97a7b03b0cf1ffa25c2d3c0a09328debf72 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Thu, 28 May 2026 08:30:11 +0200 Subject: [PATCH 01/13] Add multilingual entity alias guard --- multilingual-entity-alias-guard/README.md | 23 ++ .../acceptance-notes.md | 18 + multilingual-entity-alias-guard/demo.js | 77 ++++ multilingual-entity-alias-guard/index.js | 311 +++++++++++++++++ .../make-demo-video.py | 41 +++ multilingual-entity-alias-guard/package.json | 13 + .../reports/alias-guard-packet.json | 330 ++++++++++++++++++ .../reports/alias-guard-report.md | 34 ++ .../reports/demo.mp4 | Bin 0 -> 46481 bytes .../reports/summary.svg | 12 + .../requirements-map.md | 26 ++ multilingual-entity-alias-guard/test.js | 78 +++++ 12 files changed, 963 insertions(+) create mode 100644 multilingual-entity-alias-guard/README.md create mode 100644 multilingual-entity-alias-guard/acceptance-notes.md create mode 100644 multilingual-entity-alias-guard/demo.js create mode 100644 multilingual-entity-alias-guard/index.js create mode 100644 multilingual-entity-alias-guard/make-demo-video.py create mode 100644 multilingual-entity-alias-guard/package.json create mode 100644 multilingual-entity-alias-guard/reports/alias-guard-packet.json create mode 100644 multilingual-entity-alias-guard/reports/alias-guard-report.md create mode 100644 multilingual-entity-alias-guard/reports/demo.mp4 create mode 100644 multilingual-entity-alias-guard/reports/summary.svg create mode 100644 multilingual-entity-alias-guard/requirements-map.md create mode 100644 multilingual-entity-alias-guard/test.js diff --git a/multilingual-entity-alias-guard/README.md b/multilingual-entity-alias-guard/README.md new file mode 100644 index 00000000..2532ba3f --- /dev/null +++ b/multilingual-entity-alias-guard/README.md @@ -0,0 +1,23 @@ +# Multilingual Entity Alias Guard + +This module adds a focused Scientific Knowledge Graph Integration slice for SCIBASE issue #17. It normalizes multilingual scientific mentions before they become graph nodes, entity-page aliases, or recommendation signals. + +The guard accepts trusted translated aliases, preserves language tags, emits JSON-LD-style entity packets, holds homographs and false friends for curator review, and suppresses low-confidence aliases before recommendations are shown. + +## Run + +```bash +npm test +npm run demo +npm run video +npm run check +``` + +## Outputs + +- `reports/alias-guard-packet.json` +- `reports/alias-guard-report.md` +- `reports/summary.svg` +- `reports/demo.mp4` + +All data is synthetic. The module does not call live ontologies, identity providers, external APIs, private corpora, search indexes, or recommendation systems. diff --git a/multilingual-entity-alias-guard/acceptance-notes.md b/multilingual-entity-alias-guard/acceptance-notes.md new file mode 100644 index 00000000..5e84366f --- /dev/null +++ b/multilingual-entity-alias-guard/acceptance-notes.md @@ -0,0 +1,18 @@ +# Acceptance Notes + +This #17 slice focuses specifically on multilingual scientific alias quality before graph nodes and recommendations are produced. + +It is not: + +- a broad entity extractor or navigator +- an ontology deprecation or synonym migration tool +- a recommendation visibility or diversity guard +- a geospatial, clinical trial, biological accession, software runtime, or temporal validity guard + +Validation coverage: + +- trusted CRISPR aliases in English, German, and Spanish map to one canonical MeSH entity +- Spanish `control` is held as a homograph/false friend instead of silently creating a statistical control-group edge +- low-confidence French alias output is suppressed from recommendations +- localized names remain language-tagged on entity packets +- audit output is deterministic and private-data free diff --git a/multilingual-entity-alias-guard/demo.js b/multilingual-entity-alias-guard/demo.js new file mode 100644 index 00000000..999d39ce --- /dev/null +++ b/multilingual-entity-alias-guard/demo.js @@ -0,0 +1,77 @@ +const fs = require('fs'); +const path = require('path'); +const { evaluateAliasGuard, buildSampleCorpus } = require('./index'); + +const reportsDir = path.join(__dirname, 'reports'); +fs.mkdirSync(reportsDir, { recursive: true }); + +const result = evaluateAliasGuard(buildSampleCorpus()); + +const packetPath = path.join(reportsDir, 'alias-guard-packet.json'); +const reportPath = path.join(reportsDir, 'alias-guard-report.md'); +const svgPath = path.join(reportsDir, 'summary.svg'); + +fs.writeFileSync(packetPath, `${JSON.stringify(result, null, 2)}\n`); + +const accepted = result.mentionDecisions + .filter((decision) => decision.decision === 'accept-canonical-entity') + .map((decision) => `- ${decision.id}: ${decision.text} (${decision.language}) -> ${decision.candidateEntityId}`) + .join('\n'); + +const held = result.curatorActions + .map((action) => `- ${action.id}: ${action.action} (${action.language}:${action.text})`) + .join('\n'); + +const markdown = `# Multilingual Entity Alias Guard + +Corpus: ${result.corpusId} +Generated: ${result.generatedAt} + +## Summary + +- Accepted mentions: ${result.summary.acceptedMentions} +- Held homograph mentions: ${result.summary.heldMentions} +- Suppressed low-confidence mentions: ${result.summary.suppressedMentions} +- Entity packets emitted: ${result.summary.entityPackets} +- Audit digest: ${result.auditDigest} + +## Accepted Canonical Mappings + +${accepted} + +## Curator Actions + +${held} + +## Recommendation Guard + +Suppressed mentions are not allowed to drive entity-page recommendations until a curator verifies the alias mapping. + +## Safety + +All fixtures are synthetic. The module does not call live ontologies, identity providers, external APIs, private corpora, search indexes, or recommendation systems. +`; + +fs.writeFileSync(reportPath, markdown); + +const svg = ` + + + Multilingual Entity Alias Guard + Accepted canonical mentions: ${result.summary.acceptedMentions} + Held homograph mentions: ${result.summary.heldMentions} + Suppressed low-confidence mentions: ${result.summary.suppressedMentions} + Languages preserved: en, de, es, fr + JSON-LD entity packets ready for schema.org-style pages + Unsafe aliases are held before graph recommendations are shown. + ${result.auditDigest} + +`; + +fs.writeFileSync(svgPath, svg); + +console.log(`Wrote ${path.relative(__dirname, packetPath)}`); +console.log(`Wrote ${path.relative(__dirname, reportPath)}`); +console.log(`Wrote ${path.relative(__dirname, svgPath)}`); +console.log(`Accepted mentions: ${result.summary.acceptedMentions}`); +console.log(`Suppressed mentions: ${result.summary.suppressedMentions}`); diff --git a/multilingual-entity-alias-guard/index.js b/multilingual-entity-alias-guard/index.js new file mode 100644 index 00000000..340b1174 --- /dev/null +++ b/multilingual-entity-alias-guard/index.js @@ -0,0 +1,311 @@ +const crypto = require('crypto'); + +function stableStringify(value) { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]`; + } + + if (value && typeof value === 'object') { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(',')}}`; + } + + return JSON.stringify(value); +} + +function digest(value) { + return `sha256:${crypto.createHash('sha256').update(stableStringify(value)).digest('hex')}`; +} + +function normalizeTerm(term) { + return term.trim().toLocaleLowerCase(); +} + +function buildAliasIndex(entities) { + const index = new Map(); + + for (const entity of entities) { + for (const [language, terms] of Object.entries(entity.localizedNames)) { + for (const term of terms) { + index.set(`${language}:${normalizeTerm(term)}`, { + entity, + language, + term + }); + } + } + } + + return index; +} + +function mentionDecision(mention, aliasIndex, homographs) { + const alias = aliasIndex.get(`${mention.language}:${normalizeTerm(mention.text)}`); + const candidateEntityId = alias ? alias.entity.id : mention.candidateEntityId || null; + const homographKey = `${mention.language}:${normalizeTerm(mention.text)}`; + + if (homographs[homographKey]) { + return { + id: mention.id, + language: mention.language, + text: mention.text, + documentId: mention.documentId, + decision: 'hold-for-curator-review', + reason: 'false-friend-or-homograph', + candidateEntityId, + confidence: mention.confidence, + preservedLanguageTag: mention.language + }; + } + + if (!alias || mention.confidence < 0.8) { + return { + id: mention.id, + language: mention.language, + text: mention.text, + documentId: mention.documentId, + decision: 'suppress-recommendation', + reason: alias || candidateEntityId ? 'low-confidence-alias' : 'unknown-alias', + candidateEntityId, + confidence: mention.confidence, + preservedLanguageTag: mention.language + }; + } + + return { + id: mention.id, + language: mention.language, + text: mention.text, + documentId: mention.documentId, + decision: 'accept-canonical-entity', + reason: 'trusted-translated-alias', + candidateEntityId: alias.entity.id, + confidence: mention.confidence, + preservedLanguageTag: mention.language + }; +} + +function curatorActionForDecision(decision) { + if (decision.decision === 'accept-canonical-entity') { + return null; + } + + return { + id: `curate-${decision.id}`, + mentionId: decision.id, + action: + decision.reason === 'false-friend-or-homograph' + ? 'review-multilingual-homograph' + : 'verify-translated-alias-before-recommendation', + priority: decision.reason === 'false-friend-or-homograph' ? 'high' : 'normal', + language: decision.language, + text: decision.text, + candidateEntityId: decision.candidateEntityId, + reason: decision.reason + }; +} + +function buildEntityPackets(entities, decisions) { + return entities.map((entity) => { + const accepted = decisions.filter( + (decision) => + decision.decision === 'accept-canonical-entity' && decision.candidateEntityId === entity.id + ); + const languages = Array.from(new Set(accepted.map((decision) => decision.language))).sort(); + + return { + id: entity.id, + canonicalName: entity.canonicalName, + ontology: entity.ontology, + identifier: entity.identifier, + languages, + mentions: accepted.map((decision) => ({ + id: decision.id, + text: decision.text, + language: decision.language, + documentId: decision.documentId, + confidence: decision.confidence + })), + localizedNames: entity.localizedNames, + jsonLd: { + '@context': 'https://schema.org', + '@type': 'DefinedTerm', + name: entity.canonicalName, + identifier: `${entity.ontology}:${entity.identifier}`, + inDefinedTermSet: entity.ontology, + alternateName: Object.values(entity.localizedNames).flat() + }, + schemaOrg: { + '@type': 'ScholarlyArticle', + about: accepted.map((decision) => ({ + '@type': 'DefinedTerm', + name: decision.text, + inLanguage: decision.language, + identifier: `${entity.ontology}:${entity.identifier}` + })) + } + }; + }); +} + +function evaluateAliasGuard(corpus) { + const aliasIndex = buildAliasIndex(corpus.entities); + const mentionDecisions = corpus.mentions.map((mention) => + mentionDecision(mention, aliasIndex, corpus.homographs) + ); + const curatorActions = mentionDecisions.map(curatorActionForDecision).filter(Boolean); + const entityPackets = buildEntityPackets(corpus.entities, mentionDecisions); + const suppressedMentionIds = mentionDecisions + .filter((decision) => decision.decision !== 'accept-canonical-entity') + .map((decision) => decision.id); + + const summary = { + acceptedMentions: mentionDecisions.filter( + (decision) => decision.decision === 'accept-canonical-entity' + ).length, + heldMentions: mentionDecisions.filter( + (decision) => decision.decision === 'hold-for-curator-review' + ).length, + suppressedMentions: mentionDecisions.filter( + (decision) => decision.decision === 'suppress-recommendation' + ).length, + entityPackets: entityPackets.length + }; + + return { + corpusId: corpus.corpusId, + generatedAt: corpus.generatedAt, + mentionDecisions, + entityPackets, + curatorActions, + recommendationGuards: { + suppressedMentionIds, + safeEntityIds: entityPackets + .filter((packet) => packet.mentions.length > 0) + .map((packet) => packet.id) + }, + summary, + auditDigest: digest({ + corpusId: corpus.corpusId, + mentionDecisions, + entityPackets, + curatorActions, + summary + }) + }; +} + +function buildSampleCorpus() { + return { + corpusId: 'kg-multilingual-upload-batch-17', + generatedAt: '2026-05-28T07:00:00Z', + entities: [ + { + id: 'entity:mesh:D000077768', + canonicalName: 'CRISPR-Cas9', + ontology: 'MeSH', + identifier: 'D000077768', + localizedNames: { + en: ['CRISPR-Cas9'], + de: ['CRISPR-Cas9 Geneditierung'], + es: ['edicion genetica CRISPR-Cas9'] + } + }, + { + id: 'entity:mesh:D003920', + canonicalName: 'Diabetes Mellitus', + ontology: 'MeSH', + identifier: 'D003920', + localizedNames: { + en: ['diabetes mellitus'], + de: ['Diabetes mellitus'], + es: ['diabetes mellitus'] + } + }, + { + id: 'entity:stat:control-group', + canonicalName: 'Control Group', + ontology: 'SCIBASE-STAT', + identifier: 'control-group', + localizedNames: { + en: ['control group'], + es: ['grupo control'], + de: ['Kontrollgruppe'] + } + } + ], + homographs: { + 'es:control': { + note: 'Spanish control may refer to monitoring or governance, not necessarily a statistical control group.' + } + }, + mentions: [ + { + id: 'mention-crispr-en', + documentId: 'paper-1', + text: 'CRISPR-Cas9', + language: 'en', + confidence: 0.97 + }, + { + id: 'mention-crispr-de', + documentId: 'paper-2', + text: 'CRISPR-Cas9 Geneditierung', + language: 'de', + confidence: 0.91 + }, + { + id: 'mention-crispr-es', + documentId: 'paper-3', + text: 'edicion genetica CRISPR-Cas9', + language: 'es', + confidence: 0.89 + }, + { + id: 'mention-diabetes-en', + documentId: 'paper-4', + text: 'diabetes mellitus', + language: 'en', + confidence: 0.94 + }, + { + id: 'mention-diabetes-de', + documentId: 'paper-5', + text: 'Diabetes mellitus', + language: 'de', + confidence: 0.95 + }, + { + id: 'mention-diabetes-es', + documentId: 'paper-6', + text: 'diabetes mellitus', + language: 'es', + confidence: 0.93 + }, + { + id: 'mention-control-es', + documentId: 'paper-7', + text: 'control', + language: 'es', + confidence: 0.88, + candidateEntityId: 'entity:stat:control-group' + }, + { + id: 'mention-cellule-fr', + documentId: 'paper-8', + text: 'cellule', + language: 'fr', + confidence: 0.61, + candidateEntityId: 'entity:mesh:D002477' + } + ] + }; +} + +module.exports = { + evaluateAliasGuard, + buildSampleCorpus, + digest +}; diff --git a/multilingual-entity-alias-guard/make-demo-video.py b/multilingual-entity-alias-guard/make-demo-video.py new file mode 100644 index 00000000..a860d754 --- /dev/null +++ b/multilingual-entity-alias-guard/make-demo-video.py @@ -0,0 +1,41 @@ +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent +REPORTS = ROOT / "reports" +REPORTS.mkdir(exist_ok=True) +OUTPUT = REPORTS / "demo.mp4" + +font = "C\\:/Windows/Fonts/arial.ttf" +vf = ",".join( + [ + "drawbox=x=52:y=58:w=1176:h=604:color=0x7bd88f@0.55:t=4", + "drawbox=x=62:y=68:w=1156:h=584:color=0x142f42@0.96:t=fill", + f"drawtext=fontfile='{font}':text='Scientific Knowledge Graph Alias Guard':x=92:y=126:fontsize=42:fontcolor=white", + f"drawtext=fontfile='{font}':text='Maps multilingual scientific terms to canonical entities':x=92:y=206:fontsize=30:fontcolor=0xd8f6df", + f"drawtext=fontfile='{font}':text='Preserves language tags for entity pages and JSON-LD':x=92:y=266:fontsize=30:fontcolor=0xd8f6df", + f"drawtext=fontfile='{font}':text='Holds homographs and false friends for curator review':x=92:y=326:fontsize=30:fontcolor=0xd8f6df", + f"drawtext=fontfile='{font}':text='Suppresses weak aliases before recommendations are shown':x=92:y=386:fontsize=30:fontcolor=0xd8f6df", + f"drawtext=fontfile='{font}':text='SCIBASE issue #17 multilingual KG integration slice':x=92:y=506:fontsize=28:fontcolor=0xffd37a", + ] +) + +cmd = [ + "ffmpeg", + "-y", + "-f", + "lavfi", + "-i", + "color=c=0x0c2130:s=1280x720:d=4:r=30", + "-vf", + vf, + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + str(OUTPUT), +] + +subprocess.run(cmd, check=True) +print(f"Wrote {OUTPUT.relative_to(ROOT)}") diff --git a/multilingual-entity-alias-guard/package.json b/multilingual-entity-alias-guard/package.json new file mode 100644 index 00000000..cd75e4fe --- /dev/null +++ b/multilingual-entity-alias-guard/package.json @@ -0,0 +1,13 @@ +{ + "name": "multilingual-entity-alias-guard", + "version": "1.0.0", + "description": "Dependency-free multilingual entity alias guard for SCIBASE scientific knowledge graph integration.", + "main": "index.js", + "private": true, + "scripts": { + "test": "node test.js", + "demo": "node demo.js", + "video": "python make-demo-video.py", + "check": "npm test && npm run demo && npm run video" + } +} diff --git a/multilingual-entity-alias-guard/reports/alias-guard-packet.json b/multilingual-entity-alias-guard/reports/alias-guard-packet.json new file mode 100644 index 00000000..fc34434f --- /dev/null +++ b/multilingual-entity-alias-guard/reports/alias-guard-packet.json @@ -0,0 +1,330 @@ +{ + "corpusId": "kg-multilingual-upload-batch-17", + "generatedAt": "2026-05-28T07:00:00Z", + "mentionDecisions": [ + { + "id": "mention-crispr-en", + "language": "en", + "text": "CRISPR-Cas9", + "documentId": "paper-1", + "decision": "accept-canonical-entity", + "reason": "trusted-translated-alias", + "candidateEntityId": "entity:mesh:D000077768", + "confidence": 0.97, + "preservedLanguageTag": "en" + }, + { + "id": "mention-crispr-de", + "language": "de", + "text": "CRISPR-Cas9 Geneditierung", + "documentId": "paper-2", + "decision": "accept-canonical-entity", + "reason": "trusted-translated-alias", + "candidateEntityId": "entity:mesh:D000077768", + "confidence": 0.91, + "preservedLanguageTag": "de" + }, + { + "id": "mention-crispr-es", + "language": "es", + "text": "edicion genetica CRISPR-Cas9", + "documentId": "paper-3", + "decision": "accept-canonical-entity", + "reason": "trusted-translated-alias", + "candidateEntityId": "entity:mesh:D000077768", + "confidence": 0.89, + "preservedLanguageTag": "es" + }, + { + "id": "mention-diabetes-en", + "language": "en", + "text": "diabetes mellitus", + "documentId": "paper-4", + "decision": "accept-canonical-entity", + "reason": "trusted-translated-alias", + "candidateEntityId": "entity:mesh:D003920", + "confidence": 0.94, + "preservedLanguageTag": "en" + }, + { + "id": "mention-diabetes-de", + "language": "de", + "text": "Diabetes mellitus", + "documentId": "paper-5", + "decision": "accept-canonical-entity", + "reason": "trusted-translated-alias", + "candidateEntityId": "entity:mesh:D003920", + "confidence": 0.95, + "preservedLanguageTag": "de" + }, + { + "id": "mention-diabetes-es", + "language": "es", + "text": "diabetes mellitus", + "documentId": "paper-6", + "decision": "accept-canonical-entity", + "reason": "trusted-translated-alias", + "candidateEntityId": "entity:mesh:D003920", + "confidence": 0.93, + "preservedLanguageTag": "es" + }, + { + "id": "mention-control-es", + "language": "es", + "text": "control", + "documentId": "paper-7", + "decision": "hold-for-curator-review", + "reason": "false-friend-or-homograph", + "candidateEntityId": "entity:stat:control-group", + "confidence": 0.88, + "preservedLanguageTag": "es" + }, + { + "id": "mention-cellule-fr", + "language": "fr", + "text": "cellule", + "documentId": "paper-8", + "decision": "suppress-recommendation", + "reason": "low-confidence-alias", + "candidateEntityId": "entity:mesh:D002477", + "confidence": 0.61, + "preservedLanguageTag": "fr" + } + ], + "entityPackets": [ + { + "id": "entity:mesh:D000077768", + "canonicalName": "CRISPR-Cas9", + "ontology": "MeSH", + "identifier": "D000077768", + "languages": [ + "de", + "en", + "es" + ], + "mentions": [ + { + "id": "mention-crispr-en", + "text": "CRISPR-Cas9", + "language": "en", + "documentId": "paper-1", + "confidence": 0.97 + }, + { + "id": "mention-crispr-de", + "text": "CRISPR-Cas9 Geneditierung", + "language": "de", + "documentId": "paper-2", + "confidence": 0.91 + }, + { + "id": "mention-crispr-es", + "text": "edicion genetica CRISPR-Cas9", + "language": "es", + "documentId": "paper-3", + "confidence": 0.89 + } + ], + "localizedNames": { + "en": [ + "CRISPR-Cas9" + ], + "de": [ + "CRISPR-Cas9 Geneditierung" + ], + "es": [ + "edicion genetica CRISPR-Cas9" + ] + }, + "jsonLd": { + "@context": "https://schema.org", + "@type": "DefinedTerm", + "name": "CRISPR-Cas9", + "identifier": "MeSH:D000077768", + "inDefinedTermSet": "MeSH", + "alternateName": [ + "CRISPR-Cas9", + "CRISPR-Cas9 Geneditierung", + "edicion genetica CRISPR-Cas9" + ] + }, + "schemaOrg": { + "@type": "ScholarlyArticle", + "about": [ + { + "@type": "DefinedTerm", + "name": "CRISPR-Cas9", + "inLanguage": "en", + "identifier": "MeSH:D000077768" + }, + { + "@type": "DefinedTerm", + "name": "CRISPR-Cas9 Geneditierung", + "inLanguage": "de", + "identifier": "MeSH:D000077768" + }, + { + "@type": "DefinedTerm", + "name": "edicion genetica CRISPR-Cas9", + "inLanguage": "es", + "identifier": "MeSH:D000077768" + } + ] + } + }, + { + "id": "entity:mesh:D003920", + "canonicalName": "Diabetes Mellitus", + "ontology": "MeSH", + "identifier": "D003920", + "languages": [ + "de", + "en", + "es" + ], + "mentions": [ + { + "id": "mention-diabetes-en", + "text": "diabetes mellitus", + "language": "en", + "documentId": "paper-4", + "confidence": 0.94 + }, + { + "id": "mention-diabetes-de", + "text": "Diabetes mellitus", + "language": "de", + "documentId": "paper-5", + "confidence": 0.95 + }, + { + "id": "mention-diabetes-es", + "text": "diabetes mellitus", + "language": "es", + "documentId": "paper-6", + "confidence": 0.93 + } + ], + "localizedNames": { + "en": [ + "diabetes mellitus" + ], + "de": [ + "Diabetes mellitus" + ], + "es": [ + "diabetes mellitus" + ] + }, + "jsonLd": { + "@context": "https://schema.org", + "@type": "DefinedTerm", + "name": "Diabetes Mellitus", + "identifier": "MeSH:D003920", + "inDefinedTermSet": "MeSH", + "alternateName": [ + "diabetes mellitus", + "Diabetes mellitus", + "diabetes mellitus" + ] + }, + "schemaOrg": { + "@type": "ScholarlyArticle", + "about": [ + { + "@type": "DefinedTerm", + "name": "diabetes mellitus", + "inLanguage": "en", + "identifier": "MeSH:D003920" + }, + { + "@type": "DefinedTerm", + "name": "Diabetes mellitus", + "inLanguage": "de", + "identifier": "MeSH:D003920" + }, + { + "@type": "DefinedTerm", + "name": "diabetes mellitus", + "inLanguage": "es", + "identifier": "MeSH:D003920" + } + ] + } + }, + { + "id": "entity:stat:control-group", + "canonicalName": "Control Group", + "ontology": "SCIBASE-STAT", + "identifier": "control-group", + "languages": [], + "mentions": [], + "localizedNames": { + "en": [ + "control group" + ], + "es": [ + "grupo control" + ], + "de": [ + "Kontrollgruppe" + ] + }, + "jsonLd": { + "@context": "https://schema.org", + "@type": "DefinedTerm", + "name": "Control Group", + "identifier": "SCIBASE-STAT:control-group", + "inDefinedTermSet": "SCIBASE-STAT", + "alternateName": [ + "control group", + "grupo control", + "Kontrollgruppe" + ] + }, + "schemaOrg": { + "@type": "ScholarlyArticle", + "about": [] + } + } + ], + "curatorActions": [ + { + "id": "curate-mention-control-es", + "mentionId": "mention-control-es", + "action": "review-multilingual-homograph", + "priority": "high", + "language": "es", + "text": "control", + "candidateEntityId": "entity:stat:control-group", + "reason": "false-friend-or-homograph" + }, + { + "id": "curate-mention-cellule-fr", + "mentionId": "mention-cellule-fr", + "action": "verify-translated-alias-before-recommendation", + "priority": "normal", + "language": "fr", + "text": "cellule", + "candidateEntityId": "entity:mesh:D002477", + "reason": "low-confidence-alias" + } + ], + "recommendationGuards": { + "suppressedMentionIds": [ + "mention-control-es", + "mention-cellule-fr" + ], + "safeEntityIds": [ + "entity:mesh:D000077768", + "entity:mesh:D003920" + ] + }, + "summary": { + "acceptedMentions": 6, + "heldMentions": 1, + "suppressedMentions": 1, + "entityPackets": 3 + }, + "auditDigest": "sha256:50892b2af7709ee090c562d10ad2e5140d3a82311c6ec2d67ab77e1355e8bf76" +} diff --git a/multilingual-entity-alias-guard/reports/alias-guard-report.md b/multilingual-entity-alias-guard/reports/alias-guard-report.md new file mode 100644 index 00000000..27484b48 --- /dev/null +++ b/multilingual-entity-alias-guard/reports/alias-guard-report.md @@ -0,0 +1,34 @@ +# Multilingual Entity Alias Guard + +Corpus: kg-multilingual-upload-batch-17 +Generated: 2026-05-28T07:00:00Z + +## Summary + +- Accepted mentions: 6 +- Held homograph mentions: 1 +- Suppressed low-confidence mentions: 1 +- Entity packets emitted: 3 +- Audit digest: sha256:50892b2af7709ee090c562d10ad2e5140d3a82311c6ec2d67ab77e1355e8bf76 + +## Accepted Canonical Mappings + +- mention-crispr-en: CRISPR-Cas9 (en) -> entity:mesh:D000077768 +- mention-crispr-de: CRISPR-Cas9 Geneditierung (de) -> entity:mesh:D000077768 +- mention-crispr-es: edicion genetica CRISPR-Cas9 (es) -> entity:mesh:D000077768 +- mention-diabetes-en: diabetes mellitus (en) -> entity:mesh:D003920 +- mention-diabetes-de: Diabetes mellitus (de) -> entity:mesh:D003920 +- mention-diabetes-es: diabetes mellitus (es) -> entity:mesh:D003920 + +## Curator Actions + +- curate-mention-control-es: review-multilingual-homograph (es:control) +- curate-mention-cellule-fr: verify-translated-alias-before-recommendation (fr:cellule) + +## Recommendation Guard + +Suppressed mentions are not allowed to drive entity-page recommendations until a curator verifies the alias mapping. + +## Safety + +All fixtures are synthetic. The module does not call live ontologies, identity providers, external APIs, private corpora, search indexes, or recommendation systems. diff --git a/multilingual-entity-alias-guard/reports/demo.mp4 b/multilingual-entity-alias-guard/reports/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..5fe7e04fe76457618cd3937a17ed2ad8fd605079 GIT binary patch literal 46481 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 literal 0 HcmV?d00001 diff --git a/multilingual-entity-alias-guard/reports/summary.svg b/multilingual-entity-alias-guard/reports/summary.svg new file mode 100644 index 00000000..37b005af --- /dev/null +++ b/multilingual-entity-alias-guard/reports/summary.svg @@ -0,0 +1,12 @@ + + + + Multilingual Entity Alias Guard + Accepted canonical mentions: 6 + Held homograph mentions: 1 + Suppressed low-confidence mentions: 1 + Languages preserved: en, de, es, fr + JSON-LD entity packets ready for schema.org-style pages + Unsafe aliases are held before graph recommendations are shown. + sha256:50892b2af7709ee090c562d10ad2e5140d3a82311c6ec2d67ab77e1355e8bf76 + diff --git a/multilingual-entity-alias-guard/requirements-map.md b/multilingual-entity-alias-guard/requirements-map.md new file mode 100644 index 00000000..c81d3d51 --- /dev/null +++ b/multilingual-entity-alias-guard/requirements-map.md @@ -0,0 +1,26 @@ +# Requirements Map + +## Entity Extraction + +- Preserves language-tagged mentions from uploaded papers and datasets. +- Maps trusted translated aliases to canonical ontology identifiers. +- Holds false friends and homographs before creating graph edges. +- Emits schema.org-style `DefinedTerm` JSON-LD packets for entity pages. + +## Knowledge Navigation + +- Keeps accepted multilingual aliases attached to canonical entity pages. +- Produces curator actions for ambiguous terms that would pollute graph search. +- Prevents unknown or low-confidence aliases from becoming discoverable graph nodes. + +## AI Research Recommendations + +- Suppresses low-confidence mentions from recommendation inputs. +- Exposes safe canonical entity IDs for graph recommendations. +- Keeps multilingual evidence auditable with deterministic digests. + +## Safety And Scope + +- Synthetic data only. +- No credentials, private corpora, live ontology calls, external APIs, or production recommendation systems. +- This slice is distinct from ontology drift, synonym dedupe, temporal validity, geospatial provenance, and recommendation visibility/diversity guards. diff --git a/multilingual-entity-alias-guard/test.js b/multilingual-entity-alias-guard/test.js new file mode 100644 index 00000000..d98fbd38 --- /dev/null +++ b/multilingual-entity-alias-guard/test.js @@ -0,0 +1,78 @@ +const assert = require('assert'); +const { + evaluateAliasGuard, + buildSampleCorpus +} = require('./index'); + +function byId(items, id) { + return items.find((item) => item.id === id); +} + +function testTrustedTranslatedAliasesBecomeCanonicalGraphNodes() { + const result = evaluateAliasGuard(buildSampleCorpus()); + const crispr = byId(result.entityPackets, 'entity:mesh:D000077768'); + + assert.equal(crispr.canonicalName, 'CRISPR-Cas9'); + assert.deepEqual(crispr.languages.sort(), ['de', 'en', 'es']); + assert.equal(crispr.mentions.length, 3); + assert.equal(crispr.jsonLd['@type'], 'DefinedTerm'); + assert.equal(crispr.jsonLd.identifier, 'MeSH:D000077768'); +} + +function testFalseFriendMentionsAreHeldForCuratorReview() { + const result = evaluateAliasGuard(buildSampleCorpus()); + const event = byId(result.mentionDecisions, 'mention-control-es'); + + assert.equal(event.decision, 'hold-for-curator-review'); + assert.equal(event.reason, 'false-friend-or-homograph'); + assert.equal(event.candidateEntityId, 'entity:stat:control-group'); + + const action = byId(result.curatorActions, 'curate-mention-control-es'); + assert.equal(action.priority, 'high'); + assert.equal(action.action, 'review-multilingual-homograph'); +} + +function testLowConfidenceAliasesDoNotDriveRecommendations() { + const result = evaluateAliasGuard(buildSampleCorpus()); + const event = byId(result.mentionDecisions, 'mention-cellule-fr'); + + assert.equal(event.decision, 'suppress-recommendation'); + assert.equal(event.reason, 'low-confidence-alias'); + assert.equal(result.recommendationGuards.suppressedMentionIds.includes('mention-cellule-fr'), true); +} + +function testLanguageTaggedSynonymsArePreservedForEntityPages() { + const result = evaluateAliasGuard(buildSampleCorpus()); + const diabetes = byId(result.entityPackets, 'entity:mesh:D003920'); + + assert.deepEqual(diabetes.localizedNames, { + en: ['diabetes mellitus'], + de: ['Diabetes mellitus'], + es: ['diabetes mellitus'] + }); + assert.equal(diabetes.schemaOrg.about.length, 3); +} + +function testAuditDigestIsDeterministicAndPrivateFree() { + const result = evaluateAliasGuard(buildSampleCorpus()); + + assert.ok(result.auditDigest.startsWith('sha256:')); + assert.equal(result.summary.acceptedMentions, 6); + assert.equal(result.summary.heldMentions, 1); + assert.equal(result.summary.suppressedMentions, 1); + assert.ok(!JSON.stringify(result).includes('private@')); +} + +const tests = [ + testTrustedTranslatedAliasesBecomeCanonicalGraphNodes, + testFalseFriendMentionsAreHeldForCuratorReview, + testLowConfidenceAliasesDoNotDriveRecommendations, + testLanguageTaggedSynonymsArePreservedForEntityPages, + testAuditDigestIsDeterministicAndPrivateFree +]; + +for (const test of tests) { + test(); +} + +console.log(`${tests.length} multilingual entity alias guard tests passed`); From f8eb8dd0e8bf8f0f9f478b31c3fab2cff6906a5e Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Thu, 28 May 2026 08:42:36 +0200 Subject: [PATCH 02/13] Harden multilingual alias collision handling --- multilingual-entity-alias-guard/README.md | 2 +- .../acceptance-notes.md | 1 + multilingual-entity-alias-guard/index.js | 58 +++++++++++++++++-- .../reports/alias-guard-packet.json | 32 +++++++++- .../reports/alias-guard-report.md | 2 +- .../reports/summary.svg | 2 +- .../requirements-map.md | 1 + multilingual-entity-alias-guard/test.js | 31 ++++++++++ 8 files changed, 120 insertions(+), 9 deletions(-) diff --git a/multilingual-entity-alias-guard/README.md b/multilingual-entity-alias-guard/README.md index 2532ba3f..407141b3 100644 --- a/multilingual-entity-alias-guard/README.md +++ b/multilingual-entity-alias-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Knowledge Graph Integration slice for SCIBASE issue #17. It normalizes multilingual scientific mentions before they become graph nodes, entity-page aliases, or recommendation signals. -The guard accepts trusted translated aliases, preserves language tags, emits JSON-LD-style entity packets, holds homographs and false friends for curator review, and suppresses low-confidence aliases before recommendations are shown. +The guard accepts trusted translated aliases, preserves language tags, emits JSON-LD-style entity packets, holds homographs, false friends, and same-language alias collisions for curator review, and suppresses low-confidence aliases before recommendations are shown. ## Run diff --git a/multilingual-entity-alias-guard/acceptance-notes.md b/multilingual-entity-alias-guard/acceptance-notes.md index 5e84366f..a6390b6a 100644 --- a/multilingual-entity-alias-guard/acceptance-notes.md +++ b/multilingual-entity-alias-guard/acceptance-notes.md @@ -13,6 +13,7 @@ Validation coverage: - trusted CRISPR aliases in English, German, and Spanish map to one canonical MeSH entity - Spanish `control` is held as a homograph/false friend instead of silently creating a statistical control-group edge +- same-language translated alias collisions are held instead of silently attaching a mention to the wrong canonical entity - low-confidence French alias output is suppressed from recommendations - localized names remain language-tagged on entity packets - audit output is deterministic and private-data free diff --git a/multilingual-entity-alias-guard/index.js b/multilingual-entity-alias-guard/index.js index 340b1174..30339a31 100644 --- a/multilingual-entity-alias-guard/index.js +++ b/multilingual-entity-alias-guard/index.js @@ -29,8 +29,31 @@ function buildAliasIndex(entities) { for (const entity of entities) { for (const [language, terms] of Object.entries(entity.localizedNames)) { for (const term of terms) { - index.set(`${language}:${normalizeTerm(term)}`, { - entity, + const key = `${language}:${normalizeTerm(term)}`; + const existing = index.get(key); + + if (!existing) { + index.set(key, { + kind: 'alias', + entity, + language, + term + }); + continue; + } + + const entitiesForAlias = + existing.kind === 'collision' ? existing.entities.slice() : [existing.entity]; + + if (entitiesForAlias.some((candidate) => candidate.id === entity.id)) { + continue; + } + + entitiesForAlias.push(entity); + index.set(key, { + kind: 'collision', + entities: entitiesForAlias, + entityIds: entitiesForAlias.map((candidate) => candidate.id).sort(), language, term }); @@ -42,7 +65,8 @@ function buildAliasIndex(entities) { } function mentionDecision(mention, aliasIndex, homographs) { - const alias = aliasIndex.get(`${mention.language}:${normalizeTerm(mention.text)}`); + const aliasEntry = aliasIndex.get(`${mention.language}:${normalizeTerm(mention.text)}`); + const alias = aliasEntry && aliasEntry.kind === 'alias' ? aliasEntry : null; const candidateEntityId = alias ? alias.entity.id : mention.candidateEntityId || null; const homographKey = `${mention.language}:${normalizeTerm(mention.text)}`; @@ -55,6 +79,22 @@ function mentionDecision(mention, aliasIndex, homographs) { decision: 'hold-for-curator-review', reason: 'false-friend-or-homograph', candidateEntityId, + candidateEntityIds: candidateEntityId ? [candidateEntityId] : [], + confidence: mention.confidence, + preservedLanguageTag: mention.language + }; + } + + if (aliasEntry && aliasEntry.kind === 'collision') { + return { + id: mention.id, + language: mention.language, + text: mention.text, + documentId: mention.documentId, + decision: 'hold-for-curator-review', + reason: 'alias-collision', + candidateEntityId: null, + candidateEntityIds: aliasEntry.entityIds, confidence: mention.confidence, preservedLanguageTag: mention.language }; @@ -69,6 +109,7 @@ function mentionDecision(mention, aliasIndex, homographs) { decision: 'suppress-recommendation', reason: alias || candidateEntityId ? 'low-confidence-alias' : 'unknown-alias', candidateEntityId, + candidateEntityIds: candidateEntityId ? [candidateEntityId] : [], confidence: mention.confidence, preservedLanguageTag: mention.language }; @@ -82,6 +123,7 @@ function mentionDecision(mention, aliasIndex, homographs) { decision: 'accept-canonical-entity', reason: 'trusted-translated-alias', candidateEntityId: alias.entity.id, + candidateEntityIds: [alias.entity.id], confidence: mention.confidence, preservedLanguageTag: mention.language }; @@ -96,13 +138,19 @@ function curatorActionForDecision(decision) { id: `curate-${decision.id}`, mentionId: decision.id, action: - decision.reason === 'false-friend-or-homograph' + decision.reason === 'alias-collision' + ? 'review-multilingual-alias-collision' + : decision.reason === 'false-friend-or-homograph' ? 'review-multilingual-homograph' : 'verify-translated-alias-before-recommendation', - priority: decision.reason === 'false-friend-or-homograph' ? 'high' : 'normal', + priority: + decision.reason === 'false-friend-or-homograph' || decision.reason === 'alias-collision' + ? 'high' + : 'normal', language: decision.language, text: decision.text, candidateEntityId: decision.candidateEntityId, + candidateEntityIds: decision.candidateEntityIds, reason: decision.reason }; } diff --git a/multilingual-entity-alias-guard/reports/alias-guard-packet.json b/multilingual-entity-alias-guard/reports/alias-guard-packet.json index fc34434f..ecc84550 100644 --- a/multilingual-entity-alias-guard/reports/alias-guard-packet.json +++ b/multilingual-entity-alias-guard/reports/alias-guard-packet.json @@ -10,6 +10,9 @@ "decision": "accept-canonical-entity", "reason": "trusted-translated-alias", "candidateEntityId": "entity:mesh:D000077768", + "candidateEntityIds": [ + "entity:mesh:D000077768" + ], "confidence": 0.97, "preservedLanguageTag": "en" }, @@ -21,6 +24,9 @@ "decision": "accept-canonical-entity", "reason": "trusted-translated-alias", "candidateEntityId": "entity:mesh:D000077768", + "candidateEntityIds": [ + "entity:mesh:D000077768" + ], "confidence": 0.91, "preservedLanguageTag": "de" }, @@ -32,6 +38,9 @@ "decision": "accept-canonical-entity", "reason": "trusted-translated-alias", "candidateEntityId": "entity:mesh:D000077768", + "candidateEntityIds": [ + "entity:mesh:D000077768" + ], "confidence": 0.89, "preservedLanguageTag": "es" }, @@ -43,6 +52,9 @@ "decision": "accept-canonical-entity", "reason": "trusted-translated-alias", "candidateEntityId": "entity:mesh:D003920", + "candidateEntityIds": [ + "entity:mesh:D003920" + ], "confidence": 0.94, "preservedLanguageTag": "en" }, @@ -54,6 +66,9 @@ "decision": "accept-canonical-entity", "reason": "trusted-translated-alias", "candidateEntityId": "entity:mesh:D003920", + "candidateEntityIds": [ + "entity:mesh:D003920" + ], "confidence": 0.95, "preservedLanguageTag": "de" }, @@ -65,6 +80,9 @@ "decision": "accept-canonical-entity", "reason": "trusted-translated-alias", "candidateEntityId": "entity:mesh:D003920", + "candidateEntityIds": [ + "entity:mesh:D003920" + ], "confidence": 0.93, "preservedLanguageTag": "es" }, @@ -76,6 +94,9 @@ "decision": "hold-for-curator-review", "reason": "false-friend-or-homograph", "candidateEntityId": "entity:stat:control-group", + "candidateEntityIds": [ + "entity:stat:control-group" + ], "confidence": 0.88, "preservedLanguageTag": "es" }, @@ -87,6 +108,9 @@ "decision": "suppress-recommendation", "reason": "low-confidence-alias", "candidateEntityId": "entity:mesh:D002477", + "candidateEntityIds": [ + "entity:mesh:D002477" + ], "confidence": 0.61, "preservedLanguageTag": "fr" } @@ -297,6 +321,9 @@ "language": "es", "text": "control", "candidateEntityId": "entity:stat:control-group", + "candidateEntityIds": [ + "entity:stat:control-group" + ], "reason": "false-friend-or-homograph" }, { @@ -307,6 +334,9 @@ "language": "fr", "text": "cellule", "candidateEntityId": "entity:mesh:D002477", + "candidateEntityIds": [ + "entity:mesh:D002477" + ], "reason": "low-confidence-alias" } ], @@ -326,5 +356,5 @@ "suppressedMentions": 1, "entityPackets": 3 }, - "auditDigest": "sha256:50892b2af7709ee090c562d10ad2e5140d3a82311c6ec2d67ab77e1355e8bf76" + "auditDigest": "sha256:8bd1da50a253839d7f9becefa35e9192633262c8ece8caa8096ebb161ba52457" } diff --git a/multilingual-entity-alias-guard/reports/alias-guard-report.md b/multilingual-entity-alias-guard/reports/alias-guard-report.md index 27484b48..5cbaf803 100644 --- a/multilingual-entity-alias-guard/reports/alias-guard-report.md +++ b/multilingual-entity-alias-guard/reports/alias-guard-report.md @@ -9,7 +9,7 @@ Generated: 2026-05-28T07:00:00Z - Held homograph mentions: 1 - Suppressed low-confidence mentions: 1 - Entity packets emitted: 3 -- Audit digest: sha256:50892b2af7709ee090c562d10ad2e5140d3a82311c6ec2d67ab77e1355e8bf76 +- Audit digest: sha256:8bd1da50a253839d7f9becefa35e9192633262c8ece8caa8096ebb161ba52457 ## Accepted Canonical Mappings diff --git a/multilingual-entity-alias-guard/reports/summary.svg b/multilingual-entity-alias-guard/reports/summary.svg index 37b005af..93d911d5 100644 --- a/multilingual-entity-alias-guard/reports/summary.svg +++ b/multilingual-entity-alias-guard/reports/summary.svg @@ -8,5 +8,5 @@ Languages preserved: en, de, es, fr JSON-LD entity packets ready for schema.org-style pages Unsafe aliases are held before graph recommendations are shown. - sha256:50892b2af7709ee090c562d10ad2e5140d3a82311c6ec2d67ab77e1355e8bf76 + sha256:8bd1da50a253839d7f9becefa35e9192633262c8ece8caa8096ebb161ba52457 diff --git a/multilingual-entity-alias-guard/requirements-map.md b/multilingual-entity-alias-guard/requirements-map.md index c81d3d51..8e4e6980 100644 --- a/multilingual-entity-alias-guard/requirements-map.md +++ b/multilingual-entity-alias-guard/requirements-map.md @@ -5,6 +5,7 @@ - Preserves language-tagged mentions from uploaded papers and datasets. - Maps trusted translated aliases to canonical ontology identifiers. - Holds false friends and homographs before creating graph edges. +- Holds same-language alias collisions when ontology entries reuse the same translated term. - Emits schema.org-style `DefinedTerm` JSON-LD packets for entity pages. ## Knowledge Navigation diff --git a/multilingual-entity-alias-guard/test.js b/multilingual-entity-alias-guard/test.js index d98fbd38..318b5233 100644 --- a/multilingual-entity-alias-guard/test.js +++ b/multilingual-entity-alias-guard/test.js @@ -32,6 +32,36 @@ function testFalseFriendMentionsAreHeldForCuratorReview() { assert.equal(action.action, 'review-multilingual-homograph'); } +function testSameLanguageAliasCollisionsAreHeldForCuratorReview() { + const corpus = JSON.parse(JSON.stringify(buildSampleCorpus())); + corpus.entities.push({ + id: 'entity:custom:diabetes-insipidus', + canonicalName: 'Diabetes Insipidus', + ontology: 'SCIBASE-MED', + identifier: 'diabetes-insipidus', + localizedNames: { + es: ['diabetes mellitus'] + } + }); + + const result = evaluateAliasGuard(corpus); + const event = byId(result.mentionDecisions, 'mention-diabetes-es'); + + assert.equal(event.decision, 'hold-for-curator-review'); + assert.equal(event.reason, 'alias-collision'); + assert.deepEqual(event.candidateEntityIds, [ + 'entity:custom:diabetes-insipidus', + 'entity:mesh:D003920' + ]); + + const diabetes = byId(result.entityPackets, 'entity:mesh:D003920'); + assert.equal(diabetes.mentions.length, 2); + + const action = byId(result.curatorActions, 'curate-mention-diabetes-es'); + assert.equal(action.priority, 'high'); + assert.equal(action.action, 'review-multilingual-alias-collision'); +} + function testLowConfidenceAliasesDoNotDriveRecommendations() { const result = evaluateAliasGuard(buildSampleCorpus()); const event = byId(result.mentionDecisions, 'mention-cellule-fr'); @@ -66,6 +96,7 @@ function testAuditDigestIsDeterministicAndPrivateFree() { const tests = [ testTrustedTranslatedAliasesBecomeCanonicalGraphNodes, testFalseFriendMentionsAreHeldForCuratorReview, + testSameLanguageAliasCollisionsAreHeldForCuratorReview, testLowConfidenceAliasesDoNotDriveRecommendations, testLanguageTaggedSynonymsArePreservedForEntityPages, testAuditDigestIsDeterministicAndPrivateFree From 1c90584c402e2c5fc2fd328b5e6cb7c7c0630de8 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Thu, 28 May 2026 17:51:56 +0200 Subject: [PATCH 03/13] Harden multilingual alias normalization --- multilingual-entity-alias-guard/index.js | 2 +- multilingual-entity-alias-guard/test.js | 28 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/multilingual-entity-alias-guard/index.js b/multilingual-entity-alias-guard/index.js index 30339a31..c615b52b 100644 --- a/multilingual-entity-alias-guard/index.js +++ b/multilingual-entity-alias-guard/index.js @@ -20,7 +20,7 @@ function digest(value) { } function normalizeTerm(term) { - return term.trim().toLocaleLowerCase(); + return term.normalize('NFKC').trim().replace(/\s+/g, ' ').toLocaleLowerCase(); } function buildAliasIndex(entities) { diff --git a/multilingual-entity-alias-guard/test.js b/multilingual-entity-alias-guard/test.js index 318b5233..0fe9a31c 100644 --- a/multilingual-entity-alias-guard/test.js +++ b/multilingual-entity-alias-guard/test.js @@ -62,6 +62,33 @@ function testSameLanguageAliasCollisionsAreHeldForCuratorReview() { assert.equal(action.action, 'review-multilingual-alias-collision'); } +function testUnicodeAndWhitespaceAliasesMatchCanonicalEntities() { + const corpus = JSON.parse(JSON.stringify(buildSampleCorpus())); + corpus.entities.push({ + id: 'entity:mesh:D005260', + canonicalName: 'Gene Therapy', + ontology: 'MeSH', + identifier: 'D005260', + localizedNames: { + es: ['terapia ge\u0301nica'] + } + }); + corpus.mentions.push({ + id: 'mention-gene-therapy-es', + documentId: 'paper-9', + text: ' terapia g\u00E9nica ', + language: 'es', + confidence: 0.9 + }); + + const result = evaluateAliasGuard(corpus); + const event = byId(result.mentionDecisions, 'mention-gene-therapy-es'); + + assert.equal(event.decision, 'accept-canonical-entity'); + assert.equal(event.reason, 'trusted-translated-alias'); + assert.equal(event.candidateEntityId, 'entity:mesh:D005260'); +} + function testLowConfidenceAliasesDoNotDriveRecommendations() { const result = evaluateAliasGuard(buildSampleCorpus()); const event = byId(result.mentionDecisions, 'mention-cellule-fr'); @@ -97,6 +124,7 @@ const tests = [ testTrustedTranslatedAliasesBecomeCanonicalGraphNodes, testFalseFriendMentionsAreHeldForCuratorReview, testSameLanguageAliasCollisionsAreHeldForCuratorReview, + testUnicodeAndWhitespaceAliasesMatchCanonicalEntities, testLowConfidenceAliasesDoNotDriveRecommendations, testLanguageTaggedSynonymsArePreservedForEntityPages, testAuditDigestIsDeterministicAndPrivateFree From b91222940336e69ae38279facccb24eb9fca131c Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Fri, 29 May 2026 17:57:58 +0200 Subject: [PATCH 04/13] Harden multilingual language tag lookup --- multilingual-entity-alias-guard/README.md | 2 +- .../acceptance-notes.md | 1 + multilingual-entity-alias-guard/index.js | 11 +++++++--- .../requirements-map.md | 1 + multilingual-entity-alias-guard/test.js | 20 +++++++++++++++++++ 5 files changed, 31 insertions(+), 4 deletions(-) diff --git a/multilingual-entity-alias-guard/README.md b/multilingual-entity-alias-guard/README.md index 407141b3..ab33b495 100644 --- a/multilingual-entity-alias-guard/README.md +++ b/multilingual-entity-alias-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Knowledge Graph Integration slice for SCIBASE issue #17. It normalizes multilingual scientific mentions before they become graph nodes, entity-page aliases, or recommendation signals. -The guard accepts trusted translated aliases, preserves language tags, emits JSON-LD-style entity packets, holds homographs, false friends, and same-language alias collisions for curator review, and suppresses low-confidence aliases before recommendations are shown. +The guard accepts trusted translated aliases, preserves original language tags, normalizes language-tag casing for lookup, emits JSON-LD-style entity packets, holds homographs, false friends, and same-language alias collisions for curator review, and suppresses low-confidence aliases before recommendations are shown. ## Run diff --git a/multilingual-entity-alias-guard/acceptance-notes.md b/multilingual-entity-alias-guard/acceptance-notes.md index a6390b6a..2c73cf89 100644 --- a/multilingual-entity-alias-guard/acceptance-notes.md +++ b/multilingual-entity-alias-guard/acceptance-notes.md @@ -14,6 +14,7 @@ Validation coverage: - trusted CRISPR aliases in English, German, and Spanish map to one canonical MeSH entity - Spanish `control` is held as a homograph/false friend instead of silently creating a statistical control-group edge - same-language translated alias collisions are held instead of silently attaching a mention to the wrong canonical entity +- language-tag case differences do not suppress trusted translated aliases - low-confidence French alias output is suppressed from recommendations - localized names remain language-tagged on entity packets - audit output is deterministic and private-data free diff --git a/multilingual-entity-alias-guard/index.js b/multilingual-entity-alias-guard/index.js index c615b52b..3e338f03 100644 --- a/multilingual-entity-alias-guard/index.js +++ b/multilingual-entity-alias-guard/index.js @@ -23,13 +23,17 @@ function normalizeTerm(term) { return term.normalize('NFKC').trim().replace(/\s+/g, ' ').toLocaleLowerCase(); } +function normalizeLanguageTag(language) { + return String(language || '').normalize('NFKC').trim().toLocaleLowerCase(); +} + function buildAliasIndex(entities) { const index = new Map(); for (const entity of entities) { for (const [language, terms] of Object.entries(entity.localizedNames)) { for (const term of terms) { - const key = `${language}:${normalizeTerm(term)}`; + const key = `${normalizeLanguageTag(language)}:${normalizeTerm(term)}`; const existing = index.get(key); if (!existing) { @@ -65,10 +69,11 @@ function buildAliasIndex(entities) { } function mentionDecision(mention, aliasIndex, homographs) { - const aliasEntry = aliasIndex.get(`${mention.language}:${normalizeTerm(mention.text)}`); + const languageKey = normalizeLanguageTag(mention.language); + const aliasEntry = aliasIndex.get(`${languageKey}:${normalizeTerm(mention.text)}`); const alias = aliasEntry && aliasEntry.kind === 'alias' ? aliasEntry : null; const candidateEntityId = alias ? alias.entity.id : mention.candidateEntityId || null; - const homographKey = `${mention.language}:${normalizeTerm(mention.text)}`; + const homographKey = `${languageKey}:${normalizeTerm(mention.text)}`; if (homographs[homographKey]) { return { diff --git a/multilingual-entity-alias-guard/requirements-map.md b/multilingual-entity-alias-guard/requirements-map.md index 8e4e6980..8675b90c 100644 --- a/multilingual-entity-alias-guard/requirements-map.md +++ b/multilingual-entity-alias-guard/requirements-map.md @@ -4,6 +4,7 @@ - Preserves language-tagged mentions from uploaded papers and datasets. - Maps trusted translated aliases to canonical ontology identifiers. +- Normalizes language-tag casing for alias lookup while preserving the original tag on accepted mentions. - Holds false friends and homographs before creating graph edges. - Holds same-language alias collisions when ontology entries reuse the same translated term. - Emits schema.org-style `DefinedTerm` JSON-LD packets for entity pages. diff --git a/multilingual-entity-alias-guard/test.js b/multilingual-entity-alias-guard/test.js index 0fe9a31c..2002d087 100644 --- a/multilingual-entity-alias-guard/test.js +++ b/multilingual-entity-alias-guard/test.js @@ -89,6 +89,25 @@ function testUnicodeAndWhitespaceAliasesMatchCanonicalEntities() { assert.equal(event.candidateEntityId, 'entity:mesh:D005260'); } +function testLanguageTagCaseDoesNotSuppressTrustedAliases() { + const corpus = JSON.parse(JSON.stringify(buildSampleCorpus())); + corpus.mentions.push({ + id: 'mention-diabetes-es-uppercase', + documentId: 'paper-10', + text: 'diabetes mellitus', + language: 'ES', + confidence: 0.92 + }); + + const result = evaluateAliasGuard(corpus); + const event = byId(result.mentionDecisions, 'mention-diabetes-es-uppercase'); + + assert.equal(event.decision, 'accept-canonical-entity'); + assert.equal(event.reason, 'trusted-translated-alias'); + assert.equal(event.candidateEntityId, 'entity:mesh:D003920'); + assert.equal(event.preservedLanguageTag, 'ES'); +} + function testLowConfidenceAliasesDoNotDriveRecommendations() { const result = evaluateAliasGuard(buildSampleCorpus()); const event = byId(result.mentionDecisions, 'mention-cellule-fr'); @@ -125,6 +144,7 @@ const tests = [ testFalseFriendMentionsAreHeldForCuratorReview, testSameLanguageAliasCollisionsAreHeldForCuratorReview, testUnicodeAndWhitespaceAliasesMatchCanonicalEntities, + testLanguageTagCaseDoesNotSuppressTrustedAliases, testLowConfidenceAliasesDoNotDriveRecommendations, testLanguageTaggedSynonymsArePreservedForEntityPages, testAuditDigestIsDeterministicAndPrivateFree From 90f1648936e9e402221c566cc876a4f14f4c8785 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Fri, 29 May 2026 19:53:21 +0200 Subject: [PATCH 05/13] Support regional language alias lookup --- multilingual-entity-alias-guard/README.md | 2 +- .../acceptance-notes.md | 1 + multilingual-entity-alias-guard/index.js | 21 ++++++++-- .../requirements-map.md | 2 +- multilingual-entity-alias-guard/test.js | 41 +++++++++++++++++++ 5 files changed, 61 insertions(+), 6 deletions(-) diff --git a/multilingual-entity-alias-guard/README.md b/multilingual-entity-alias-guard/README.md index ab33b495..a0359b3f 100644 --- a/multilingual-entity-alias-guard/README.md +++ b/multilingual-entity-alias-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Knowledge Graph Integration slice for SCIBASE issue #17. It normalizes multilingual scientific mentions before they become graph nodes, entity-page aliases, or recommendation signals. -The guard accepts trusted translated aliases, preserves original language tags, normalizes language-tag casing for lookup, emits JSON-LD-style entity packets, holds homographs, false friends, and same-language alias collisions for curator review, and suppresses low-confidence aliases before recommendations are shown. +The guard accepts trusted translated aliases, preserves original language tags, normalizes language-tag casing for lookup, falls back from regional language tags to their base language, emits JSON-LD-style entity packets, holds homographs, false friends, and same-language alias collisions for curator review, and suppresses low-confidence aliases before recommendations are shown. ## Run diff --git a/multilingual-entity-alias-guard/acceptance-notes.md b/multilingual-entity-alias-guard/acceptance-notes.md index 2c73cf89..cbe96563 100644 --- a/multilingual-entity-alias-guard/acceptance-notes.md +++ b/multilingual-entity-alias-guard/acceptance-notes.md @@ -15,6 +15,7 @@ Validation coverage: - Spanish `control` is held as a homograph/false friend instead of silently creating a statistical control-group edge - same-language translated alias collisions are held instead of silently attaching a mention to the wrong canonical entity - language-tag case differences do not suppress trusted translated aliases +- regional language tags such as `es-MX` use base-language alias and homograph policy while preserving the original tag - low-confidence French alias output is suppressed from recommendations - localized names remain language-tagged on entity packets - audit output is deterministic and private-data free diff --git a/multilingual-entity-alias-guard/index.js b/multilingual-entity-alias-guard/index.js index 3e338f03..aa8baf0a 100644 --- a/multilingual-entity-alias-guard/index.js +++ b/multilingual-entity-alias-guard/index.js @@ -27,6 +27,14 @@ function normalizeLanguageTag(language) { return String(language || '').normalize('NFKC').trim().toLocaleLowerCase(); } +function languageLookupKeys(language) { + const normalized = normalizeLanguageTag(language); + if (!normalized) return ['']; + + const primary = normalized.split('-')[0]; + return primary && primary !== normalized ? [normalized, primary] : [normalized]; +} + function buildAliasIndex(entities) { const index = new Map(); @@ -69,13 +77,18 @@ function buildAliasIndex(entities) { } function mentionDecision(mention, aliasIndex, homographs) { - const languageKey = normalizeLanguageTag(mention.language); - const aliasEntry = aliasIndex.get(`${languageKey}:${normalizeTerm(mention.text)}`); + const languageKeys = languageLookupKeys(mention.language); + const termKey = normalizeTerm(mention.text); + const aliasEntry = languageKeys + .map((languageKey) => aliasIndex.get(`${languageKey}:${termKey}`)) + .find(Boolean); const alias = aliasEntry && aliasEntry.kind === 'alias' ? aliasEntry : null; const candidateEntityId = alias ? alias.entity.id : mention.candidateEntityId || null; - const homographKey = `${languageKey}:${normalizeTerm(mention.text)}`; + const homographEntry = languageKeys + .map((languageKey) => homographs[`${languageKey}:${termKey}`]) + .find(Boolean); - if (homographs[homographKey]) { + if (homographEntry) { return { id: mention.id, language: mention.language, diff --git a/multilingual-entity-alias-guard/requirements-map.md b/multilingual-entity-alias-guard/requirements-map.md index 8675b90c..fddf1965 100644 --- a/multilingual-entity-alias-guard/requirements-map.md +++ b/multilingual-entity-alias-guard/requirements-map.md @@ -4,7 +4,7 @@ - Preserves language-tagged mentions from uploaded papers and datasets. - Maps trusted translated aliases to canonical ontology identifiers. -- Normalizes language-tag casing for alias lookup while preserving the original tag on accepted mentions. +- Normalizes language-tag casing and regional subtags for alias lookup while preserving the original tag on decisions. - Holds false friends and homographs before creating graph edges. - Holds same-language alias collisions when ontology entries reuse the same translated term. - Emits schema.org-style `DefinedTerm` JSON-LD packets for entity pages. diff --git a/multilingual-entity-alias-guard/test.js b/multilingual-entity-alias-guard/test.js index 2002d087..3208f88e 100644 --- a/multilingual-entity-alias-guard/test.js +++ b/multilingual-entity-alias-guard/test.js @@ -108,6 +108,45 @@ function testLanguageTagCaseDoesNotSuppressTrustedAliases() { assert.equal(event.preservedLanguageTag, 'ES'); } +function testRegionalLanguageTagsUseBaseAliasLookup() { + const corpus = JSON.parse(JSON.stringify(buildSampleCorpus())); + corpus.mentions.push({ + id: 'mention-diabetes-es-mx', + documentId: 'paper-11', + text: 'diabetes mellitus', + language: 'es-MX', + confidence: 0.92 + }); + + const result = evaluateAliasGuard(corpus); + const event = byId(result.mentionDecisions, 'mention-diabetes-es-mx'); + + assert.equal(event.decision, 'accept-canonical-entity'); + assert.equal(event.reason, 'trusted-translated-alias'); + assert.equal(event.candidateEntityId, 'entity:mesh:D003920'); + assert.equal(event.preservedLanguageTag, 'es-MX'); +} + +function testRegionalLanguageTagsStillUseBaseHomographHolds() { + const corpus = JSON.parse(JSON.stringify(buildSampleCorpus())); + corpus.mentions.push({ + id: 'mention-control-es-mx', + documentId: 'paper-12', + text: 'control', + language: 'es-MX', + confidence: 0.88, + candidateEntityId: 'entity:stat:control-group' + }); + + const result = evaluateAliasGuard(corpus); + const event = byId(result.mentionDecisions, 'mention-control-es-mx'); + + assert.equal(event.decision, 'hold-for-curator-review'); + assert.equal(event.reason, 'false-friend-or-homograph'); + assert.equal(event.candidateEntityId, 'entity:stat:control-group'); + assert.equal(event.preservedLanguageTag, 'es-MX'); +} + function testLowConfidenceAliasesDoNotDriveRecommendations() { const result = evaluateAliasGuard(buildSampleCorpus()); const event = byId(result.mentionDecisions, 'mention-cellule-fr'); @@ -145,6 +184,8 @@ const tests = [ testSameLanguageAliasCollisionsAreHeldForCuratorReview, testUnicodeAndWhitespaceAliasesMatchCanonicalEntities, testLanguageTagCaseDoesNotSuppressTrustedAliases, + testRegionalLanguageTagsUseBaseAliasLookup, + testRegionalLanguageTagsStillUseBaseHomographHolds, testLowConfidenceAliasesDoNotDriveRecommendations, testLanguageTaggedSynonymsArePreservedForEntityPages, testAuditDigestIsDeterministicAndPrivateFree From 8011d0693d584395afe62bfe3577e8ad61fc68e7 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Fri, 29 May 2026 21:28:54 +0200 Subject: [PATCH 06/13] Require alias confidence evidence --- multilingual-entity-alias-guard/README.md | 2 +- .../acceptance-notes.md | 1 + multilingual-entity-alias-guard/index.js | 8 ++++++- .../requirements-map.md | 2 +- multilingual-entity-alias-guard/test.js | 24 +++++++++++++++++++ 5 files changed, 34 insertions(+), 3 deletions(-) diff --git a/multilingual-entity-alias-guard/README.md b/multilingual-entity-alias-guard/README.md index a0359b3f..4566e420 100644 --- a/multilingual-entity-alias-guard/README.md +++ b/multilingual-entity-alias-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Knowledge Graph Integration slice for SCIBASE issue #17. It normalizes multilingual scientific mentions before they become graph nodes, entity-page aliases, or recommendation signals. -The guard accepts trusted translated aliases, preserves original language tags, normalizes language-tag casing for lookup, falls back from regional language tags to their base language, emits JSON-LD-style entity packets, holds homographs, false friends, and same-language alias collisions for curator review, and suppresses low-confidence aliases before recommendations are shown. +The guard accepts trusted translated aliases only when numeric confidence evidence is present, preserves original language tags, normalizes language-tag casing for lookup, falls back from regional language tags to their base language, emits JSON-LD-style entity packets, holds homographs, false friends, and same-language alias collisions for curator review, and suppresses low-confidence or missing-confidence aliases before recommendations are shown. ## Run diff --git a/multilingual-entity-alias-guard/acceptance-notes.md b/multilingual-entity-alias-guard/acceptance-notes.md index cbe96563..35803985 100644 --- a/multilingual-entity-alias-guard/acceptance-notes.md +++ b/multilingual-entity-alias-guard/acceptance-notes.md @@ -17,5 +17,6 @@ Validation coverage: - language-tag case differences do not suppress trusted translated aliases - regional language tags such as `es-MX` use base-language alias and homograph policy while preserving the original tag - low-confidence French alias output is suppressed from recommendations +- missing or non-numeric confidence evidence is suppressed before graph recommendations - localized names remain language-tagged on entity packets - audit output is deterministic and private-data free diff --git a/multilingual-entity-alias-guard/index.js b/multilingual-entity-alias-guard/index.js index aa8baf0a..3e1ff384 100644 --- a/multilingual-entity-alias-guard/index.js +++ b/multilingual-entity-alias-guard/index.js @@ -35,6 +35,11 @@ function languageLookupKeys(language) { return primary && primary !== normalized ? [normalized, primary] : [normalized]; } +function confidenceScore(value) { + const score = Number(value); + return Number.isFinite(score) ? score : null; +} + function buildAliasIndex(entities) { const index = new Map(); @@ -87,6 +92,7 @@ function mentionDecision(mention, aliasIndex, homographs) { const homographEntry = languageKeys .map((languageKey) => homographs[`${languageKey}:${termKey}`]) .find(Boolean); + const confidence = confidenceScore(mention.confidence); if (homographEntry) { return { @@ -118,7 +124,7 @@ function mentionDecision(mention, aliasIndex, homographs) { }; } - if (!alias || mention.confidence < 0.8) { + if (!alias || confidence === null || confidence < 0.8) { return { id: mention.id, language: mention.language, diff --git a/multilingual-entity-alias-guard/requirements-map.md b/multilingual-entity-alias-guard/requirements-map.md index fddf1965..7b27d676 100644 --- a/multilingual-entity-alias-guard/requirements-map.md +++ b/multilingual-entity-alias-guard/requirements-map.md @@ -17,7 +17,7 @@ ## AI Research Recommendations -- Suppresses low-confidence mentions from recommendation inputs. +- Suppresses low-confidence or missing-confidence mentions from recommendation inputs. - Exposes safe canonical entity IDs for graph recommendations. - Keeps multilingual evidence auditable with deterministic digests. diff --git a/multilingual-entity-alias-guard/test.js b/multilingual-entity-alias-guard/test.js index 3208f88e..d94f69d1 100644 --- a/multilingual-entity-alias-guard/test.js +++ b/multilingual-entity-alias-guard/test.js @@ -156,6 +156,29 @@ function testLowConfidenceAliasesDoNotDriveRecommendations() { assert.equal(result.recommendationGuards.suppressedMentionIds.includes('mention-cellule-fr'), true); } +function testMissingConfidenceAliasesDoNotDriveRecommendations() { + const corpus = JSON.parse(JSON.stringify(buildSampleCorpus())); + corpus.mentions = [ + { + id: 'mention-diabetes-missing-confidence', + documentId: 'paper-13', + text: 'diabetes mellitus', + language: 'es' + } + ]; + + const result = evaluateAliasGuard(corpus); + const event = byId(result.mentionDecisions, 'mention-diabetes-missing-confidence'); + const diabetes = byId(result.entityPackets, 'entity:mesh:D003920'); + + assert.equal(event.decision, 'suppress-recommendation'); + assert.equal(event.reason, 'low-confidence-alias'); + assert.equal(event.candidateEntityId, 'entity:mesh:D003920'); + assert.equal(result.recommendationGuards.suppressedMentionIds.includes('mention-diabetes-missing-confidence'), true); + assert.equal(result.recommendationGuards.safeEntityIds.includes('entity:mesh:D003920'), false); + assert.equal(diabetes.mentions.length, 0); +} + function testLanguageTaggedSynonymsArePreservedForEntityPages() { const result = evaluateAliasGuard(buildSampleCorpus()); const diabetes = byId(result.entityPackets, 'entity:mesh:D003920'); @@ -187,6 +210,7 @@ const tests = [ testRegionalLanguageTagsUseBaseAliasLookup, testRegionalLanguageTagsStillUseBaseHomographHolds, testLowConfidenceAliasesDoNotDriveRecommendations, + testMissingConfidenceAliasesDoNotDriveRecommendations, testLanguageTaggedSynonymsArePreservedForEntityPages, testAuditDigestIsDeterministicAndPrivateFree ]; From f90337e24f714be0998ab30982a9c82d2039fc48 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Fri, 29 May 2026 23:46:37 +0200 Subject: [PATCH 07/13] Normalize underscored language regions --- multilingual-entity-alias-guard/README.md | 2 +- .../acceptance-notes.md | 1 + multilingual-entity-alias-guard/index.js | 2 +- .../requirements-map.md | 2 +- multilingual-entity-alias-guard/test.js | 34 +++++++++++++++++++ 5 files changed, 38 insertions(+), 3 deletions(-) diff --git a/multilingual-entity-alias-guard/README.md b/multilingual-entity-alias-guard/README.md index 4566e420..8032866d 100644 --- a/multilingual-entity-alias-guard/README.md +++ b/multilingual-entity-alias-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Knowledge Graph Integration slice for SCIBASE issue #17. It normalizes multilingual scientific mentions before they become graph nodes, entity-page aliases, or recommendation signals. -The guard accepts trusted translated aliases only when numeric confidence evidence is present, preserves original language tags, normalizes language-tag casing for lookup, falls back from regional language tags to their base language, emits JSON-LD-style entity packets, holds homographs, false friends, and same-language alias collisions for curator review, and suppresses low-confidence or missing-confidence aliases before recommendations are shown. +The guard accepts trusted translated aliases only when numeric confidence evidence is present, preserves original language tags, normalizes language-tag casing and underscore or hyphen regional separators for lookup, falls back from regional language tags to their base language, emits JSON-LD-style entity packets, holds homographs, false friends, and same-language alias collisions for curator review, and suppresses low-confidence or missing-confidence aliases before recommendations are shown. ## Run diff --git a/multilingual-entity-alias-guard/acceptance-notes.md b/multilingual-entity-alias-guard/acceptance-notes.md index 35803985..edf2ebd8 100644 --- a/multilingual-entity-alias-guard/acceptance-notes.md +++ b/multilingual-entity-alias-guard/acceptance-notes.md @@ -16,6 +16,7 @@ Validation coverage: - same-language translated alias collisions are held instead of silently attaching a mention to the wrong canonical entity - language-tag case differences do not suppress trusted translated aliases - regional language tags such as `es-MX` use base-language alias and homograph policy while preserving the original tag +- underscore regional language tags such as `es_MX` use the same base-language alias and homograph policy while preserving the original tag - low-confidence French alias output is suppressed from recommendations - missing or non-numeric confidence evidence is suppressed before graph recommendations - localized names remain language-tagged on entity packets diff --git a/multilingual-entity-alias-guard/index.js b/multilingual-entity-alias-guard/index.js index 3e1ff384..c2559392 100644 --- a/multilingual-entity-alias-guard/index.js +++ b/multilingual-entity-alias-guard/index.js @@ -24,7 +24,7 @@ function normalizeTerm(term) { } function normalizeLanguageTag(language) { - return String(language || '').normalize('NFKC').trim().toLocaleLowerCase(); + return String(language || '').normalize('NFKC').trim().replace(/_/g, '-').toLocaleLowerCase(); } function languageLookupKeys(language) { diff --git a/multilingual-entity-alias-guard/requirements-map.md b/multilingual-entity-alias-guard/requirements-map.md index 7b27d676..19ef958c 100644 --- a/multilingual-entity-alias-guard/requirements-map.md +++ b/multilingual-entity-alias-guard/requirements-map.md @@ -4,7 +4,7 @@ - Preserves language-tagged mentions from uploaded papers and datasets. - Maps trusted translated aliases to canonical ontology identifiers. -- Normalizes language-tag casing and regional subtags for alias lookup while preserving the original tag on decisions. +- Normalizes language-tag casing plus hyphenated or underscored regional subtags for alias lookup while preserving the original tag on decisions. - Holds false friends and homographs before creating graph edges. - Holds same-language alias collisions when ontology entries reuse the same translated term. - Emits schema.org-style `DefinedTerm` JSON-LD packets for entity pages. diff --git a/multilingual-entity-alias-guard/test.js b/multilingual-entity-alias-guard/test.js index d94f69d1..3a07436b 100644 --- a/multilingual-entity-alias-guard/test.js +++ b/multilingual-entity-alias-guard/test.js @@ -147,6 +147,39 @@ function testRegionalLanguageTagsStillUseBaseHomographHolds() { assert.equal(event.preservedLanguageTag, 'es-MX'); } +function testUnderscoreRegionalLanguageTagsUseBaseAliasAndHomographRules() { + const corpus = JSON.parse(JSON.stringify(buildSampleCorpus())); + corpus.mentions.push( + { + id: 'mention-diabetes-es-mx-underscore', + documentId: 'paper-13', + text: 'diabetes mellitus', + language: 'es_MX', + confidence: 0.92 + }, + { + id: 'mention-control-es-mx-underscore', + documentId: 'paper-14', + text: 'control', + language: 'es_MX', + confidence: 0.88, + candidateEntityId: 'entity:stat:control-group' + } + ); + + const result = evaluateAliasGuard(corpus); + const aliasEvent = byId(result.mentionDecisions, 'mention-diabetes-es-mx-underscore'); + const homographEvent = byId(result.mentionDecisions, 'mention-control-es-mx-underscore'); + + assert.equal(aliasEvent.decision, 'accept-canonical-entity'); + assert.equal(aliasEvent.reason, 'trusted-translated-alias'); + assert.equal(aliasEvent.candidateEntityId, 'entity:mesh:D003920'); + assert.equal(aliasEvent.preservedLanguageTag, 'es_MX'); + assert.equal(homographEvent.decision, 'hold-for-curator-review'); + assert.equal(homographEvent.reason, 'false-friend-or-homograph'); + assert.equal(homographEvent.candidateEntityId, 'entity:stat:control-group'); +} + function testLowConfidenceAliasesDoNotDriveRecommendations() { const result = evaluateAliasGuard(buildSampleCorpus()); const event = byId(result.mentionDecisions, 'mention-cellule-fr'); @@ -209,6 +242,7 @@ const tests = [ testLanguageTagCaseDoesNotSuppressTrustedAliases, testRegionalLanguageTagsUseBaseAliasLookup, testRegionalLanguageTagsStillUseBaseHomographHolds, + testUnderscoreRegionalLanguageTagsUseBaseAliasAndHomographRules, testLowConfidenceAliasesDoNotDriveRecommendations, testMissingConfidenceAliasesDoNotDriveRecommendations, testLanguageTaggedSynonymsArePreservedForEntityPages, From e7ffd6c1502df05640bc51249a0435a09d836825 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Sat, 30 May 2026 01:45:12 +0200 Subject: [PATCH 08/13] Hold mixed-script multilingual aliases --- multilingual-entity-alias-guard/README.md | 2 +- .../acceptance-notes.md | 1 + multilingual-entity-alias-guard/demo.js | 6 +- multilingual-entity-alias-guard/index.js | 75 ++++++++++++++++++- .../reports/alias-guard-packet.json | 34 ++++++++- .../reports/alias-guard-report.md | 7 +- .../reports/summary.svg | 4 +- .../requirements-map.md | 3 +- multilingual-entity-alias-guard/test.js | 16 +++- 9 files changed, 133 insertions(+), 15 deletions(-) diff --git a/multilingual-entity-alias-guard/README.md b/multilingual-entity-alias-guard/README.md index 8032866d..8559e5d5 100644 --- a/multilingual-entity-alias-guard/README.md +++ b/multilingual-entity-alias-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Knowledge Graph Integration slice for SCIBASE issue #17. It normalizes multilingual scientific mentions before they become graph nodes, entity-page aliases, or recommendation signals. -The guard accepts trusted translated aliases only when numeric confidence evidence is present, preserves original language tags, normalizes language-tag casing and underscore or hyphen regional separators for lookup, falls back from regional language tags to their base language, emits JSON-LD-style entity packets, holds homographs, false friends, and same-language alias collisions for curator review, and suppresses low-confidence or missing-confidence aliases before recommendations are shown. +The guard accepts trusted translated aliases only when numeric confidence evidence is present, preserves original language tags, normalizes language-tag casing and underscore or hyphen regional separators for lookup, falls back from regional language tags to their base language, emits JSON-LD-style entity packets, holds homographs, false friends, same-language alias collisions, and mixed-script Latin-language lookalikes for curator review, and suppresses low-confidence or missing-confidence aliases before recommendations are shown. ## Run diff --git a/multilingual-entity-alias-guard/acceptance-notes.md b/multilingual-entity-alias-guard/acceptance-notes.md index edf2ebd8..ae54a542 100644 --- a/multilingual-entity-alias-guard/acceptance-notes.md +++ b/multilingual-entity-alias-guard/acceptance-notes.md @@ -17,6 +17,7 @@ Validation coverage: - language-tag case differences do not suppress trusted translated aliases - regional language tags such as `es-MX` use base-language alias and homograph policy while preserving the original tag - underscore regional language tags such as `es_MX` use the same base-language alias and homograph policy while preserving the original tag +- mixed-script Latin-language aliases such as Cyrillic-lookalike `CRISPR` text are held for curator review instead of becoming quiet unknowns - low-confidence French alias output is suppressed from recommendations - missing or non-numeric confidence evidence is suppressed before graph recommendations - localized names remain language-tagged on entity packets diff --git a/multilingual-entity-alias-guard/demo.js b/multilingual-entity-alias-guard/demo.js index 999d39ce..51670739 100644 --- a/multilingual-entity-alias-guard/demo.js +++ b/multilingual-entity-alias-guard/demo.js @@ -30,7 +30,7 @@ Generated: ${result.generatedAt} ## Summary - Accepted mentions: ${result.summary.acceptedMentions} -- Held homograph mentions: ${result.summary.heldMentions} +- Held curator-review mentions: ${result.summary.heldMentions} - Suppressed low-confidence mentions: ${result.summary.suppressedMentions} - Entity packets emitted: ${result.summary.entityPackets} - Audit digest: ${result.auditDigest} @@ -45,7 +45,7 @@ ${held} ## Recommendation Guard -Suppressed mentions are not allowed to drive entity-page recommendations until a curator verifies the alias mapping. +Held or suppressed mentions are not allowed to drive entity-page recommendations until a curator verifies the alias mapping. ## Safety @@ -59,7 +59,7 @@ const svg = ` Multilingual Entity Alias Guard Accepted canonical mentions: ${result.summary.acceptedMentions} - Held homograph mentions: ${result.summary.heldMentions} + Held curator-review mentions: ${result.summary.heldMentions} Suppressed low-confidence mentions: ${result.summary.suppressedMentions} Languages preserved: en, de, es, fr JSON-LD entity packets ready for schema.org-style pages diff --git a/multilingual-entity-alias-guard/index.js b/multilingual-entity-alias-guard/index.js index c2559392..8fcaccf2 100644 --- a/multilingual-entity-alias-guard/index.js +++ b/multilingual-entity-alias-guard/index.js @@ -19,6 +19,35 @@ function digest(value) { return `sha256:${crypto.createHash('sha256').update(stableStringify(value)).digest('hex')}`; } +const LATIN_SCRIPT_LANGUAGES = new Set([ + 'ca', + 'cs', + 'da', + 'de', + 'en', + 'es', + 'fi', + 'fr', + 'hr', + 'hu', + 'id', + 'it', + 'nl', + 'no', + 'pl', + 'pt', + 'ro', + 'sk', + 'sl', + 'sv', + 'tr', + 'vi' +]); + +const LATIN_LETTER_RE = /[A-Za-z\u00C0-\u024F]/u; +const CYRILLIC_LATIN_CONFUSABLE_RE = /[\u0405\u0410\u0412\u0415\u041A\u041C\u041D\u041E\u0420\u0421\u0422\u0425\u0430\u0435\u043E\u0440\u0441\u0445\u0455]/u; +const GREEK_LATIN_CONFUSABLE_RE = /[\u0391\u0392\u0395\u0396\u0397\u0399\u039A\u039C\u039D\u039F\u03A1\u03A4\u03A5\u03A7]/u; + function normalizeTerm(term) { return term.normalize('NFKC').trim().replace(/\s+/g, ' ').toLocaleLowerCase(); } @@ -35,6 +64,23 @@ function languageLookupKeys(language) { return primary && primary !== normalized ? [normalized, primary] : [normalized]; } +function primaryLanguage(language) { + return normalizeLanguageTag(language).split('-')[0] || ''; +} + +function hasMixedScriptConfusableRisk(mention) { + const primary = primaryLanguage(mention.language); + if (!LATIN_SCRIPT_LANGUAGES.has(primary)) { + return false; + } + + const text = String(mention.text || '').normalize('NFKC'); + return ( + LATIN_LETTER_RE.test(text) && + (CYRILLIC_LATIN_CONFUSABLE_RE.test(text) || GREEK_LATIN_CONFUSABLE_RE.test(text)) + ); +} + function confidenceScore(value) { const score = Number(value); return Number.isFinite(score) ? score : null; @@ -94,6 +140,21 @@ function mentionDecision(mention, aliasIndex, homographs) { .find(Boolean); const confidence = confidenceScore(mention.confidence); + if (hasMixedScriptConfusableRisk(mention)) { + return { + id: mention.id, + language: mention.language, + text: mention.text, + documentId: mention.documentId, + decision: 'hold-for-curator-review', + reason: 'script-confusable-alias', + candidateEntityId, + candidateEntityIds: candidateEntityId ? [candidateEntityId] : [], + confidence: mention.confidence, + preservedLanguageTag: mention.language + }; + } + if (homographEntry) { return { id: mention.id, @@ -164,11 +225,15 @@ function curatorActionForDecision(decision) { action: decision.reason === 'alias-collision' ? 'review-multilingual-alias-collision' + : decision.reason === 'script-confusable-alias' + ? 'review-multilingual-script-confusable' : decision.reason === 'false-friend-or-homograph' ? 'review-multilingual-homograph' : 'verify-translated-alias-before-recommendation', priority: - decision.reason === 'false-friend-or-homograph' || decision.reason === 'alias-collision' + decision.reason === 'false-friend-or-homograph' || + decision.reason === 'alias-collision' || + decision.reason === 'script-confusable-alias' ? 'high' : 'normal', language: decision.language, @@ -371,6 +436,14 @@ function buildSampleCorpus() { language: 'fr', confidence: 0.61, candidateEntityId: 'entity:mesh:D002477' + }, + { + id: 'mention-crispr-cyrillic-spoof', + documentId: 'paper-9', + text: '\u0421RISPR-Cas9', + language: 'en', + confidence: 0.97, + candidateEntityId: 'entity:mesh:D000077768' } ] }; diff --git a/multilingual-entity-alias-guard/reports/alias-guard-packet.json b/multilingual-entity-alias-guard/reports/alias-guard-packet.json index ecc84550..415eef4c 100644 --- a/multilingual-entity-alias-guard/reports/alias-guard-packet.json +++ b/multilingual-entity-alias-guard/reports/alias-guard-packet.json @@ -113,6 +113,20 @@ ], "confidence": 0.61, "preservedLanguageTag": "fr" + }, + { + "id": "mention-crispr-cyrillic-spoof", + "language": "en", + "text": "СRISPR-Cas9", + "documentId": "paper-9", + "decision": "hold-for-curator-review", + "reason": "script-confusable-alias", + "candidateEntityId": "entity:mesh:D000077768", + "candidateEntityIds": [ + "entity:mesh:D000077768" + ], + "confidence": 0.97, + "preservedLanguageTag": "en" } ], "entityPackets": [ @@ -338,12 +352,26 @@ "entity:mesh:D002477" ], "reason": "low-confidence-alias" + }, + { + "id": "curate-mention-crispr-cyrillic-spoof", + "mentionId": "mention-crispr-cyrillic-spoof", + "action": "review-multilingual-script-confusable", + "priority": "high", + "language": "en", + "text": "СRISPR-Cas9", + "candidateEntityId": "entity:mesh:D000077768", + "candidateEntityIds": [ + "entity:mesh:D000077768" + ], + "reason": "script-confusable-alias" } ], "recommendationGuards": { "suppressedMentionIds": [ "mention-control-es", - "mention-cellule-fr" + "mention-cellule-fr", + "mention-crispr-cyrillic-spoof" ], "safeEntityIds": [ "entity:mesh:D000077768", @@ -352,9 +380,9 @@ }, "summary": { "acceptedMentions": 6, - "heldMentions": 1, + "heldMentions": 2, "suppressedMentions": 1, "entityPackets": 3 }, - "auditDigest": "sha256:8bd1da50a253839d7f9becefa35e9192633262c8ece8caa8096ebb161ba52457" + "auditDigest": "sha256:58b1b2b3395ce6655f497a7bd521b57f00def5458b200200c46fa0f76ad854db" } diff --git a/multilingual-entity-alias-guard/reports/alias-guard-report.md b/multilingual-entity-alias-guard/reports/alias-guard-report.md index 5cbaf803..435e6888 100644 --- a/multilingual-entity-alias-guard/reports/alias-guard-report.md +++ b/multilingual-entity-alias-guard/reports/alias-guard-report.md @@ -6,10 +6,10 @@ Generated: 2026-05-28T07:00:00Z ## Summary - Accepted mentions: 6 -- Held homograph mentions: 1 +- Held curator-review mentions: 2 - Suppressed low-confidence mentions: 1 - Entity packets emitted: 3 -- Audit digest: sha256:8bd1da50a253839d7f9becefa35e9192633262c8ece8caa8096ebb161ba52457 +- Audit digest: sha256:58b1b2b3395ce6655f497a7bd521b57f00def5458b200200c46fa0f76ad854db ## Accepted Canonical Mappings @@ -24,10 +24,11 @@ Generated: 2026-05-28T07:00:00Z - curate-mention-control-es: review-multilingual-homograph (es:control) - curate-mention-cellule-fr: verify-translated-alias-before-recommendation (fr:cellule) +- curate-mention-crispr-cyrillic-spoof: review-multilingual-script-confusable (en:СRISPR-Cas9) ## Recommendation Guard -Suppressed mentions are not allowed to drive entity-page recommendations until a curator verifies the alias mapping. +Held or suppressed mentions are not allowed to drive entity-page recommendations until a curator verifies the alias mapping. ## Safety diff --git a/multilingual-entity-alias-guard/reports/summary.svg b/multilingual-entity-alias-guard/reports/summary.svg index 93d911d5..ef1b1f9a 100644 --- a/multilingual-entity-alias-guard/reports/summary.svg +++ b/multilingual-entity-alias-guard/reports/summary.svg @@ -3,10 +3,10 @@ Multilingual Entity Alias Guard Accepted canonical mentions: 6 - Held homograph mentions: 1 + Held curator-review mentions: 2 Suppressed low-confidence mentions: 1 Languages preserved: en, de, es, fr JSON-LD entity packets ready for schema.org-style pages Unsafe aliases are held before graph recommendations are shown. - sha256:8bd1da50a253839d7f9becefa35e9192633262c8ece8caa8096ebb161ba52457 + sha256:58b1b2b3395ce6655f497a7bd521b57f00def5458b200200c46fa0f76ad854db diff --git a/multilingual-entity-alias-guard/requirements-map.md b/multilingual-entity-alias-guard/requirements-map.md index 19ef958c..37661572 100644 --- a/multilingual-entity-alias-guard/requirements-map.md +++ b/multilingual-entity-alias-guard/requirements-map.md @@ -7,6 +7,7 @@ - Normalizes language-tag casing plus hyphenated or underscored regional subtags for alias lookup while preserving the original tag on decisions. - Holds false friends and homographs before creating graph edges. - Holds same-language alias collisions when ontology entries reuse the same translated term. +- Holds Latin-language mentions with Cyrillic or Greek lookalike characters for curator review before creating graph edges. - Emits schema.org-style `DefinedTerm` JSON-LD packets for entity pages. ## Knowledge Navigation @@ -25,4 +26,4 @@ - Synthetic data only. - No credentials, private corpora, live ontology calls, external APIs, or production recommendation systems. -- This slice is distinct from ontology drift, synonym dedupe, temporal validity, geospatial provenance, and recommendation visibility/diversity guards. +- This slice is distinct from ontology drift, synonym dedupe, generic entity disambiguation, temporal validity, geospatial provenance, and recommendation visibility/diversity guards. diff --git a/multilingual-entity-alias-guard/test.js b/multilingual-entity-alias-guard/test.js index 3a07436b..3d406888 100644 --- a/multilingual-entity-alias-guard/test.js +++ b/multilingual-entity-alias-guard/test.js @@ -180,6 +180,19 @@ function testUnderscoreRegionalLanguageTagsUseBaseAliasAndHomographRules() { assert.equal(homographEvent.candidateEntityId, 'entity:stat:control-group'); } +function testMixedScriptLatinLanguageMentionsAreHeldForCuratorReview() { + const result = evaluateAliasGuard(buildSampleCorpus()); + const event = byId(result.mentionDecisions, 'mention-crispr-cyrillic-spoof'); + + assert.equal(event.decision, 'hold-for-curator-review'); + assert.equal(event.reason, 'script-confusable-alias'); + assert.equal(event.candidateEntityId, 'entity:mesh:D000077768'); + + const action = byId(result.curatorActions, 'curate-mention-crispr-cyrillic-spoof'); + assert.equal(action.priority, 'high'); + assert.equal(action.action, 'review-multilingual-script-confusable'); +} + function testLowConfidenceAliasesDoNotDriveRecommendations() { const result = evaluateAliasGuard(buildSampleCorpus()); const event = byId(result.mentionDecisions, 'mention-cellule-fr'); @@ -229,7 +242,7 @@ function testAuditDigestIsDeterministicAndPrivateFree() { assert.ok(result.auditDigest.startsWith('sha256:')); assert.equal(result.summary.acceptedMentions, 6); - assert.equal(result.summary.heldMentions, 1); + assert.equal(result.summary.heldMentions, 2); assert.equal(result.summary.suppressedMentions, 1); assert.ok(!JSON.stringify(result).includes('private@')); } @@ -243,6 +256,7 @@ const tests = [ testRegionalLanguageTagsUseBaseAliasLookup, testRegionalLanguageTagsStillUseBaseHomographHolds, testUnderscoreRegionalLanguageTagsUseBaseAliasAndHomographRules, + testMixedScriptLatinLanguageMentionsAreHeldForCuratorReview, testLowConfidenceAliasesDoNotDriveRecommendations, testMissingConfidenceAliasesDoNotDriveRecommendations, testLanguageTaggedSynonymsArePreservedForEntityPages, From 2e4c82264debea17e3b5ff87d346133a0c94ca32 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Sat, 30 May 2026 10:05:01 +0200 Subject: [PATCH 09/13] Detect lowercase Greek alias spoofs --- multilingual-entity-alias-guard/README.md | 2 +- .../acceptance-notes.md | 2 +- multilingual-entity-alias-guard/index.js | 10 +++++- .../reports/alias-guard-packet.json | 34 +++++++++++++++++-- .../reports/alias-guard-report.md | 5 +-- .../reports/summary.svg | 4 +-- .../requirements-map.md | 2 +- multilingual-entity-alias-guard/test.js | 27 ++++++++++++++- 8 files changed, 74 insertions(+), 12 deletions(-) diff --git a/multilingual-entity-alias-guard/README.md b/multilingual-entity-alias-guard/README.md index 8559e5d5..7c3cdf6d 100644 --- a/multilingual-entity-alias-guard/README.md +++ b/multilingual-entity-alias-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Knowledge Graph Integration slice for SCIBASE issue #17. It normalizes multilingual scientific mentions before they become graph nodes, entity-page aliases, or recommendation signals. -The guard accepts trusted translated aliases only when numeric confidence evidence is present, preserves original language tags, normalizes language-tag casing and underscore or hyphen regional separators for lookup, falls back from regional language tags to their base language, emits JSON-LD-style entity packets, holds homographs, false friends, same-language alias collisions, and mixed-script Latin-language lookalikes for curator review, and suppresses low-confidence or missing-confidence aliases before recommendations are shown. +The guard accepts trusted translated aliases only when numeric confidence evidence is present, preserves original language tags, normalizes language-tag casing and underscore or hyphen regional separators for lookup, falls back from regional language tags to their base language, emits JSON-LD-style entity packets, holds homographs, false friends, same-language alias collisions, and mixed-script Latin-language lookalikes including lowercase Greek or Cyrillic confusables for curator review, and suppresses low-confidence or missing-confidence aliases before recommendations are shown. ## Run diff --git a/multilingual-entity-alias-guard/acceptance-notes.md b/multilingual-entity-alias-guard/acceptance-notes.md index ae54a542..04a0ae21 100644 --- a/multilingual-entity-alias-guard/acceptance-notes.md +++ b/multilingual-entity-alias-guard/acceptance-notes.md @@ -17,7 +17,7 @@ Validation coverage: - language-tag case differences do not suppress trusted translated aliases - regional language tags such as `es-MX` use base-language alias and homograph policy while preserving the original tag - underscore regional language tags such as `es_MX` use the same base-language alias and homograph policy while preserving the original tag -- mixed-script Latin-language aliases such as Cyrillic-lookalike `CRISPR` text are held for curator review instead of becoming quiet unknowns +- mixed-script Latin-language aliases such as Cyrillic-lookalike `CRISPR` text or lowercase Greek-alpha `CRISPR-Cαs9` text are held for curator review instead of becoming quiet unknowns - low-confidence French alias output is suppressed from recommendations - missing or non-numeric confidence evidence is suppressed before graph recommendations - localized names remain language-tagged on entity packets diff --git a/multilingual-entity-alias-guard/index.js b/multilingual-entity-alias-guard/index.js index 8fcaccf2..cd87c9fd 100644 --- a/multilingual-entity-alias-guard/index.js +++ b/multilingual-entity-alias-guard/index.js @@ -46,7 +46,7 @@ const LATIN_SCRIPT_LANGUAGES = new Set([ const LATIN_LETTER_RE = /[A-Za-z\u00C0-\u024F]/u; const CYRILLIC_LATIN_CONFUSABLE_RE = /[\u0405\u0410\u0412\u0415\u041A\u041C\u041D\u041E\u0420\u0421\u0422\u0425\u0430\u0435\u043E\u0440\u0441\u0445\u0455]/u; -const GREEK_LATIN_CONFUSABLE_RE = /[\u0391\u0392\u0395\u0396\u0397\u0399\u039A\u039C\u039D\u039F\u03A1\u03A4\u03A5\u03A7]/u; +const GREEK_LATIN_CONFUSABLE_RE = /[\u0391\u0392\u0395\u0396\u0397\u0399\u039A\u039C\u039D\u039F\u03A1\u03A4\u03A5\u03A7\u03B1\u03B5\u03B9\u03BA\u03BC\u03BD\u03BF\u03C1\u03C4\u03C5\u03C7]/u; function normalizeTerm(term) { return term.normalize('NFKC').trim().replace(/\s+/g, ' ').toLocaleLowerCase(); @@ -444,6 +444,14 @@ function buildSampleCorpus() { language: 'en', confidence: 0.97, candidateEntityId: 'entity:mesh:D000077768' + }, + { + id: 'mention-crispr-greek-alpha-spoof', + documentId: 'paper-10', + text: 'CRISPR-C\u03B1s9', + language: 'en', + confidence: 0.97, + candidateEntityId: 'entity:mesh:D000077768' } ] }; diff --git a/multilingual-entity-alias-guard/reports/alias-guard-packet.json b/multilingual-entity-alias-guard/reports/alias-guard-packet.json index 415eef4c..e489d936 100644 --- a/multilingual-entity-alias-guard/reports/alias-guard-packet.json +++ b/multilingual-entity-alias-guard/reports/alias-guard-packet.json @@ -127,6 +127,20 @@ ], "confidence": 0.97, "preservedLanguageTag": "en" + }, + { + "id": "mention-crispr-greek-alpha-spoof", + "language": "en", + "text": "CRISPR-Cαs9", + "documentId": "paper-10", + "decision": "hold-for-curator-review", + "reason": "script-confusable-alias", + "candidateEntityId": "entity:mesh:D000077768", + "candidateEntityIds": [ + "entity:mesh:D000077768" + ], + "confidence": 0.97, + "preservedLanguageTag": "en" } ], "entityPackets": [ @@ -365,13 +379,27 @@ "entity:mesh:D000077768" ], "reason": "script-confusable-alias" + }, + { + "id": "curate-mention-crispr-greek-alpha-spoof", + "mentionId": "mention-crispr-greek-alpha-spoof", + "action": "review-multilingual-script-confusable", + "priority": "high", + "language": "en", + "text": "CRISPR-Cαs9", + "candidateEntityId": "entity:mesh:D000077768", + "candidateEntityIds": [ + "entity:mesh:D000077768" + ], + "reason": "script-confusable-alias" } ], "recommendationGuards": { "suppressedMentionIds": [ "mention-control-es", "mention-cellule-fr", - "mention-crispr-cyrillic-spoof" + "mention-crispr-cyrillic-spoof", + "mention-crispr-greek-alpha-spoof" ], "safeEntityIds": [ "entity:mesh:D000077768", @@ -380,9 +408,9 @@ }, "summary": { "acceptedMentions": 6, - "heldMentions": 2, + "heldMentions": 3, "suppressedMentions": 1, "entityPackets": 3 }, - "auditDigest": "sha256:58b1b2b3395ce6655f497a7bd521b57f00def5458b200200c46fa0f76ad854db" + "auditDigest": "sha256:48d59a0c5224f91e46bbcd93174e2ce12a6f0008946fbcea6f7608abd6798778" } diff --git a/multilingual-entity-alias-guard/reports/alias-guard-report.md b/multilingual-entity-alias-guard/reports/alias-guard-report.md index 435e6888..76dbc245 100644 --- a/multilingual-entity-alias-guard/reports/alias-guard-report.md +++ b/multilingual-entity-alias-guard/reports/alias-guard-report.md @@ -6,10 +6,10 @@ Generated: 2026-05-28T07:00:00Z ## Summary - Accepted mentions: 6 -- Held curator-review mentions: 2 +- Held curator-review mentions: 3 - Suppressed low-confidence mentions: 1 - Entity packets emitted: 3 -- Audit digest: sha256:58b1b2b3395ce6655f497a7bd521b57f00def5458b200200c46fa0f76ad854db +- Audit digest: sha256:48d59a0c5224f91e46bbcd93174e2ce12a6f0008946fbcea6f7608abd6798778 ## Accepted Canonical Mappings @@ -25,6 +25,7 @@ Generated: 2026-05-28T07:00:00Z - curate-mention-control-es: review-multilingual-homograph (es:control) - curate-mention-cellule-fr: verify-translated-alias-before-recommendation (fr:cellule) - curate-mention-crispr-cyrillic-spoof: review-multilingual-script-confusable (en:СRISPR-Cas9) +- curate-mention-crispr-greek-alpha-spoof: review-multilingual-script-confusable (en:CRISPR-Cαs9) ## Recommendation Guard diff --git a/multilingual-entity-alias-guard/reports/summary.svg b/multilingual-entity-alias-guard/reports/summary.svg index ef1b1f9a..183df421 100644 --- a/multilingual-entity-alias-guard/reports/summary.svg +++ b/multilingual-entity-alias-guard/reports/summary.svg @@ -3,10 +3,10 @@ Multilingual Entity Alias Guard Accepted canonical mentions: 6 - Held curator-review mentions: 2 + Held curator-review mentions: 3 Suppressed low-confidence mentions: 1 Languages preserved: en, de, es, fr JSON-LD entity packets ready for schema.org-style pages Unsafe aliases are held before graph recommendations are shown. - sha256:58b1b2b3395ce6655f497a7bd521b57f00def5458b200200c46fa0f76ad854db + sha256:48d59a0c5224f91e46bbcd93174e2ce12a6f0008946fbcea6f7608abd6798778 diff --git a/multilingual-entity-alias-guard/requirements-map.md b/multilingual-entity-alias-guard/requirements-map.md index 37661572..56ce3beb 100644 --- a/multilingual-entity-alias-guard/requirements-map.md +++ b/multilingual-entity-alias-guard/requirements-map.md @@ -7,7 +7,7 @@ - Normalizes language-tag casing plus hyphenated or underscored regional subtags for alias lookup while preserving the original tag on decisions. - Holds false friends and homographs before creating graph edges. - Holds same-language alias collisions when ontology entries reuse the same translated term. -- Holds Latin-language mentions with Cyrillic or Greek lookalike characters for curator review before creating graph edges. +- Holds Latin-language mentions with Cyrillic or Greek lookalike characters, including lowercase Greek confusables, for curator review before creating graph edges. - Emits schema.org-style `DefinedTerm` JSON-LD packets for entity pages. ## Knowledge Navigation diff --git a/multilingual-entity-alias-guard/test.js b/multilingual-entity-alias-guard/test.js index 3d406888..f38d4921 100644 --- a/multilingual-entity-alias-guard/test.js +++ b/multilingual-entity-alias-guard/test.js @@ -193,6 +193,30 @@ function testMixedScriptLatinLanguageMentionsAreHeldForCuratorReview() { assert.equal(action.action, 'review-multilingual-script-confusable'); } +function testLowercaseGreekLookalikeLatinMentionsAreHeldForCuratorReview() { + const corpus = JSON.parse(JSON.stringify(buildSampleCorpus())); + corpus.mentions = [ + { + id: 'mention-crispr-greek-alpha-spoof', + documentId: 'paper-15', + text: 'CRISPR-C\u03B1s9', + language: 'en', + confidence: 0.97, + candidateEntityId: 'entity:mesh:D000077768' + } + ]; + + const result = evaluateAliasGuard(corpus); + const event = byId(result.mentionDecisions, 'mention-crispr-greek-alpha-spoof'); + const action = byId(result.curatorActions, 'curate-mention-crispr-greek-alpha-spoof'); + + assert.equal(event.decision, 'hold-for-curator-review'); + assert.equal(event.reason, 'script-confusable-alias'); + assert.equal(event.candidateEntityId, 'entity:mesh:D000077768'); + assert.equal(action.priority, 'high'); + assert.equal(action.action, 'review-multilingual-script-confusable'); +} + function testLowConfidenceAliasesDoNotDriveRecommendations() { const result = evaluateAliasGuard(buildSampleCorpus()); const event = byId(result.mentionDecisions, 'mention-cellule-fr'); @@ -242,7 +266,7 @@ function testAuditDigestIsDeterministicAndPrivateFree() { assert.ok(result.auditDigest.startsWith('sha256:')); assert.equal(result.summary.acceptedMentions, 6); - assert.equal(result.summary.heldMentions, 2); + assert.equal(result.summary.heldMentions, 3); assert.equal(result.summary.suppressedMentions, 1); assert.ok(!JSON.stringify(result).includes('private@')); } @@ -257,6 +281,7 @@ const tests = [ testRegionalLanguageTagsStillUseBaseHomographHolds, testUnderscoreRegionalLanguageTagsUseBaseAliasAndHomographRules, testMixedScriptLatinLanguageMentionsAreHeldForCuratorReview, + testLowercaseGreekLookalikeLatinMentionsAreHeldForCuratorReview, testLowConfidenceAliasesDoNotDriveRecommendations, testMissingConfidenceAliasesDoNotDriveRecommendations, testLanguageTaggedSynonymsArePreservedForEntityPages, From 2b9700d813a3f6c504942369dbdacb812beb7ac9 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Sat, 30 May 2026 12:12:19 +0200 Subject: [PATCH 10/13] Handle sparse multilingual alias payloads --- multilingual-entity-alias-guard/README.md | 3 +- .../acceptance-notes.md | 1 + multilingual-entity-alias-guard/demo.js | 19 +++++ multilingual-entity-alias-guard/index.js | 31 +++++--- .../make-demo-video.py | 3 +- .../reports/alias-guard-report.md | 4 ++ .../reports/demo.mp4 | Bin 46481 -> 50395 bytes .../reports/sparse-alias-guard-packet.json | 40 +++++++++++ .../requirements-map.md | 1 + multilingual-entity-alias-guard/test.js | 67 ++++++++++++++++++ 10 files changed, 159 insertions(+), 10 deletions(-) create mode 100644 multilingual-entity-alias-guard/reports/sparse-alias-guard-packet.json diff --git a/multilingual-entity-alias-guard/README.md b/multilingual-entity-alias-guard/README.md index 7c3cdf6d..8bd997d5 100644 --- a/multilingual-entity-alias-guard/README.md +++ b/multilingual-entity-alias-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Knowledge Graph Integration slice for SCIBASE issue #17. It normalizes multilingual scientific mentions before they become graph nodes, entity-page aliases, or recommendation signals. -The guard accepts trusted translated aliases only when numeric confidence evidence is present, preserves original language tags, normalizes language-tag casing and underscore or hyphen regional separators for lookup, falls back from regional language tags to their base language, emits JSON-LD-style entity packets, holds homographs, false friends, same-language alias collisions, and mixed-script Latin-language lookalikes including lowercase Greek or Cyrillic confusables for curator review, and suppresses low-confidence or missing-confidence aliases before recommendations are shown. +The guard accepts trusted translated aliases only when numeric confidence evidence is present, preserves original language tags, normalizes language-tag casing and underscore or hyphen regional separators for lookup, falls back from regional language tags to their base language, emits JSON-LD-style entity packets, holds homographs, false friends, same-language alias collisions, and mixed-script Latin-language lookalikes including lowercase Greek or Cyrillic confusables for curator review, suppresses low-confidence or missing-confidence aliases before recommendations are shown, and treats omitted localized names, mentions, or homograph policies as sparse graph evidence instead of crashing corpus review. ## Run @@ -16,6 +16,7 @@ npm run check ## Outputs - `reports/alias-guard-packet.json` +- `reports/sparse-alias-guard-packet.json` - `reports/alias-guard-report.md` - `reports/summary.svg` - `reports/demo.mp4` diff --git a/multilingual-entity-alias-guard/acceptance-notes.md b/multilingual-entity-alias-guard/acceptance-notes.md index 04a0ae21..4771a484 100644 --- a/multilingual-entity-alias-guard/acceptance-notes.md +++ b/multilingual-entity-alias-guard/acceptance-notes.md @@ -20,5 +20,6 @@ Validation coverage: - mixed-script Latin-language aliases such as Cyrillic-lookalike `CRISPR` text or lowercase Greek-alpha `CRISPR-Cαs9` text are held for curator review instead of becoming quiet unknowns - low-confidence French alias output is suppressed from recommendations - missing or non-numeric confidence evidence is suppressed before graph recommendations +- sparse ontology/corpus exports with omitted localized names, mention lists, or homograph policies do not crash corpus review - localized names remain language-tagged on entity packets - audit output is deterministic and private-data free diff --git a/multilingual-entity-alias-guard/demo.js b/multilingual-entity-alias-guard/demo.js index 51670739..f3eac042 100644 --- a/multilingual-entity-alias-guard/demo.js +++ b/multilingual-entity-alias-guard/demo.js @@ -6,12 +6,26 @@ const reportsDir = path.join(__dirname, 'reports'); fs.mkdirSync(reportsDir, { recursive: true }); const result = evaluateAliasGuard(buildSampleCorpus()); +const sparseResult = evaluateAliasGuard({ + corpusId: 'kg-sparse-ontology-export-17', + generatedAt: '2026-05-30T12:00:00Z', + entities: [ + { + id: 'entity:mesh:D012345', + canonicalName: 'Sparse Ontology Entity', + ontology: 'MeSH', + identifier: 'D012345' + } + ] +}); const packetPath = path.join(reportsDir, 'alias-guard-packet.json'); +const sparsePacketPath = path.join(reportsDir, 'sparse-alias-guard-packet.json'); const reportPath = path.join(reportsDir, 'alias-guard-report.md'); const svgPath = path.join(reportsDir, 'summary.svg'); fs.writeFileSync(packetPath, `${JSON.stringify(result, null, 2)}\n`); +fs.writeFileSync(sparsePacketPath, `${JSON.stringify(sparseResult, null, 2)}\n`); const accepted = result.mentionDecisions .filter((decision) => decision.decision === 'accept-canonical-entity') @@ -47,6 +61,10 @@ ${held} Held or suppressed mentions are not allowed to drive entity-page recommendations until a curator verifies the alias mapping. +## Sparse Corpus Guard + +Sparse ontology or corpus exports that omit localized names, mention lists, or homograph policy still produce deterministic graph review evidence. The sparse fixture emitted ${sparseResult.summary.entityPackets} entity packet and ${sparseResult.mentionDecisions.length} mention decisions. + ## Safety All fixtures are synthetic. The module does not call live ontologies, identity providers, external APIs, private corpora, search indexes, or recommendation systems. @@ -71,6 +89,7 @@ const svg = ` { + return evidenceList(entities).map((entity) => { + const localizedNames = localizedNamesFor(entity); const accepted = decisions.filter( (decision) => decision.decision === 'accept-canonical-entity' && decision.candidateEntityId === entity.id @@ -265,14 +280,14 @@ function buildEntityPackets(entities, decisions) { documentId: decision.documentId, confidence: decision.confidence })), - localizedNames: entity.localizedNames, + localizedNames, jsonLd: { '@context': 'https://schema.org', '@type': 'DefinedTerm', name: entity.canonicalName, identifier: `${entity.ontology}:${entity.identifier}`, inDefinedTermSet: entity.ontology, - alternateName: Object.values(entity.localizedNames).flat() + alternateName: Object.values(localizedNames).flat() }, schemaOrg: { '@type': 'ScholarlyArticle', @@ -289,8 +304,8 @@ function buildEntityPackets(entities, decisions) { function evaluateAliasGuard(corpus) { const aliasIndex = buildAliasIndex(corpus.entities); - const mentionDecisions = corpus.mentions.map((mention) => - mentionDecision(mention, aliasIndex, corpus.homographs) + const mentionDecisions = evidenceList(corpus.mentions).map((mention) => + mentionDecision(mention, aliasIndex, evidenceObject(corpus.homographs)) ); const curatorActions = mentionDecisions.map(curatorActionForDecision).filter(Boolean); const entityPackets = buildEntityPackets(corpus.entities, mentionDecisions); diff --git a/multilingual-entity-alias-guard/make-demo-video.py b/multilingual-entity-alias-guard/make-demo-video.py index a860d754..1b02492c 100644 --- a/multilingual-entity-alias-guard/make-demo-video.py +++ b/multilingual-entity-alias-guard/make-demo-video.py @@ -17,7 +17,8 @@ f"drawtext=fontfile='{font}':text='Preserves language tags for entity pages and JSON-LD':x=92:y=266:fontsize=30:fontcolor=0xd8f6df", f"drawtext=fontfile='{font}':text='Holds homographs and false friends for curator review':x=92:y=326:fontsize=30:fontcolor=0xd8f6df", f"drawtext=fontfile='{font}':text='Suppresses weak aliases before recommendations are shown':x=92:y=386:fontsize=30:fontcolor=0xd8f6df", - f"drawtext=fontfile='{font}':text='SCIBASE issue #17 multilingual KG integration slice':x=92:y=506:fontsize=28:fontcolor=0xffd37a", + f"drawtext=fontfile='{font}':text='Handles sparse ontology exports without runtime failures':x=92:y=446:fontsize=30:fontcolor=0xd8f6df", + f"drawtext=fontfile='{font}':text='SCIBASE issue #17 multilingual KG integration slice':x=92:y=536:fontsize=28:fontcolor=0xffd37a", ] ) diff --git a/multilingual-entity-alias-guard/reports/alias-guard-report.md b/multilingual-entity-alias-guard/reports/alias-guard-report.md index 76dbc245..3fb5fa60 100644 --- a/multilingual-entity-alias-guard/reports/alias-guard-report.md +++ b/multilingual-entity-alias-guard/reports/alias-guard-report.md @@ -31,6 +31,10 @@ Generated: 2026-05-28T07:00:00Z Held or suppressed mentions are not allowed to drive entity-page recommendations until a curator verifies the alias mapping. +## Sparse Corpus Guard + +Sparse ontology or corpus exports that omit localized names, mention lists, or homograph policy still produce deterministic graph review evidence. The sparse fixture emitted 1 entity packet and 0 mention decisions. + ## Safety All fixtures are synthetic. The module does not call live ontologies, identity providers, external APIs, private corpora, search indexes, or recommendation systems. diff --git a/multilingual-entity-alias-guard/reports/demo.mp4 b/multilingual-entity-alias-guard/reports/demo.mp4 index 5fe7e04fe76457618cd3937a17ed2ad8fd605079..6efc79a6247e7e0c38eaf4b2bea1804ee182dcc8 100644 GIT binary patch delta 14743 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 delta 10788 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 Date: Sat, 30 May 2026 14:42:51 +0200 Subject: [PATCH 11/13] Harden multilingual alias candidate conflicts --- multilingual-entity-alias-guard/README.md | 3 +- .../acceptance-notes.md | 1 + multilingual-entity-alias-guard/demo.js | 22 +++ multilingual-entity-alias-guard/index.js | 18 ++ .../reports/alias-guard-report.md | 4 + .../candidate-alias-conflict-packet.json | 157 ++++++++++++++++++ .../requirements-map.md | 1 + multilingual-entity-alias-guard/test.js | 31 ++++ 8 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 multilingual-entity-alias-guard/reports/candidate-alias-conflict-packet.json diff --git a/multilingual-entity-alias-guard/README.md b/multilingual-entity-alias-guard/README.md index 8bd997d5..454f9879 100644 --- a/multilingual-entity-alias-guard/README.md +++ b/multilingual-entity-alias-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Knowledge Graph Integration slice for SCIBASE issue #17. It normalizes multilingual scientific mentions before they become graph nodes, entity-page aliases, or recommendation signals. -The guard accepts trusted translated aliases only when numeric confidence evidence is present, preserves original language tags, normalizes language-tag casing and underscore or hyphen regional separators for lookup, falls back from regional language tags to their base language, emits JSON-LD-style entity packets, holds homographs, false friends, same-language alias collisions, and mixed-script Latin-language lookalikes including lowercase Greek or Cyrillic confusables for curator review, suppresses low-confidence or missing-confidence aliases before recommendations are shown, and treats omitted localized names, mentions, or homograph policies as sparse graph evidence instead of crashing corpus review. +The guard accepts trusted translated aliases only when numeric confidence evidence is present, preserves original language tags, normalizes language-tag casing and underscore or hyphen regional separators for lookup, falls back from regional language tags to their base language, emits JSON-LD-style entity packets, holds homographs, false friends, same-language alias collisions, extractor-candidate/alias conflicts, and mixed-script Latin-language lookalikes including lowercase Greek or Cyrillic confusables for curator review, suppresses low-confidence or missing-confidence aliases before recommendations are shown, and treats omitted localized names, mentions, or homograph policies as sparse graph evidence instead of crashing corpus review. ## Run @@ -17,6 +17,7 @@ npm run check - `reports/alias-guard-packet.json` - `reports/sparse-alias-guard-packet.json` +- `reports/candidate-alias-conflict-packet.json` - `reports/alias-guard-report.md` - `reports/summary.svg` - `reports/demo.mp4` diff --git a/multilingual-entity-alias-guard/acceptance-notes.md b/multilingual-entity-alias-guard/acceptance-notes.md index 4771a484..4027bf30 100644 --- a/multilingual-entity-alias-guard/acceptance-notes.md +++ b/multilingual-entity-alias-guard/acceptance-notes.md @@ -14,6 +14,7 @@ Validation coverage: - trusted CRISPR aliases in English, German, and Spanish map to one canonical MeSH entity - Spanish `control` is held as a homograph/false friend instead of silently creating a statistical control-group edge - same-language translated alias collisions are held instead of silently attaching a mention to the wrong canonical entity +- extractor candidate IDs that disagree with multilingual alias lookup are held instead of silently overriding either signal - language-tag case differences do not suppress trusted translated aliases - regional language tags such as `es-MX` use base-language alias and homograph policy while preserving the original tag - underscore regional language tags such as `es_MX` use the same base-language alias and homograph policy while preserving the original tag diff --git a/multilingual-entity-alias-guard/demo.js b/multilingual-entity-alias-guard/demo.js index f3eac042..ae799947 100644 --- a/multilingual-entity-alias-guard/demo.js +++ b/multilingual-entity-alias-guard/demo.js @@ -18,14 +18,31 @@ const sparseResult = evaluateAliasGuard({ } ] }); +const conflictResult = evaluateAliasGuard({ + ...buildSampleCorpus(), + corpusId: 'kg-candidate-alias-conflict-17', + generatedAt: '2026-05-30T12:30:00Z', + mentions: [ + { + id: 'mention-diabetes-conflicting-candidate', + documentId: 'paper-17', + text: 'diabetes mellitus', + language: 'es', + confidence: 0.93, + candidateEntityId: 'entity:stat:control-group' + } + ] +}); const packetPath = path.join(reportsDir, 'alias-guard-packet.json'); const sparsePacketPath = path.join(reportsDir, 'sparse-alias-guard-packet.json'); +const conflictPacketPath = path.join(reportsDir, 'candidate-alias-conflict-packet.json'); const reportPath = path.join(reportsDir, 'alias-guard-report.md'); const svgPath = path.join(reportsDir, 'summary.svg'); fs.writeFileSync(packetPath, `${JSON.stringify(result, null, 2)}\n`); fs.writeFileSync(sparsePacketPath, `${JSON.stringify(sparseResult, null, 2)}\n`); +fs.writeFileSync(conflictPacketPath, `${JSON.stringify(conflictResult, null, 2)}\n`); const accepted = result.mentionDecisions .filter((decision) => decision.decision === 'accept-canonical-entity') @@ -65,6 +82,10 @@ Held or suppressed mentions are not allowed to drive entity-page recommendations Sparse ontology or corpus exports that omit localized names, mention lists, or homograph policy still produce deterministic graph review evidence. The sparse fixture emitted ${sparseResult.summary.entityPackets} entity packet and ${sparseResult.mentionDecisions.length} mention decisions. +## Candidate Alias Conflict Guard + +Extractor candidates that disagree with trusted multilingual alias lookup are held for curator review instead of silently overriding the upstream candidate. The conflict fixture decision is ${conflictResult.mentionDecisions[0].decision} with reason ${conflictResult.mentionDecisions[0].reason}. + ## Safety All fixtures are synthetic. The module does not call live ontologies, identity providers, external APIs, private corpora, search indexes, or recommendation systems. @@ -90,6 +111,7 @@ fs.writeFileSync(svgPath, svg); console.log(`Wrote ${path.relative(__dirname, packetPath)}`); console.log(`Wrote ${path.relative(__dirname, sparsePacketPath)}`); +console.log(`Wrote ${path.relative(__dirname, conflictPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, reportPath)}`); console.log(`Wrote ${path.relative(__dirname, svgPath)}`); console.log(`Accepted mentions: ${result.summary.acceptedMentions}`); diff --git a/multilingual-entity-alias-guard/index.js b/multilingual-entity-alias-guard/index.js index 15929297..3131a93a 100644 --- a/multilingual-entity-alias-guard/index.js +++ b/multilingual-entity-alias-guard/index.js @@ -199,6 +199,21 @@ function mentionDecision(mention, aliasIndex, homographs) { }; } + if (alias && mention.candidateEntityId && mention.candidateEntityId !== alias.entity.id) { + return { + id: mention.id, + language: mention.language, + text: mention.text, + documentId: mention.documentId, + decision: 'hold-for-curator-review', + reason: 'candidate-alias-conflict', + candidateEntityId: null, + candidateEntityIds: [alias.entity.id, mention.candidateEntityId].sort(), + confidence: mention.confidence, + preservedLanguageTag: mention.language + }; + } + if (!alias || confidence === null || confidence < 0.8) { return { id: mention.id, @@ -239,6 +254,8 @@ function curatorActionForDecision(decision) { action: decision.reason === 'alias-collision' ? 'review-multilingual-alias-collision' + : decision.reason === 'candidate-alias-conflict' + ? 'review-multilingual-candidate-alias-conflict' : decision.reason === 'script-confusable-alias' ? 'review-multilingual-script-confusable' : decision.reason === 'false-friend-or-homograph' @@ -247,6 +264,7 @@ function curatorActionForDecision(decision) { priority: decision.reason === 'false-friend-or-homograph' || decision.reason === 'alias-collision' || + decision.reason === 'candidate-alias-conflict' || decision.reason === 'script-confusable-alias' ? 'high' : 'normal', diff --git a/multilingual-entity-alias-guard/reports/alias-guard-report.md b/multilingual-entity-alias-guard/reports/alias-guard-report.md index 3fb5fa60..06d2ae58 100644 --- a/multilingual-entity-alias-guard/reports/alias-guard-report.md +++ b/multilingual-entity-alias-guard/reports/alias-guard-report.md @@ -35,6 +35,10 @@ Held or suppressed mentions are not allowed to drive entity-page recommendations Sparse ontology or corpus exports that omit localized names, mention lists, or homograph policy still produce deterministic graph review evidence. The sparse fixture emitted 1 entity packet and 0 mention decisions. +## Candidate Alias Conflict Guard + +Extractor candidates that disagree with trusted multilingual alias lookup are held for curator review instead of silently overriding the upstream candidate. The conflict fixture decision is hold-for-curator-review with reason candidate-alias-conflict. + ## Safety All fixtures are synthetic. The module does not call live ontologies, identity providers, external APIs, private corpora, search indexes, or recommendation systems. diff --git a/multilingual-entity-alias-guard/reports/candidate-alias-conflict-packet.json b/multilingual-entity-alias-guard/reports/candidate-alias-conflict-packet.json new file mode 100644 index 00000000..12187c87 --- /dev/null +++ b/multilingual-entity-alias-guard/reports/candidate-alias-conflict-packet.json @@ -0,0 +1,157 @@ +{ + "corpusId": "kg-candidate-alias-conflict-17", + "generatedAt": "2026-05-30T12:30:00Z", + "mentionDecisions": [ + { + "id": "mention-diabetes-conflicting-candidate", + "language": "es", + "text": "diabetes mellitus", + "documentId": "paper-17", + "decision": "hold-for-curator-review", + "reason": "candidate-alias-conflict", + "candidateEntityId": null, + "candidateEntityIds": [ + "entity:mesh:D003920", + "entity:stat:control-group" + ], + "confidence": 0.93, + "preservedLanguageTag": "es" + } + ], + "entityPackets": [ + { + "id": "entity:mesh:D000077768", + "canonicalName": "CRISPR-Cas9", + "ontology": "MeSH", + "identifier": "D000077768", + "languages": [], + "mentions": [], + "localizedNames": { + "en": [ + "CRISPR-Cas9" + ], + "de": [ + "CRISPR-Cas9 Geneditierung" + ], + "es": [ + "edicion genetica CRISPR-Cas9" + ] + }, + "jsonLd": { + "@context": "https://schema.org", + "@type": "DefinedTerm", + "name": "CRISPR-Cas9", + "identifier": "MeSH:D000077768", + "inDefinedTermSet": "MeSH", + "alternateName": [ + "CRISPR-Cas9", + "CRISPR-Cas9 Geneditierung", + "edicion genetica CRISPR-Cas9" + ] + }, + "schemaOrg": { + "@type": "ScholarlyArticle", + "about": [] + } + }, + { + "id": "entity:mesh:D003920", + "canonicalName": "Diabetes Mellitus", + "ontology": "MeSH", + "identifier": "D003920", + "languages": [], + "mentions": [], + "localizedNames": { + "en": [ + "diabetes mellitus" + ], + "de": [ + "Diabetes mellitus" + ], + "es": [ + "diabetes mellitus" + ] + }, + "jsonLd": { + "@context": "https://schema.org", + "@type": "DefinedTerm", + "name": "Diabetes Mellitus", + "identifier": "MeSH:D003920", + "inDefinedTermSet": "MeSH", + "alternateName": [ + "diabetes mellitus", + "Diabetes mellitus", + "diabetes mellitus" + ] + }, + "schemaOrg": { + "@type": "ScholarlyArticle", + "about": [] + } + }, + { + "id": "entity:stat:control-group", + "canonicalName": "Control Group", + "ontology": "SCIBASE-STAT", + "identifier": "control-group", + "languages": [], + "mentions": [], + "localizedNames": { + "en": [ + "control group" + ], + "es": [ + "grupo control" + ], + "de": [ + "Kontrollgruppe" + ] + }, + "jsonLd": { + "@context": "https://schema.org", + "@type": "DefinedTerm", + "name": "Control Group", + "identifier": "SCIBASE-STAT:control-group", + "inDefinedTermSet": "SCIBASE-STAT", + "alternateName": [ + "control group", + "grupo control", + "Kontrollgruppe" + ] + }, + "schemaOrg": { + "@type": "ScholarlyArticle", + "about": [] + } + } + ], + "curatorActions": [ + { + "id": "curate-mention-diabetes-conflicting-candidate", + "mentionId": "mention-diabetes-conflicting-candidate", + "action": "review-multilingual-candidate-alias-conflict", + "priority": "high", + "language": "es", + "text": "diabetes mellitus", + "candidateEntityId": null, + "candidateEntityIds": [ + "entity:mesh:D003920", + "entity:stat:control-group" + ], + "reason": "candidate-alias-conflict" + } + ], + "recommendationGuards": { + "suppressedMentionIds": [ + "mention-diabetes-conflicting-candidate" + ], + "safeEntityIds": [] + }, + "summary": { + "acceptedMentions": 0, + "heldMentions": 1, + "suppressedMentions": 0, + "entityPackets": 3 + }, + "auditDigest": "sha256:0442b163495dcce491f9967b67d2aa614a9cbedbcf160ad1c978a18e8b5a46ed" +} diff --git a/multilingual-entity-alias-guard/requirements-map.md b/multilingual-entity-alias-guard/requirements-map.md index e217e2b6..2432a33e 100644 --- a/multilingual-entity-alias-guard/requirements-map.md +++ b/multilingual-entity-alias-guard/requirements-map.md @@ -7,6 +7,7 @@ - Normalizes language-tag casing plus hyphenated or underscored regional subtags for alias lookup while preserving the original tag on decisions. - Holds false friends and homographs before creating graph edges. - Holds same-language alias collisions when ontology entries reuse the same translated term. +- Holds extractor-candidate and multilingual-alias conflicts before creating graph edges or recommendation inputs. - Holds Latin-language mentions with Cyrillic or Greek lookalike characters, including lowercase Greek confusables, for curator review before creating graph edges. - Treats omitted localized-name maps, mention lists, and homograph policies as sparse graph evidence instead of crashing corpus review. - Emits schema.org-style `DefinedTerm` JSON-LD packets for entity pages. diff --git a/multilingual-entity-alias-guard/test.js b/multilingual-entity-alias-guard/test.js index b06bbe6d..c7f5f817 100644 --- a/multilingual-entity-alias-guard/test.js +++ b/multilingual-entity-alias-guard/test.js @@ -62,6 +62,36 @@ function testSameLanguageAliasCollisionsAreHeldForCuratorReview() { assert.equal(action.action, 'review-multilingual-alias-collision'); } +function testConflictingExtractorCandidateAndAliasLookupAreHeld() { + const corpus = JSON.parse(JSON.stringify(buildSampleCorpus())); + corpus.mentions = [ + { + id: 'mention-diabetes-conflicting-candidate', + documentId: 'paper-17', + text: 'diabetes mellitus', + language: 'es', + confidence: 0.93, + candidateEntityId: 'entity:stat:control-group' + } + ]; + + const result = evaluateAliasGuard(corpus); + const event = byId(result.mentionDecisions, 'mention-diabetes-conflicting-candidate'); + const action = byId(result.curatorActions, 'curate-mention-diabetes-conflicting-candidate'); + const diabetes = byId(result.entityPackets, 'entity:mesh:D003920'); + + assert.equal(event.decision, 'hold-for-curator-review'); + assert.equal(event.reason, 'candidate-alias-conflict'); + assert.deepEqual(event.candidateEntityIds, [ + 'entity:mesh:D003920', + 'entity:stat:control-group' + ]); + assert.equal(action.action, 'review-multilingual-candidate-alias-conflict'); + assert.equal(action.priority, 'high'); + assert.equal(diabetes.mentions.length, 0); + assert.equal(result.recommendationGuards.safeEntityIds.includes('entity:mesh:D003920'), false); +} + function testUnicodeAndWhitespaceAliasesMatchCanonicalEntities() { const corpus = JSON.parse(JSON.stringify(buildSampleCorpus())); corpus.entities.push({ @@ -339,6 +369,7 @@ const tests = [ testTrustedTranslatedAliasesBecomeCanonicalGraphNodes, testFalseFriendMentionsAreHeldForCuratorReview, testSameLanguageAliasCollisionsAreHeldForCuratorReview, + testConflictingExtractorCandidateAndAliasLookupAreHeld, testUnicodeAndWhitespaceAliasesMatchCanonicalEntities, testLanguageTagCaseDoesNotSuppressTrustedAliases, testRegionalLanguageTagsUseBaseAliasLookup, From 58d6051f2159bbd0acc990ec58fa31d87821ab5a Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Sun, 31 May 2026 12:40:36 +0200 Subject: [PATCH 12/13] Harden malformed alias evidence --- multilingual-entity-alias-guard/README.md | 4 +- .../acceptance-notes.md | 2 + multilingual-entity-alias-guard/demo.js | 56 +++++- multilingual-entity-alias-guard/index.js | 70 +++++++- .../make-demo-video.py | 2 +- .../reports/alias-guard-packet.json | 5 +- .../reports/alias-guard-report.md | 10 +- .../candidate-alias-conflict-packet.json | 5 +- .../reports/demo.mp4 | Bin 50395 -> 50413 bytes .../malformed-alias-evidence-packet.json | 87 ++++++++++ .../malformed-mention-text-packet.json | 162 ++++++++++++++++++ .../reports/sparse-alias-guard-packet.json | 3 +- .../reports/summary.svg | 4 +- .../requirements-map.md | 3 +- multilingual-entity-alias-guard/test.js | 67 ++++++++ 15 files changed, 466 insertions(+), 14 deletions(-) create mode 100644 multilingual-entity-alias-guard/reports/malformed-alias-evidence-packet.json create mode 100644 multilingual-entity-alias-guard/reports/malformed-mention-text-packet.json diff --git a/multilingual-entity-alias-guard/README.md b/multilingual-entity-alias-guard/README.md index 454f9879..705c4302 100644 --- a/multilingual-entity-alias-guard/README.md +++ b/multilingual-entity-alias-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Knowledge Graph Integration slice for SCIBASE issue #17. It normalizes multilingual scientific mentions before they become graph nodes, entity-page aliases, or recommendation signals. -The guard accepts trusted translated aliases only when numeric confidence evidence is present, preserves original language tags, normalizes language-tag casing and underscore or hyphen regional separators for lookup, falls back from regional language tags to their base language, emits JSON-LD-style entity packets, holds homographs, false friends, same-language alias collisions, extractor-candidate/alias conflicts, and mixed-script Latin-language lookalikes including lowercase Greek or Cyrillic confusables for curator review, suppresses low-confidence or missing-confidence aliases before recommendations are shown, and treats omitted localized names, mentions, or homograph policies as sparse graph evidence instead of crashing corpus review. +The guard accepts trusted translated aliases only when numeric confidence evidence is present, preserves original language tags, normalizes language-tag casing and underscore or hyphen regional separators for lookup, falls back from regional language tags to their base language, emits JSON-LD-style entity packets, holds homographs, false friends, same-language alias collisions, extractor-candidate/alias conflicts, malformed mention text, and mixed-script Latin-language lookalikes including lowercase Greek or Cyrillic confusables for curator review, suppresses low-confidence or missing-confidence aliases before recommendations are shown, and treats omitted or malformed localized names, mentions, or homograph policies as sparse graph evidence instead of crashing corpus review. ## Run @@ -18,6 +18,8 @@ npm run check - `reports/alias-guard-packet.json` - `reports/sparse-alias-guard-packet.json` - `reports/candidate-alias-conflict-packet.json` +- `reports/malformed-mention-text-packet.json` +- `reports/malformed-alias-evidence-packet.json` - `reports/alias-guard-report.md` - `reports/summary.svg` - `reports/demo.mp4` diff --git a/multilingual-entity-alias-guard/acceptance-notes.md b/multilingual-entity-alias-guard/acceptance-notes.md index 4027bf30..5a825aac 100644 --- a/multilingual-entity-alias-guard/acceptance-notes.md +++ b/multilingual-entity-alias-guard/acceptance-notes.md @@ -22,5 +22,7 @@ Validation coverage: - low-confidence French alias output is suppressed from recommendations - missing or non-numeric confidence evidence is suppressed before graph recommendations - sparse ontology/corpus exports with omitted localized names, mention lists, or homograph policies do not crash corpus review +- malformed localized-name entries are omitted from alias lookup and JSON-LD alternate names, with alias evidence issues preserved for review +- malformed mention text values are held for curator review instead of crashing alias normalization or reaching recommendation-safe IDs - localized names remain language-tagged on entity packets - audit output is deterministic and private-data free diff --git a/multilingual-entity-alias-guard/demo.js b/multilingual-entity-alias-guard/demo.js index ae799947..e479303d 100644 --- a/multilingual-entity-alias-guard/demo.js +++ b/multilingual-entity-alias-guard/demo.js @@ -33,16 +33,60 @@ const conflictResult = evaluateAliasGuard({ } ] }); +const malformedMentionResult = evaluateAliasGuard({ + ...buildSampleCorpus(), + corpusId: 'kg-malformed-mention-text-17', + generatedAt: '2026-05-31T10:45:00Z', + mentions: [ + { + id: 'mention-malformed-text', + documentId: 'paper-18', + text: { value: 'diabetes mellitus' }, + language: 'es', + confidence: 0.94, + candidateEntityId: 'entity:mesh:D003920' + } + ] +}); +const malformedAliasEvidenceResult = evaluateAliasGuard({ + ...buildSampleCorpus(), + corpusId: 'kg-malformed-localized-name-17', + generatedAt: '2026-05-31T10:46:00Z', + entities: [ + { + id: 'entity:mesh:D003920', + canonicalName: 'Diabetes Mellitus', + ontology: 'MeSH', + identifier: 'D003920', + localizedNames: { + es: ['diabetes mellitus', { value: 'diabete mellitus' }] + } + } + ], + mentions: [ + { + id: 'mention-diabetes-es', + documentId: 'paper-19', + text: 'diabetes mellitus', + language: 'es', + confidence: 0.94 + } + ] +}); const packetPath = path.join(reportsDir, 'alias-guard-packet.json'); const sparsePacketPath = path.join(reportsDir, 'sparse-alias-guard-packet.json'); const conflictPacketPath = path.join(reportsDir, 'candidate-alias-conflict-packet.json'); +const malformedMentionPacketPath = path.join(reportsDir, 'malformed-mention-text-packet.json'); +const malformedAliasEvidencePacketPath = path.join(reportsDir, 'malformed-alias-evidence-packet.json'); const reportPath = path.join(reportsDir, 'alias-guard-report.md'); const svgPath = path.join(reportsDir, 'summary.svg'); fs.writeFileSync(packetPath, `${JSON.stringify(result, null, 2)}\n`); fs.writeFileSync(sparsePacketPath, `${JSON.stringify(sparseResult, null, 2)}\n`); fs.writeFileSync(conflictPacketPath, `${JSON.stringify(conflictResult, null, 2)}\n`); +fs.writeFileSync(malformedMentionPacketPath, `${JSON.stringify(malformedMentionResult, null, 2)}\n`); +fs.writeFileSync(malformedAliasEvidencePacketPath, `${JSON.stringify(malformedAliasEvidenceResult, null, 2)}\n`); const accepted = result.mentionDecisions .filter((decision) => decision.decision === 'accept-canonical-entity') @@ -86,6 +130,14 @@ Sparse ontology or corpus exports that omit localized names, mention lists, or h Extractor candidates that disagree with trusted multilingual alias lookup are held for curator review instead of silently overriding the upstream candidate. The conflict fixture decision is ${conflictResult.mentionDecisions[0].decision} with reason ${conflictResult.mentionDecisions[0].reason}. +## Malformed Mention Text Guard + +Malformed mention text values are held for curator review instead of crashing alias normalization. The malformed fixture decision is ${malformedMentionResult.mentionDecisions[0].decision} with reason ${malformedMentionResult.mentionDecisions[0].reason}, and it emits ${malformedMentionResult.curatorActions[0].action}. + +## Malformed Alias Evidence Guard + +Malformed localized-name evidence is omitted from alias lookup and JSON-LD alternate names instead of crashing ontology review. The malformed alias fixture records ${malformedAliasEvidenceResult.entityPackets[0].aliasEvidenceIssues.length} alias evidence issue with reason ${malformedAliasEvidenceResult.entityPackets[0].aliasEvidenceIssues[0].reason}. + ## Safety All fixtures are synthetic. The module does not call live ontologies, identity providers, external APIs, private corpora, search indexes, or recommendation systems. @@ -102,7 +154,7 @@ const svg = `Suppressed low-confidence mentions: ${result.summary.suppressedMentions} Languages preserved: en, de, es, fr JSON-LD entity packets ready for schema.org-style pages - Unsafe aliases are held before graph recommendations are shown. + Unsafe or malformed aliases are held before recommendations are shown. ${result.auditDigest} `; @@ -112,6 +164,8 @@ fs.writeFileSync(svgPath, svg); console.log(`Wrote ${path.relative(__dirname, packetPath)}`); console.log(`Wrote ${path.relative(__dirname, sparsePacketPath)}`); console.log(`Wrote ${path.relative(__dirname, conflictPacketPath)}`); +console.log(`Wrote ${path.relative(__dirname, malformedMentionPacketPath)}`); +console.log(`Wrote ${path.relative(__dirname, malformedAliasEvidencePacketPath)}`); console.log(`Wrote ${path.relative(__dirname, reportPath)}`); console.log(`Wrote ${path.relative(__dirname, svgPath)}`); console.log(`Accepted mentions: ${result.summary.acceptedMentions}`); diff --git a/multilingual-entity-alias-guard/index.js b/multilingual-entity-alias-guard/index.js index 3131a93a..b5646fab 100644 --- a/multilingual-entity-alias-guard/index.js +++ b/multilingual-entity-alias-guard/index.js @@ -23,10 +23,47 @@ function evidenceList(value) { return Array.isArray(value) ? value : []; } +function valueType(value) { + return value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value; +} + function localizedNamesFor(entity) { - return entity.localizedNames && typeof entity.localizedNames === 'object' - ? entity.localizedNames - : {}; + const localizedNames = + entity.localizedNames && typeof entity.localizedNames === 'object' ? entity.localizedNames : {}; + return Object.fromEntries( + Object.entries(localizedNames) + .map(([language, terms]) => [language, evidenceList(terms).filter(isTextValue)]) + .filter(([, terms]) => terms.length > 0) + ); +} + +function localizedNameIssuesFor(entity) { + const localizedNames = + entity.localizedNames && typeof entity.localizedNames === 'object' ? entity.localizedNames : {}; + const issues = []; + + for (const [language, terms] of Object.entries(localizedNames)) { + if (!Array.isArray(terms)) { + issues.push({ + language, + reason: 'malformed-localized-name-list', + valueType: valueType(terms) + }); + continue; + } + + for (const term of terms) { + if (!isTextValue(term)) { + issues.push({ + language, + reason: 'malformed-localized-name', + valueType: valueType(term) + }); + } + } + } + + return issues; } function evidenceObject(value) { @@ -66,6 +103,10 @@ function normalizeTerm(term) { return term.normalize('NFKC').trim().replace(/\s+/g, ' ').toLocaleLowerCase(); } +function isTextValue(value) { + return typeof value === 'string'; +} + function normalizeLanguageTag(language) { return String(language || '').normalize('NFKC').trim().replace(/_/g, '-').toLocaleLowerCase(); } @@ -143,6 +184,22 @@ function buildAliasIndex(entities) { function mentionDecision(mention, aliasIndex, homographs) { const languageKeys = languageLookupKeys(mention.language); + if (!isTextValue(mention.text)) { + const candidateEntityId = mention.candidateEntityId || null; + return { + id: mention.id, + language: mention.language, + text: mention.text, + documentId: mention.documentId, + decision: 'hold-for-curator-review', + reason: 'malformed-mention-text', + candidateEntityId, + candidateEntityIds: candidateEntityId ? [candidateEntityId] : [], + confidence: mention.confidence, + preservedLanguageTag: mention.language + }; + } + const termKey = normalizeTerm(mention.text); const aliasEntry = languageKeys .map((languageKey) => aliasIndex.get(`${languageKey}:${termKey}`)) @@ -258,6 +315,8 @@ function curatorActionForDecision(decision) { ? 'review-multilingual-candidate-alias-conflict' : decision.reason === 'script-confusable-alias' ? 'review-multilingual-script-confusable' + : decision.reason === 'malformed-mention-text' + ? 'review-multilingual-malformed-mention' : decision.reason === 'false-friend-or-homograph' ? 'review-multilingual-homograph' : 'verify-translated-alias-before-recommendation', @@ -265,7 +324,8 @@ function curatorActionForDecision(decision) { decision.reason === 'false-friend-or-homograph' || decision.reason === 'alias-collision' || decision.reason === 'candidate-alias-conflict' || - decision.reason === 'script-confusable-alias' + decision.reason === 'script-confusable-alias' || + decision.reason === 'malformed-mention-text' ? 'high' : 'normal', language: decision.language, @@ -279,6 +339,7 @@ function curatorActionForDecision(decision) { function buildEntityPackets(entities, decisions) { return evidenceList(entities).map((entity) => { const localizedNames = localizedNamesFor(entity); + const aliasEvidenceIssues = localizedNameIssuesFor(entity); const accepted = decisions.filter( (decision) => decision.decision === 'accept-canonical-entity' && decision.candidateEntityId === entity.id @@ -291,6 +352,7 @@ function buildEntityPackets(entities, decisions) { ontology: entity.ontology, identifier: entity.identifier, languages, + aliasEvidenceIssues, mentions: accepted.map((decision) => ({ id: decision.id, text: decision.text, diff --git a/multilingual-entity-alias-guard/make-demo-video.py b/multilingual-entity-alias-guard/make-demo-video.py index 1b02492c..b6ffb874 100644 --- a/multilingual-entity-alias-guard/make-demo-video.py +++ b/multilingual-entity-alias-guard/make-demo-video.py @@ -17,7 +17,7 @@ f"drawtext=fontfile='{font}':text='Preserves language tags for entity pages and JSON-LD':x=92:y=266:fontsize=30:fontcolor=0xd8f6df", f"drawtext=fontfile='{font}':text='Holds homographs and false friends for curator review':x=92:y=326:fontsize=30:fontcolor=0xd8f6df", f"drawtext=fontfile='{font}':text='Suppresses weak aliases before recommendations are shown':x=92:y=386:fontsize=30:fontcolor=0xd8f6df", - f"drawtext=fontfile='{font}':text='Handles sparse ontology exports without runtime failures':x=92:y=446:fontsize=30:fontcolor=0xd8f6df", + f"drawtext=fontfile='{font}':text='Handles malformed alias evidence without runtime failures':x=92:y=446:fontsize=30:fontcolor=0xd8f6df", f"drawtext=fontfile='{font}':text='SCIBASE issue #17 multilingual KG integration slice':x=92:y=536:fontsize=28:fontcolor=0xffd37a", ] ) diff --git a/multilingual-entity-alias-guard/reports/alias-guard-packet.json b/multilingual-entity-alias-guard/reports/alias-guard-packet.json index e489d936..44a38ddd 100644 --- a/multilingual-entity-alias-guard/reports/alias-guard-packet.json +++ b/multilingual-entity-alias-guard/reports/alias-guard-packet.json @@ -154,6 +154,7 @@ "en", "es" ], + "aliasEvidenceIssues": [], "mentions": [ { "id": "mention-crispr-en", @@ -234,6 +235,7 @@ "en", "es" ], + "aliasEvidenceIssues": [], "mentions": [ { "id": "mention-diabetes-en", @@ -310,6 +312,7 @@ "ontology": "SCIBASE-STAT", "identifier": "control-group", "languages": [], + "aliasEvidenceIssues": [], "mentions": [], "localizedNames": { "en": [ @@ -412,5 +415,5 @@ "suppressedMentions": 1, "entityPackets": 3 }, - "auditDigest": "sha256:48d59a0c5224f91e46bbcd93174e2ce12a6f0008946fbcea6f7608abd6798778" + "auditDigest": "sha256:f11c08d8634f046b8382a175239964b368830acc24fe0f8c2ff1b92cdd02ef8f" } diff --git a/multilingual-entity-alias-guard/reports/alias-guard-report.md b/multilingual-entity-alias-guard/reports/alias-guard-report.md index 06d2ae58..72b1afd5 100644 --- a/multilingual-entity-alias-guard/reports/alias-guard-report.md +++ b/multilingual-entity-alias-guard/reports/alias-guard-report.md @@ -9,7 +9,7 @@ Generated: 2026-05-28T07:00:00Z - Held curator-review mentions: 3 - Suppressed low-confidence mentions: 1 - Entity packets emitted: 3 -- Audit digest: sha256:48d59a0c5224f91e46bbcd93174e2ce12a6f0008946fbcea6f7608abd6798778 +- Audit digest: sha256:f11c08d8634f046b8382a175239964b368830acc24fe0f8c2ff1b92cdd02ef8f ## Accepted Canonical Mappings @@ -39,6 +39,14 @@ Sparse ontology or corpus exports that omit localized names, mention lists, or h Extractor candidates that disagree with trusted multilingual alias lookup are held for curator review instead of silently overriding the upstream candidate. The conflict fixture decision is hold-for-curator-review with reason candidate-alias-conflict. +## Malformed Mention Text Guard + +Malformed mention text values are held for curator review instead of crashing alias normalization. The malformed fixture decision is hold-for-curator-review with reason malformed-mention-text, and it emits review-multilingual-malformed-mention. + +## Malformed Alias Evidence Guard + +Malformed localized-name evidence is omitted from alias lookup and JSON-LD alternate names instead of crashing ontology review. The malformed alias fixture records 1 alias evidence issue with reason malformed-localized-name. + ## Safety All fixtures are synthetic. The module does not call live ontologies, identity providers, external APIs, private corpora, search indexes, or recommendation systems. diff --git a/multilingual-entity-alias-guard/reports/candidate-alias-conflict-packet.json b/multilingual-entity-alias-guard/reports/candidate-alias-conflict-packet.json index 12187c87..37881f7f 100644 --- a/multilingual-entity-alias-guard/reports/candidate-alias-conflict-packet.json +++ b/multilingual-entity-alias-guard/reports/candidate-alias-conflict-packet.json @@ -25,6 +25,7 @@ "ontology": "MeSH", "identifier": "D000077768", "languages": [], + "aliasEvidenceIssues": [], "mentions": [], "localizedNames": { "en": [ @@ -60,6 +61,7 @@ "ontology": "MeSH", "identifier": "D003920", "languages": [], + "aliasEvidenceIssues": [], "mentions": [], "localizedNames": { "en": [ @@ -95,6 +97,7 @@ "ontology": "SCIBASE-STAT", "identifier": "control-group", "languages": [], + "aliasEvidenceIssues": [], "mentions": [], "localizedNames": { "en": [ @@ -153,5 +156,5 @@ "suppressedMentions": 0, "entityPackets": 3 }, - "auditDigest": "sha256:0442b163495dcce491f9967b67d2aa614a9cbedbcf160ad1c978a18e8b5a46ed" + "auditDigest": "sha256:9c5561ec683bbf63505c6a252c7cae3559dca84172940c59941bfab62e23485e" } diff --git a/multilingual-entity-alias-guard/reports/demo.mp4 b/multilingual-entity-alias-guard/reports/demo.mp4 index 6efc79a6247e7e0c38eaf4b2bea1804ee182dcc8..dde86e7005b2cb5070bf9a1a907c17b63634be3c 100644 GIT binary patch delta 12701 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 delta 12700 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 a6JiGbuZfOY{RE{!v;TJoaYD}^Suppressed low-confidence mentions: 1 Languages preserved: en, de, es, fr JSON-LD entity packets ready for schema.org-style pages - Unsafe aliases are held before graph recommendations are shown. - sha256:48d59a0c5224f91e46bbcd93174e2ce12a6f0008946fbcea6f7608abd6798778 + Unsafe or malformed aliases are held before recommendations are shown. + sha256:f11c08d8634f046b8382a175239964b368830acc24fe0f8c2ff1b92cdd02ef8f diff --git a/multilingual-entity-alias-guard/requirements-map.md b/multilingual-entity-alias-guard/requirements-map.md index 2432a33e..9388bdf0 100644 --- a/multilingual-entity-alias-guard/requirements-map.md +++ b/multilingual-entity-alias-guard/requirements-map.md @@ -8,8 +8,9 @@ - Holds false friends and homographs before creating graph edges. - Holds same-language alias collisions when ontology entries reuse the same translated term. - Holds extractor-candidate and multilingual-alias conflicts before creating graph edges or recommendation inputs. +- Holds malformed mention text values for curator review instead of crashing alias normalization or accepting unsafe graph evidence. - Holds Latin-language mentions with Cyrillic or Greek lookalike characters, including lowercase Greek confusables, for curator review before creating graph edges. -- Treats omitted localized-name maps, mention lists, and homograph policies as sparse graph evidence instead of crashing corpus review. +- Treats omitted localized-name maps, malformed localized-name entries, mention lists, and homograph policies as sparse graph evidence instead of crashing corpus review. - Emits schema.org-style `DefinedTerm` JSON-LD packets for entity pages. ## Knowledge Navigation diff --git a/multilingual-entity-alias-guard/test.js b/multilingual-entity-alias-guard/test.js index c7f5f817..5f2f5cf1 100644 --- a/multilingual-entity-alias-guard/test.js +++ b/multilingual-entity-alias-guard/test.js @@ -306,6 +306,45 @@ function testMissingLocalizedNamesEmitEmptyEntityAliasPacket() { assert.ok(result.auditDigest.startsWith('sha256:')); } +function testMalformedLocalizedNameTermsAreOmittedFromAliasEvidence() { + const corpus = JSON.parse(JSON.stringify(buildSampleCorpus())); + corpus.entities = [ + { + id: 'entity:mesh:D003920', + canonicalName: 'Diabetes Mellitus', + ontology: 'MeSH', + identifier: 'D003920', + localizedNames: { + es: ['diabetes mellitus', { value: 'diabete mellitus' }] + } + } + ]; + corpus.mentions = [ + { + id: 'mention-diabetes-es', + documentId: 'paper-19', + text: 'diabetes mellitus', + language: 'es', + confidence: 0.94 + } + ]; + + const result = evaluateAliasGuard(corpus); + const event = byId(result.mentionDecisions, 'mention-diabetes-es'); + const diabetes = byId(result.entityPackets, 'entity:mesh:D003920'); + + assert.equal(event.decision, 'accept-canonical-entity'); + assert.deepEqual(diabetes.localizedNames, { es: ['diabetes mellitus'] }); + assert.deepEqual(diabetes.jsonLd.alternateName, ['diabetes mellitus']); + assert.deepEqual(diabetes.aliasEvidenceIssues, [ + { + language: 'es', + reason: 'malformed-localized-name', + valueType: 'object' + } + ]); +} + function testMissingMentionListProducesEmptyAliasReview() { const corpus = JSON.parse(JSON.stringify(buildSampleCorpus())); delete corpus.mentions; @@ -343,6 +382,32 @@ function testMissingHomographPolicyDefaultsToEmptyPolicy() { assert.deepEqual(result.curatorActions, []); } +function testMalformedMentionTextIsHeldForCuratorReview() { + const corpus = JSON.parse(JSON.stringify(buildSampleCorpus())); + corpus.mentions = [ + { + id: 'mention-malformed-text', + documentId: 'paper-18', + text: { value: 'diabetes mellitus' }, + language: 'es', + confidence: 0.94, + candidateEntityId: 'entity:mesh:D003920' + } + ]; + + const result = evaluateAliasGuard(corpus); + const event = byId(result.mentionDecisions, 'mention-malformed-text'); + const action = byId(result.curatorActions, 'curate-mention-malformed-text'); + + assert.equal(event.decision, 'hold-for-curator-review'); + assert.equal(event.reason, 'malformed-mention-text'); + assert.equal(event.candidateEntityId, 'entity:mesh:D003920'); + assert.deepEqual(event.candidateEntityIds, ['entity:mesh:D003920']); + assert.equal(action.priority, 'high'); + assert.equal(action.action, 'review-multilingual-malformed-mention'); + assert.equal(result.recommendationGuards.safeEntityIds.includes('entity:mesh:D003920'), false); +} + function testLanguageTaggedSynonymsArePreservedForEntityPages() { const result = evaluateAliasGuard(buildSampleCorpus()); const diabetes = byId(result.entityPackets, 'entity:mesh:D003920'); @@ -380,8 +445,10 @@ const tests = [ testLowConfidenceAliasesDoNotDriveRecommendations, testMissingConfidenceAliasesDoNotDriveRecommendations, testMissingLocalizedNamesEmitEmptyEntityAliasPacket, + testMalformedLocalizedNameTermsAreOmittedFromAliasEvidence, testMissingMentionListProducesEmptyAliasReview, testMissingHomographPolicyDefaultsToEmptyPolicy, + testMalformedMentionTextIsHeldForCuratorReview, testLanguageTaggedSynonymsArePreservedForEntityPages, testAuditDigestIsDeterministicAndPrivateFree ]; From ad8ee9b6704d1d30cc900129fe37248fc7a19e3b Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Mon, 1 Jun 2026 21:01:42 +0200 Subject: [PATCH 13/13] Harden malformed alias mention entries --- multilingual-entity-alias-guard/README.md | 3 +- .../acceptance-notes.md | 1 + multilingual-entity-alias-guard/demo.js | 16 ++ multilingual-entity-alias-guard/index.js | 28 +++- .../reports/alias-guard-report.md | 4 + .../malformed-mention-entry-packet.json | 155 ++++++++++++++++++ .../requirements-map.md | 1 + multilingual-entity-alias-guard/test.js | 17 ++ 8 files changed, 219 insertions(+), 6 deletions(-) create mode 100644 multilingual-entity-alias-guard/reports/malformed-mention-entry-packet.json diff --git a/multilingual-entity-alias-guard/README.md b/multilingual-entity-alias-guard/README.md index 705c4302..284b40da 100644 --- a/multilingual-entity-alias-guard/README.md +++ b/multilingual-entity-alias-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Scientific Knowledge Graph Integration slice for SCIBASE issue #17. It normalizes multilingual scientific mentions before they become graph nodes, entity-page aliases, or recommendation signals. -The guard accepts trusted translated aliases only when numeric confidence evidence is present, preserves original language tags, normalizes language-tag casing and underscore or hyphen regional separators for lookup, falls back from regional language tags to their base language, emits JSON-LD-style entity packets, holds homographs, false friends, same-language alias collisions, extractor-candidate/alias conflicts, malformed mention text, and mixed-script Latin-language lookalikes including lowercase Greek or Cyrillic confusables for curator review, suppresses low-confidence or missing-confidence aliases before recommendations are shown, and treats omitted or malformed localized names, mentions, or homograph policies as sparse graph evidence instead of crashing corpus review. +The guard accepts trusted translated aliases only when numeric confidence evidence is present, preserves original language tags, normalizes language-tag casing and underscore or hyphen regional separators for lookup, falls back from regional language tags to their base language, emits JSON-LD-style entity packets, holds homographs, false friends, same-language alias collisions, extractor-candidate/alias conflicts, malformed mention entries or mention text, and mixed-script Latin-language lookalikes including lowercase Greek or Cyrillic confusables for curator review, suppresses low-confidence or missing-confidence aliases before recommendations are shown, and treats omitted or malformed localized names, mentions, or homograph policies as sparse graph evidence instead of crashing corpus review. ## Run @@ -19,6 +19,7 @@ npm run check - `reports/sparse-alias-guard-packet.json` - `reports/candidate-alias-conflict-packet.json` - `reports/malformed-mention-text-packet.json` +- `reports/malformed-mention-entry-packet.json` - `reports/malformed-alias-evidence-packet.json` - `reports/alias-guard-report.md` - `reports/summary.svg` diff --git a/multilingual-entity-alias-guard/acceptance-notes.md b/multilingual-entity-alias-guard/acceptance-notes.md index 5a825aac..0a601bae 100644 --- a/multilingual-entity-alias-guard/acceptance-notes.md +++ b/multilingual-entity-alias-guard/acceptance-notes.md @@ -24,5 +24,6 @@ Validation coverage: - sparse ontology/corpus exports with omitted localized names, mention lists, or homograph policies do not crash corpus review - malformed localized-name entries are omitted from alias lookup and JSON-LD alternate names, with alias evidence issues preserved for review - malformed mention text values are held for curator review instead of crashing alias normalization or reaching recommendation-safe IDs +- malformed mention rows such as null entries are held for curator review instead of crashing before graph packets are produced - localized names remain language-tagged on entity packets - audit output is deterministic and private-data free diff --git a/multilingual-entity-alias-guard/demo.js b/multilingual-entity-alias-guard/demo.js index e479303d..4e9d6f44 100644 --- a/multilingual-entity-alias-guard/demo.js +++ b/multilingual-entity-alias-guard/demo.js @@ -48,6 +48,12 @@ const malformedMentionResult = evaluateAliasGuard({ } ] }); +const malformedMentionEntryResult = evaluateAliasGuard({ + ...buildSampleCorpus(), + corpusId: 'kg-malformed-mention-entry-17', + generatedAt: '2026-06-01T18:55:00Z', + mentions: [null] +}); const malformedAliasEvidenceResult = evaluateAliasGuard({ ...buildSampleCorpus(), corpusId: 'kg-malformed-localized-name-17', @@ -78,6 +84,7 @@ const packetPath = path.join(reportsDir, 'alias-guard-packet.json'); const sparsePacketPath = path.join(reportsDir, 'sparse-alias-guard-packet.json'); const conflictPacketPath = path.join(reportsDir, 'candidate-alias-conflict-packet.json'); const malformedMentionPacketPath = path.join(reportsDir, 'malformed-mention-text-packet.json'); +const malformedMentionEntryPacketPath = path.join(reportsDir, 'malformed-mention-entry-packet.json'); const malformedAliasEvidencePacketPath = path.join(reportsDir, 'malformed-alias-evidence-packet.json'); const reportPath = path.join(reportsDir, 'alias-guard-report.md'); const svgPath = path.join(reportsDir, 'summary.svg'); @@ -86,6 +93,10 @@ fs.writeFileSync(packetPath, `${JSON.stringify(result, null, 2)}\n`); fs.writeFileSync(sparsePacketPath, `${JSON.stringify(sparseResult, null, 2)}\n`); fs.writeFileSync(conflictPacketPath, `${JSON.stringify(conflictResult, null, 2)}\n`); fs.writeFileSync(malformedMentionPacketPath, `${JSON.stringify(malformedMentionResult, null, 2)}\n`); +fs.writeFileSync( + malformedMentionEntryPacketPath, + `${JSON.stringify(malformedMentionEntryResult, null, 2)}\n` +); fs.writeFileSync(malformedAliasEvidencePacketPath, `${JSON.stringify(malformedAliasEvidenceResult, null, 2)}\n`); const accepted = result.mentionDecisions @@ -134,6 +145,10 @@ Extractor candidates that disagree with trusted multilingual alias lookup are he Malformed mention text values are held for curator review instead of crashing alias normalization. The malformed fixture decision is ${malformedMentionResult.mentionDecisions[0].decision} with reason ${malformedMentionResult.mentionDecisions[0].reason}, and it emits ${malformedMentionResult.curatorActions[0].action}. +## Malformed Mention Entry Guard + +Malformed mention rows such as null entries are held for curator review instead of crashing before graph packets are produced. The malformed entry fixture decision is ${malformedMentionEntryResult.mentionDecisions[0].decision} with reason ${malformedMentionEntryResult.mentionDecisions[0].reason}, and it emits ${malformedMentionEntryResult.curatorActions[0].action}. + ## Malformed Alias Evidence Guard Malformed localized-name evidence is omitted from alias lookup and JSON-LD alternate names instead of crashing ontology review. The malformed alias fixture records ${malformedAliasEvidenceResult.entityPackets[0].aliasEvidenceIssues.length} alias evidence issue with reason ${malformedAliasEvidenceResult.entityPackets[0].aliasEvidenceIssues[0].reason}. @@ -165,6 +180,7 @@ console.log(`Wrote ${path.relative(__dirname, packetPath)}`); console.log(`Wrote ${path.relative(__dirname, sparsePacketPath)}`); console.log(`Wrote ${path.relative(__dirname, conflictPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, malformedMentionPacketPath)}`); +console.log(`Wrote ${path.relative(__dirname, malformedMentionEntryPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, malformedAliasEvidencePacketPath)}`); console.log(`Wrote ${path.relative(__dirname, reportPath)}`); console.log(`Wrote ${path.relative(__dirname, svgPath)}`); diff --git a/multilingual-entity-alias-guard/index.js b/multilingual-entity-alias-guard/index.js index b5646fab..06a2f078 100644 --- a/multilingual-entity-alias-guard/index.js +++ b/multilingual-entity-alias-guard/index.js @@ -182,7 +182,23 @@ function buildAliasIndex(entities) { return index; } -function mentionDecision(mention, aliasIndex, homographs) { +function mentionDecision(mention, aliasIndex, homographs, index) { + if (!mention || typeof mention !== 'object' || Array.isArray(mention)) { + return { + id: `malformed-mention-entry-${index + 1}`, + language: null, + text: null, + documentId: null, + decision: 'hold-for-curator-review', + reason: 'malformed-mention-entry', + valueType: valueType(mention), + candidateEntityId: null, + candidateEntityIds: [], + confidence: null, + preservedLanguageTag: null + }; + } + const languageKeys = languageLookupKeys(mention.language); if (!isTextValue(mention.text)) { const candidateEntityId = mention.candidateEntityId || null; @@ -315,7 +331,8 @@ function curatorActionForDecision(decision) { ? 'review-multilingual-candidate-alias-conflict' : decision.reason === 'script-confusable-alias' ? 'review-multilingual-script-confusable' - : decision.reason === 'malformed-mention-text' + : decision.reason === 'malformed-mention-text' || + decision.reason === 'malformed-mention-entry' ? 'review-multilingual-malformed-mention' : decision.reason === 'false-friend-or-homograph' ? 'review-multilingual-homograph' @@ -325,7 +342,8 @@ function curatorActionForDecision(decision) { decision.reason === 'alias-collision' || decision.reason === 'candidate-alias-conflict' || decision.reason === 'script-confusable-alias' || - decision.reason === 'malformed-mention-text' + decision.reason === 'malformed-mention-text' || + decision.reason === 'malformed-mention-entry' ? 'high' : 'normal', language: decision.language, @@ -384,8 +402,8 @@ function buildEntityPackets(entities, decisions) { function evaluateAliasGuard(corpus) { const aliasIndex = buildAliasIndex(corpus.entities); - const mentionDecisions = evidenceList(corpus.mentions).map((mention) => - mentionDecision(mention, aliasIndex, evidenceObject(corpus.homographs)) + const mentionDecisions = evidenceList(corpus.mentions).map((mention, index) => + mentionDecision(mention, aliasIndex, evidenceObject(corpus.homographs), index) ); const curatorActions = mentionDecisions.map(curatorActionForDecision).filter(Boolean); const entityPackets = buildEntityPackets(corpus.entities, mentionDecisions); diff --git a/multilingual-entity-alias-guard/reports/alias-guard-report.md b/multilingual-entity-alias-guard/reports/alias-guard-report.md index 72b1afd5..ec8cadb3 100644 --- a/multilingual-entity-alias-guard/reports/alias-guard-report.md +++ b/multilingual-entity-alias-guard/reports/alias-guard-report.md @@ -43,6 +43,10 @@ Extractor candidates that disagree with trusted multilingual alias lookup are he Malformed mention text values are held for curator review instead of crashing alias normalization. The malformed fixture decision is hold-for-curator-review with reason malformed-mention-text, and it emits review-multilingual-malformed-mention. +## Malformed Mention Entry Guard + +Malformed mention rows such as null entries are held for curator review instead of crashing before graph packets are produced. The malformed entry fixture decision is hold-for-curator-review with reason malformed-mention-entry, and it emits review-multilingual-malformed-mention. + ## Malformed Alias Evidence Guard Malformed localized-name evidence is omitted from alias lookup and JSON-LD alternate names instead of crashing ontology review. The malformed alias fixture records 1 alias evidence issue with reason malformed-localized-name. diff --git a/multilingual-entity-alias-guard/reports/malformed-mention-entry-packet.json b/multilingual-entity-alias-guard/reports/malformed-mention-entry-packet.json new file mode 100644 index 00000000..bc0859dd --- /dev/null +++ b/multilingual-entity-alias-guard/reports/malformed-mention-entry-packet.json @@ -0,0 +1,155 @@ +{ + "corpusId": "kg-malformed-mention-entry-17", + "generatedAt": "2026-06-01T18:55:00Z", + "mentionDecisions": [ + { + "id": "malformed-mention-entry-1", + "language": null, + "text": null, + "documentId": null, + "decision": "hold-for-curator-review", + "reason": "malformed-mention-entry", + "valueType": "null", + "candidateEntityId": null, + "candidateEntityIds": [], + "confidence": null, + "preservedLanguageTag": null + } + ], + "entityPackets": [ + { + "id": "entity:mesh:D000077768", + "canonicalName": "CRISPR-Cas9", + "ontology": "MeSH", + "identifier": "D000077768", + "languages": [], + "aliasEvidenceIssues": [], + "mentions": [], + "localizedNames": { + "en": [ + "CRISPR-Cas9" + ], + "de": [ + "CRISPR-Cas9 Geneditierung" + ], + "es": [ + "edicion genetica CRISPR-Cas9" + ] + }, + "jsonLd": { + "@context": "https://schema.org", + "@type": "DefinedTerm", + "name": "CRISPR-Cas9", + "identifier": "MeSH:D000077768", + "inDefinedTermSet": "MeSH", + "alternateName": [ + "CRISPR-Cas9", + "CRISPR-Cas9 Geneditierung", + "edicion genetica CRISPR-Cas9" + ] + }, + "schemaOrg": { + "@type": "ScholarlyArticle", + "about": [] + } + }, + { + "id": "entity:mesh:D003920", + "canonicalName": "Diabetes Mellitus", + "ontology": "MeSH", + "identifier": "D003920", + "languages": [], + "aliasEvidenceIssues": [], + "mentions": [], + "localizedNames": { + "en": [ + "diabetes mellitus" + ], + "de": [ + "Diabetes mellitus" + ], + "es": [ + "diabetes mellitus" + ] + }, + "jsonLd": { + "@context": "https://schema.org", + "@type": "DefinedTerm", + "name": "Diabetes Mellitus", + "identifier": "MeSH:D003920", + "inDefinedTermSet": "MeSH", + "alternateName": [ + "diabetes mellitus", + "Diabetes mellitus", + "diabetes mellitus" + ] + }, + "schemaOrg": { + "@type": "ScholarlyArticle", + "about": [] + } + }, + { + "id": "entity:stat:control-group", + "canonicalName": "Control Group", + "ontology": "SCIBASE-STAT", + "identifier": "control-group", + "languages": [], + "aliasEvidenceIssues": [], + "mentions": [], + "localizedNames": { + "en": [ + "control group" + ], + "es": [ + "grupo control" + ], + "de": [ + "Kontrollgruppe" + ] + }, + "jsonLd": { + "@context": "https://schema.org", + "@type": "DefinedTerm", + "name": "Control Group", + "identifier": "SCIBASE-STAT:control-group", + "inDefinedTermSet": "SCIBASE-STAT", + "alternateName": [ + "control group", + "grupo control", + "Kontrollgruppe" + ] + }, + "schemaOrg": { + "@type": "ScholarlyArticle", + "about": [] + } + } + ], + "curatorActions": [ + { + "id": "curate-malformed-mention-entry-1", + "mentionId": "malformed-mention-entry-1", + "action": "review-multilingual-malformed-mention", + "priority": "high", + "language": null, + "text": null, + "candidateEntityId": null, + "candidateEntityIds": [], + "reason": "malformed-mention-entry" + } + ], + "recommendationGuards": { + "suppressedMentionIds": [ + "malformed-mention-entry-1" + ], + "safeEntityIds": [] + }, + "summary": { + "acceptedMentions": 0, + "heldMentions": 1, + "suppressedMentions": 0, + "entityPackets": 3 + }, + "auditDigest": "sha256:db1ae56b44d2eff6ab408d4ff715a8f2fd1666b6e7077fa8e35113b923317a9f" +} diff --git a/multilingual-entity-alias-guard/requirements-map.md b/multilingual-entity-alias-guard/requirements-map.md index 9388bdf0..56f47783 100644 --- a/multilingual-entity-alias-guard/requirements-map.md +++ b/multilingual-entity-alias-guard/requirements-map.md @@ -9,6 +9,7 @@ - Holds same-language alias collisions when ontology entries reuse the same translated term. - Holds extractor-candidate and multilingual-alias conflicts before creating graph edges or recommendation inputs. - Holds malformed mention text values for curator review instead of crashing alias normalization or accepting unsafe graph evidence. +- Holds malformed mention rows for curator review instead of crashing before graph packets are produced. - Holds Latin-language mentions with Cyrillic or Greek lookalike characters, including lowercase Greek confusables, for curator review before creating graph edges. - Treats omitted localized-name maps, malformed localized-name entries, mention lists, and homograph policies as sparse graph evidence instead of crashing corpus review. - Emits schema.org-style `DefinedTerm` JSON-LD packets for entity pages. diff --git a/multilingual-entity-alias-guard/test.js b/multilingual-entity-alias-guard/test.js index 5f2f5cf1..75bd4f82 100644 --- a/multilingual-entity-alias-guard/test.js +++ b/multilingual-entity-alias-guard/test.js @@ -408,6 +408,22 @@ function testMalformedMentionTextIsHeldForCuratorReview() { assert.equal(result.recommendationGuards.safeEntityIds.includes('entity:mesh:D003920'), false); } +function testMalformedMentionEntriesAreHeldForCuratorReview() { + const corpus = JSON.parse(JSON.stringify(buildSampleCorpus())); + corpus.mentions = [null]; + + const result = evaluateAliasGuard(corpus); + const event = byId(result.mentionDecisions, 'malformed-mention-entry-1'); + const action = byId(result.curatorActions, 'curate-malformed-mention-entry-1'); + + assert.equal(event.decision, 'hold-for-curator-review'); + assert.equal(event.reason, 'malformed-mention-entry'); + assert.equal(event.valueType, 'null'); + assert.equal(action.priority, 'high'); + assert.equal(action.action, 'review-multilingual-malformed-mention'); + assert.equal(result.recommendationGuards.safeEntityIds.includes('entity:mesh:D003920'), false); +} + function testLanguageTaggedSynonymsArePreservedForEntityPages() { const result = evaluateAliasGuard(buildSampleCorpus()); const diabetes = byId(result.entityPackets, 'entity:mesh:D003920'); @@ -449,6 +465,7 @@ const tests = [ testMissingMentionListProducesEmptyAliasReview, testMissingHomographPolicyDefaultsToEmptyPolicy, testMalformedMentionTextIsHeldForCuratorReview, + testMalformedMentionEntriesAreHeldForCuratorReview, testLanguageTaggedSynonymsArePreservedForEntityPages, testAuditDigestIsDeterministicAndPrivateFree ];