From c2e6b97a7b03b0cf1ffa25c2d3c0a09328debf72 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Thu, 28 May 2026 08:30:11 +0200 Subject: [PATCH 01/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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 ];