diff --git a/knowledge-graph-measurement-harmonization-guard/README.md b/knowledge-graph-measurement-harmonization-guard/README.md
new file mode 100644
index 0000000..d492e1e
--- /dev/null
+++ b/knowledge-graph-measurement-harmonization-guard/README.md
@@ -0,0 +1,13 @@
+# Knowledge Graph Measurement Harmonization Guard
+
+This self-contained slice reviews scientific measurement edges before the knowledge graph treats them as comparable.
+It normalizes units, checks biological context, compares statistical endpoints, and blocks low-provenance recommendations.
+
+## Run locally
+
+```bash
+node knowledge-graph-measurement-harmonization-guard/test.js
+node knowledge-graph-measurement-harmonization-guard/demo.js
+```
+
+Demo outputs are written to `knowledge-graph-measurement-harmonization-guard/demo-output/`.
diff --git a/knowledge-graph-measurement-harmonization-guard/acceptance-notes.md b/knowledge-graph-measurement-harmonization-guard/acceptance-notes.md
new file mode 100644
index 0000000..e12f0bd
--- /dev/null
+++ b/knowledge-graph-measurement-harmonization-guard/acceptance-notes.md
@@ -0,0 +1,6 @@
+# Acceptance Notes
+
+- The module is dependency-free and uses synthetic graph data only.
+- Tests cover blocked graph edges, deterministic unit normalization, JSON-LD review output, and a ready graph path.
+- Demo artifacts include JSON, Markdown, SVG, and MP4 output.
+- The slice is distinct from prior #17 submissions around broad extractors, link audit, ontology drift, relationship conflict, author-affiliation disambiguation, artifact lineage, evidence freshness, reproducibility routes, visibility guards, and negative-result replication signals.
diff --git a/knowledge-graph-measurement-harmonization-guard/demo-output/curator-actions.md b/knowledge-graph-measurement-harmonization-guard/demo-output/curator-actions.md
new file mode 100644
index 0000000..5264f7e
--- /dev/null
+++ b/knowledge-graph-measurement-harmonization-guard/demo-output/curator-actions.md
@@ -0,0 +1,11 @@
+# Measurement harmonization curator actions
+
+Graph: kg-cardio-measurement-review
+Status: curation_required
+
+- edge-rat-human-pressure: Split the graph edge or add a cross-context translation model before recommending reuse.
+- edge-low-provenance: Attach DOI, protocol, or dataset provenance before surfacing this recommendation.
+- edge-mean-vs-median: Normalize the statistical endpoint or require curator approval for the comparison.
+- edge-glucose-rfu: Add an ontology-backed conversion or mark the relationship as non-comparable.
+
+Audit digest: 6b18c9efd5c2126643baa9b6c35532fd305fc889aca712635386ff4dcc70a58b
diff --git a/knowledge-graph-measurement-harmonization-guard/demo-output/demo.mp4 b/knowledge-graph-measurement-harmonization-guard/demo-output/demo.mp4
new file mode 100644
index 0000000..a6eccd4
Binary files /dev/null and b/knowledge-graph-measurement-harmonization-guard/demo-output/demo.mp4 differ
diff --git a/knowledge-graph-measurement-harmonization-guard/demo-output/demo.svg b/knowledge-graph-measurement-harmonization-guard/demo-output/demo.svg
new file mode 100644
index 0000000..fbf2570
--- /dev/null
+++ b/knowledge-graph-measurement-harmonization-guard/demo-output/demo.svg
@@ -0,0 +1,12 @@
+
diff --git a/knowledge-graph-measurement-harmonization-guard/demo-output/harmonization-packet.json b/knowledge-graph-measurement-harmonization-guard/demo-output/harmonization-packet.json
new file mode 100644
index 0000000..66ecc78
--- /dev/null
+++ b/knowledge-graph-measurement-harmonization-guard/demo-output/harmonization-packet.json
@@ -0,0 +1,148 @@
+{
+ "packetType": "knowledge-graph-measurement-harmonization-guard",
+ "graphId": "kg-cardio-measurement-review",
+ "overallStatus": "curation_required",
+ "edgeDecisions": [
+ {
+ "edgeId": "edge-rat-pressure-pa",
+ "sourceMeasurementId": "rat-pressure-mmhg",
+ "targetMeasurementId": "rat-pressure-pa",
+ "decision": "allow_comparison",
+ "normalizedSource": {
+ "normalizedValue": 15998.64,
+ "normalizedUnit": "Pa",
+ "conversion": "mmHg_to_Pa"
+ },
+ "normalizedTarget": {
+ "normalizedValue": 15999,
+ "normalizedUnit": "Pa",
+ "conversion": "identity"
+ }
+ },
+ {
+ "edgeId": "edge-rat-human-pressure",
+ "sourceMeasurementId": "rat-pressure-mmhg",
+ "targetMeasurementId": "human-pressure-pa",
+ "decision": "suppress_recommendation",
+ "normalizedSource": {
+ "normalizedValue": 15998.64,
+ "normalizedUnit": "Pa",
+ "conversion": "mmHg_to_Pa"
+ },
+ "normalizedTarget": {
+ "normalizedValue": 15999,
+ "normalizedUnit": "Pa",
+ "conversion": "identity"
+ }
+ },
+ {
+ "edgeId": "edge-glucose-rfu",
+ "sourceMeasurementId": "glucose-mgdl",
+ "targetMeasurementId": "glucose-rfu",
+ "decision": "suppress_recommendation",
+ "normalizedSource": {
+ "normalizedValue": 5.27,
+ "normalizedUnit": "mmol/L",
+ "conversion": "mg_dL_to_mmol_L"
+ },
+ "normalizedTarget": {
+ "normalizedValue": null,
+ "normalizedUnit": null,
+ "conversion": "unsupported"
+ }
+ },
+ {
+ "edgeId": "edge-mean-vs-median",
+ "sourceMeasurementId": "yield-mean",
+ "targetMeasurementId": "yield-median",
+ "decision": "suppress_recommendation",
+ "normalizedSource": {
+ "normalizedValue": 81,
+ "normalizedUnit": "percent",
+ "conversion": "identity"
+ },
+ "normalizedTarget": {
+ "normalizedValue": 79,
+ "normalizedUnit": "percent",
+ "conversion": "identity"
+ }
+ },
+ {
+ "edgeId": "edge-low-provenance",
+ "sourceMeasurementId": "rat-pressure-mmhg",
+ "targetMeasurementId": "rat-pressure-pa",
+ "decision": "suppress_recommendation",
+ "normalizedSource": {
+ "normalizedValue": 15998.64,
+ "normalizedUnit": "Pa",
+ "conversion": "mmHg_to_Pa"
+ },
+ "normalizedTarget": {
+ "normalizedValue": 15999,
+ "normalizedUnit": "Pa",
+ "conversion": "identity"
+ }
+ }
+ ],
+ "findings": [
+ {
+ "edgeId": "edge-rat-human-pressure",
+ "code": "biological_context_mismatch",
+ "severity": "blocker",
+ "evidence": "Context mismatch in species.",
+ "curatorAction": "Split the graph edge or add a cross-context translation model before recommending reuse."
+ },
+ {
+ "edgeId": "edge-low-provenance",
+ "code": "provenance_confidence_low",
+ "severity": "blocker",
+ "evidence": "Evidence confidence 0.42 is below 0.75.",
+ "curatorAction": "Attach DOI, protocol, or dataset provenance before surfacing this recommendation."
+ },
+ {
+ "edgeId": "edge-mean-vs-median",
+ "code": "statistical_endpoint_mismatch",
+ "severity": "blocker",
+ "evidence": "mean cannot be compared directly with median.",
+ "curatorAction": "Normalize the statistical endpoint or require curator approval for the comparison."
+ },
+ {
+ "edgeId": "edge-glucose-rfu",
+ "code": "unit_not_convertible",
+ "severity": "blocker",
+ "evidence": "mg/dL cannot be safely compared with RFU for glucose_concentration.",
+ "curatorAction": "Add an ontology-backed conversion or mark the relationship as non-comparable."
+ }
+ ],
+ "curatorActions": [
+ {
+ "edgeId": "edge-rat-human-pressure",
+ "action": "Split the graph edge or add a cross-context translation model before recommending reuse.",
+ "evidence": "Context mismatch in species."
+ },
+ {
+ "edgeId": "edge-low-provenance",
+ "action": "Attach DOI, protocol, or dataset provenance before surfacing this recommendation.",
+ "evidence": "Evidence confidence 0.42 is below 0.75."
+ },
+ {
+ "edgeId": "edge-mean-vs-median",
+ "action": "Normalize the statistical endpoint or require curator approval for the comparison.",
+ "evidence": "mean cannot be compared directly with median."
+ },
+ {
+ "edgeId": "edge-glucose-rfu",
+ "action": "Add an ontology-backed conversion or mark the relationship as non-comparable.",
+ "evidence": "mg/dL cannot be safely compared with RFU for glucose_concentration."
+ }
+ ],
+ "jsonLd": {
+ "@context": "https://schema.org",
+ "@type": "MeasurementHarmonizationReview",
+ "identifier": "kg-cardio-measurement-review",
+ "reviewStatus": "curation_required",
+ "measurementCount": 7,
+ "edgeCount": 5
+ },
+ "auditDigest": "6b18c9efd5c2126643baa9b6c35532fd305fc889aca712635386ff4dcc70a58b"
+}
diff --git a/knowledge-graph-measurement-harmonization-guard/demo.js b/knowledge-graph-measurement-harmonization-guard/demo.js
new file mode 100644
index 0000000..6c3eb75
--- /dev/null
+++ b/knowledge-graph-measurement-harmonization-guard/demo.js
@@ -0,0 +1,72 @@
+const fs = require('fs');
+const path = require('path');
+const { execFileSync } = require('child_process');
+const { buildGraphHarmonizationPacket } = require('./index');
+const { sampleGraph } = require('./sample-data');
+
+const outputDir = path.join(__dirname, 'demo-output');
+fs.mkdirSync(outputDir, { recursive: true });
+
+const packet = buildGraphHarmonizationPacket(sampleGraph);
+
+fs.writeFileSync(path.join(outputDir, 'harmonization-packet.json'), `${JSON.stringify(packet, null, 2)}\n`);
+
+const rows = packet.findings
+ .map((finding, index) => `${index + 1}. ${finding.edgeId}: ${finding.code}`)
+ .join('\n ');
+
+fs.writeFileSync(path.join(outputDir, 'demo.svg'), `
+`);
+
+fs.writeFileSync(path.join(outputDir, 'curator-actions.md'), [
+ '# Measurement harmonization curator actions',
+ '',
+ `Graph: ${packet.graphId}`,
+ `Status: ${packet.overallStatus}`,
+ '',
+ ...packet.curatorActions.map((item) => `- ${item.edgeId}: ${item.action}`),
+ '',
+ `Audit digest: ${packet.auditDigest}`,
+ '',
+].join('\n'));
+
+function renderMp4() {
+ const videoPath = path.join(outputDir, 'demo.mp4');
+ const font = 'C\\:/Windows/Fonts/arial.ttf';
+ const escapeText = (value) => String(value).replace(/\\/g, '\\\\').replace(/:/g, '\\:').replace(/'/g, "\\'");
+ const filters = [
+ `drawtext=fontfile='${font}':text='${escapeText('KG Measurement Harmonization Guard')}':x=70:y=80:fontsize=42:fontcolor=white`,
+ `drawtext=fontfile='${font}':text='${escapeText(`${packet.findings.length} graph edge blockers found`)}':x=70:y=155:fontsize=32:fontcolor=0xffd166`,
+ ...packet.findings.map((finding, index) =>
+ `drawtext=fontfile='${font}':text='${escapeText(`${finding.edgeId}: ${finding.code}`)}':x=90:y=${235 + index * 58}:fontsize=25:fontcolor=white`,
+ ),
+ `drawtext=fontfile='${font}':text='${escapeText(`audit ${packet.auditDigest.slice(0, 20)}...`)}':x=70:y=630:fontsize=24:fontcolor=0x93c5fd`,
+ ].join(',');
+
+ execFileSync('ffmpeg', [
+ '-y',
+ '-f',
+ 'lavfi',
+ '-i',
+ 'color=c=0x0f172a:s=1280x720:d=7',
+ '-vf',
+ filters,
+ '-c:v',
+ 'libx264',
+ '-pix_fmt',
+ 'yuv420p',
+ videoPath,
+ ], { stdio: 'inherit' });
+}
+
+renderMp4();
+
+console.log(`Wrote demo artifacts to ${outputDir}`);
diff --git a/knowledge-graph-measurement-harmonization-guard/index.js b/knowledge-graph-measurement-harmonization-guard/index.js
new file mode 100644
index 0000000..6763d53
--- /dev/null
+++ b/knowledge-graph-measurement-harmonization-guard/index.js
@@ -0,0 +1,215 @@
+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 crypto.createHash('sha256').update(stableStringify(value)).digest('hex');
+}
+
+function round2(value) {
+ return Math.round(value * 100) / 100;
+}
+
+function normalizeMeasurement(measurement) {
+ if (measurement.endpoint.includes('pressure')) {
+ if (measurement.unit === 'mmHg') {
+ return {
+ normalizedValue: round2(measurement.value * 133.322),
+ normalizedUnit: 'Pa',
+ conversion: 'mmHg_to_Pa',
+ };
+ }
+ if (measurement.unit === 'kPa') {
+ return {
+ normalizedValue: round2(measurement.value * 1000),
+ normalizedUnit: 'Pa',
+ conversion: 'kPa_to_Pa',
+ };
+ }
+ if (measurement.unit === 'Pa') {
+ return {
+ normalizedValue: round2(measurement.value),
+ normalizedUnit: 'Pa',
+ conversion: 'identity',
+ };
+ }
+ }
+
+ if (measurement.endpoint.includes('glucose')) {
+ if (measurement.unit === 'mg/dL') {
+ return {
+ normalizedValue: round2(measurement.value * 0.0555),
+ normalizedUnit: 'mmol/L',
+ conversion: 'mg_dL_to_mmol_L',
+ };
+ }
+ if (measurement.unit === 'mmol/L') {
+ return {
+ normalizedValue: round2(measurement.value),
+ normalizedUnit: 'mmol/L',
+ conversion: 'identity',
+ };
+ }
+ }
+
+ if (measurement.unit === 'percent' || measurement.unit === 'ratio') {
+ return {
+ normalizedValue: round2(measurement.value),
+ normalizedUnit: measurement.unit,
+ conversion: 'identity',
+ };
+ }
+
+ return {
+ normalizedValue: null,
+ normalizedUnit: null,
+ conversion: 'unsupported',
+ };
+}
+
+function addFinding(findings, edge, code, evidence, curatorAction) {
+ findings.push({
+ edgeId: edge.edgeId,
+ code,
+ severity: 'blocker',
+ evidence,
+ curatorAction,
+ });
+}
+
+function evaluateEdge(edge, measurementsById, graph) {
+ const source = measurementsById.get(edge.sourceMeasurementId);
+ const target = measurementsById.get(edge.targetMeasurementId);
+ const findings = [];
+ const normalizedSource = normalizeMeasurement(source);
+ const normalizedTarget = normalizeMeasurement(target);
+
+ if (!normalizedSource.normalizedUnit || !normalizedTarget.normalizedUnit || normalizedSource.normalizedUnit !== normalizedTarget.normalizedUnit) {
+ addFinding(
+ findings,
+ edge,
+ 'unit_not_convertible',
+ `${source.unit} cannot be safely compared with ${target.unit} for ${source.endpoint}.`,
+ 'Add an ontology-backed conversion or mark the relationship as non-comparable.',
+ );
+ }
+
+ const contextFields = ['species', 'tissue', 'assayContext'];
+ const mismatchedContext = contextFields.filter((field) => source.context[field] !== target.context[field]);
+ if (mismatchedContext.length > 0) {
+ addFinding(
+ findings,
+ edge,
+ 'biological_context_mismatch',
+ `Context mismatch in ${mismatchedContext.join(', ')}.`,
+ 'Split the graph edge or add a cross-context translation model before recommending reuse.',
+ );
+ }
+
+ if (source.statistic !== target.statistic) {
+ addFinding(
+ findings,
+ edge,
+ 'statistical_endpoint_mismatch',
+ `${source.statistic} cannot be compared directly with ${target.statistic}.`,
+ 'Normalize the statistical endpoint or require curator approval for the comparison.',
+ );
+ }
+
+ if (edge.evidenceConfidence < graph.policy.minimumEvidenceConfidence) {
+ addFinding(
+ findings,
+ edge,
+ 'provenance_confidence_low',
+ `Evidence confidence ${edge.evidenceConfidence} is below ${graph.policy.minimumEvidenceConfidence}.`,
+ 'Attach DOI, protocol, or dataset provenance before surfacing this recommendation.',
+ );
+ }
+
+ return {
+ decision: {
+ edgeId: edge.edgeId,
+ sourceMeasurementId: source.measurementId,
+ targetMeasurementId: target.measurementId,
+ decision: findings.length > 0 ? 'suppress_recommendation' : 'allow_comparison',
+ normalizedSource,
+ normalizedTarget,
+ },
+ findings,
+ };
+}
+
+function evaluateMeasurementHarmonization(graph) {
+ const measurementsById = new Map(graph.measurements.map((measurement) => [measurement.measurementId, measurement]));
+ const edgeDecisions = [];
+ const findings = [];
+
+ for (const edge of graph.candidateEdges) {
+ const result = evaluateEdge(edge, measurementsById, graph);
+ edgeDecisions.push(result.decision);
+ findings.push(...result.findings);
+ }
+
+ const sortedFindings = findings.sort((left, right) => left.code.localeCompare(right.code));
+
+ return {
+ graphId: graph.graphId,
+ overallStatus: sortedFindings.length > 0 ? 'curation_required' : 'ready',
+ summary: {
+ measurementsReviewed: graph.measurements.length,
+ edgesReviewed: graph.candidateEdges.length,
+ blockingFindings: sortedFindings.length,
+ allowedComparisons: edgeDecisions.filter((edge) => edge.decision === 'allow_comparison').length,
+ },
+ edgeDecisions,
+ findings: sortedFindings,
+ };
+}
+
+function buildGraphHarmonizationPacket(graph) {
+ const review = evaluateMeasurementHarmonization(graph);
+ const packet = {
+ packetType: 'knowledge-graph-measurement-harmonization-guard',
+ graphId: review.graphId,
+ overallStatus: review.overallStatus,
+ edgeDecisions: review.edgeDecisions,
+ findings: review.findings,
+ curatorActions: review.findings.map((finding) => ({
+ edgeId: finding.edgeId,
+ action: finding.curatorAction,
+ evidence: finding.evidence,
+ })),
+ jsonLd: {
+ '@context': 'https://schema.org',
+ '@type': 'MeasurementHarmonizationReview',
+ identifier: review.graphId,
+ reviewStatus: review.overallStatus,
+ measurementCount: review.summary.measurementsReviewed,
+ edgeCount: review.summary.edgesReviewed,
+ },
+ };
+
+ return {
+ ...packet,
+ auditDigest: digest(packet),
+ };
+}
+
+module.exports = {
+ evaluateMeasurementHarmonization,
+ buildGraphHarmonizationPacket,
+ normalizeMeasurement,
+ stableStringify,
+ digest,
+};
diff --git a/knowledge-graph-measurement-harmonization-guard/requirements-map.md b/knowledge-graph-measurement-harmonization-guard/requirements-map.md
new file mode 100644
index 0000000..e0f0daf
--- /dev/null
+++ b/knowledge-graph-measurement-harmonization-guard/requirements-map.md
@@ -0,0 +1,11 @@
+# Requirements Map
+
+Issue #17 asks for scientific knowledge graph integration across concepts, datasets, protocols, papers, authors, and recommendations.
+
+| Requirement area | Coverage in this slice |
+| --- | --- |
+| Entity normalization | Normalizes measurement units and endpoints before graph edge creation. |
+| Relationship quality | Suppresses recommendations when biological context, units, statistics, or provenance are unsafe. |
+| Evidence provenance | Enforces a minimum evidence-confidence threshold for comparable edges. |
+| Curator workflow | Emits explicit curator actions for blocked graph recommendations. |
+| Interoperability | Produces JSON-LD review metadata for entity pages and graph ingestion logs. |
diff --git a/knowledge-graph-measurement-harmonization-guard/sample-data.js b/knowledge-graph-measurement-harmonization-guard/sample-data.js
new file mode 100644
index 0000000..773026a
--- /dev/null
+++ b/knowledge-graph-measurement-harmonization-guard/sample-data.js
@@ -0,0 +1,134 @@
+const sampleGraph = {
+ graphId: 'kg-cardio-measurement-review',
+ policy: {
+ minimumEvidenceConfidence: 0.75,
+ },
+ measurements: [
+ {
+ measurementId: 'rat-pressure-mmhg',
+ endpoint: 'systolic_pressure',
+ value: 120,
+ unit: 'mmHg',
+ statistic: 'mean',
+ context: { species: 'rat', tissue: 'artery', assayContext: 'ex-vivo' },
+ },
+ {
+ measurementId: 'rat-pressure-pa',
+ endpoint: 'systolic_pressure',
+ value: 15999,
+ unit: 'Pa',
+ statistic: 'mean',
+ context: { species: 'rat', tissue: 'artery', assayContext: 'ex-vivo' },
+ },
+ {
+ measurementId: 'human-pressure-pa',
+ endpoint: 'systolic_pressure',
+ value: 15999,
+ unit: 'Pa',
+ statistic: 'mean',
+ context: { species: 'human', tissue: 'artery', assayContext: 'ex-vivo' },
+ },
+ {
+ measurementId: 'glucose-mgdl',
+ endpoint: 'glucose_concentration',
+ value: 95,
+ unit: 'mg/dL',
+ statistic: 'mean',
+ context: { species: 'mouse', tissue: 'serum', assayContext: 'fasted' },
+ },
+ {
+ measurementId: 'glucose-rfu',
+ endpoint: 'glucose_concentration',
+ value: 4200,
+ unit: 'RFU',
+ statistic: 'mean',
+ context: { species: 'mouse', tissue: 'serum', assayContext: 'fasted' },
+ },
+ {
+ measurementId: 'yield-mean',
+ endpoint: 'reaction_yield',
+ value: 81,
+ unit: 'percent',
+ statistic: 'mean',
+ context: { species: 'n/a', tissue: 'catalyst-bed', assayContext: 'bench' },
+ },
+ {
+ measurementId: 'yield-median',
+ endpoint: 'reaction_yield',
+ value: 79,
+ unit: 'percent',
+ statistic: 'median',
+ context: { species: 'n/a', tissue: 'catalyst-bed', assayContext: 'bench' },
+ },
+ ],
+ candidateEdges: [
+ {
+ edgeId: 'edge-rat-pressure-pa',
+ sourceMeasurementId: 'rat-pressure-mmhg',
+ targetMeasurementId: 'rat-pressure-pa',
+ evidenceConfidence: 0.94,
+ },
+ {
+ edgeId: 'edge-rat-human-pressure',
+ sourceMeasurementId: 'rat-pressure-mmhg',
+ targetMeasurementId: 'human-pressure-pa',
+ evidenceConfidence: 0.92,
+ },
+ {
+ edgeId: 'edge-glucose-rfu',
+ sourceMeasurementId: 'glucose-mgdl',
+ targetMeasurementId: 'glucose-rfu',
+ evidenceConfidence: 0.81,
+ },
+ {
+ edgeId: 'edge-mean-vs-median',
+ sourceMeasurementId: 'yield-mean',
+ targetMeasurementId: 'yield-median',
+ evidenceConfidence: 0.85,
+ },
+ {
+ edgeId: 'edge-low-provenance',
+ sourceMeasurementId: 'rat-pressure-mmhg',
+ targetMeasurementId: 'rat-pressure-pa',
+ evidenceConfidence: 0.42,
+ },
+ ],
+};
+
+const harmonizedGraph = {
+ graphId: 'kg-ready-pressure-review',
+ policy: {
+ minimumEvidenceConfidence: 0.75,
+ },
+ measurements: [
+ {
+ measurementId: 'pressure-a',
+ endpoint: 'systolic_pressure',
+ value: 118,
+ unit: 'mmHg',
+ statistic: 'mean',
+ context: { species: 'rat', tissue: 'artery', assayContext: 'ex-vivo' },
+ },
+ {
+ measurementId: 'pressure-b',
+ endpoint: 'systolic_pressure',
+ value: 15730,
+ unit: 'Pa',
+ statistic: 'mean',
+ context: { species: 'rat', tissue: 'artery', assayContext: 'ex-vivo' },
+ },
+ ],
+ candidateEdges: [
+ {
+ edgeId: 'edge-ready-pressure',
+ sourceMeasurementId: 'pressure-a',
+ targetMeasurementId: 'pressure-b',
+ evidenceConfidence: 0.91,
+ },
+ ],
+};
+
+module.exports = {
+ sampleGraph,
+ harmonizedGraph,
+};
diff --git a/knowledge-graph-measurement-harmonization-guard/test.js b/knowledge-graph-measurement-harmonization-guard/test.js
new file mode 100644
index 0000000..7352135
--- /dev/null
+++ b/knowledge-graph-measurement-harmonization-guard/test.js
@@ -0,0 +1,64 @@
+const assert = require('assert');
+const {
+ evaluateMeasurementHarmonization,
+ buildGraphHarmonizationPacket,
+ normalizeMeasurement,
+} = require('./index');
+const { sampleGraph, harmonizedGraph } = require('./sample-data');
+
+function testMeasurementEdgesAreBlockedWhenNotComparable() {
+ const review = evaluateMeasurementHarmonization(sampleGraph);
+
+ assert.equal(review.overallStatus, 'curation_required');
+ assert.equal(review.summary.blockingFindings, 4);
+ assert.ok(review.findings.some((finding) => finding.code === 'biological_context_mismatch'));
+ assert.ok(review.findings.some((finding) => finding.code === 'unit_not_convertible'));
+ assert.ok(review.findings.some((finding) => finding.code === 'statistical_endpoint_mismatch'));
+ assert.ok(review.findings.some((finding) => finding.code === 'provenance_confidence_low'));
+ assert.equal(review.edgeDecisions.find((edge) => edge.edgeId === 'edge-rat-human-pressure').decision, 'suppress_recommendation');
+ assert.equal(review.edgeDecisions.find((edge) => edge.edgeId === 'edge-rat-pressure-pa').decision, 'allow_comparison');
+}
+
+function testUnitNormalizationIsDeterministic() {
+ const normalized = normalizeMeasurement({
+ value: 120,
+ unit: 'mmHg',
+ endpoint: 'systolic_pressure',
+ });
+
+ assert.equal(normalized.normalizedUnit, 'Pa');
+ assert.equal(normalized.normalizedValue, 15998.64);
+ assert.equal(normalized.conversion, 'mmHg_to_Pa');
+}
+
+function testGraphPacketIncludesJsonLdAndCuratorActions() {
+ const packet = buildGraphHarmonizationPacket(sampleGraph);
+
+ assert.match(packet.auditDigest, /^[a-f0-9]{64}$/);
+ assert.equal(packet.jsonLd['@type'], 'MeasurementHarmonizationReview');
+ assert.equal(packet.curatorActions.length, 4);
+ assert.deepEqual(
+ packet.findings.map((finding) => finding.code),
+ [
+ 'biological_context_mismatch',
+ 'provenance_confidence_low',
+ 'statistical_endpoint_mismatch',
+ 'unit_not_convertible',
+ ],
+ );
+}
+
+function testHarmonizedGraphAllowsRecommendations() {
+ const review = evaluateMeasurementHarmonization(harmonizedGraph);
+
+ assert.equal(review.overallStatus, 'ready');
+ assert.equal(review.summary.blockingFindings, 0);
+ assert.equal(review.edgeDecisions.every((edge) => edge.decision === 'allow_comparison'), true);
+}
+
+testMeasurementEdgesAreBlockedWhenNotComparable();
+testUnitNormalizationIsDeterministic();
+testGraphPacketIncludesJsonLdAndCuratorActions();
+testHarmonizedGraphAllowsRecommendations();
+
+console.log('knowledge-graph-measurement-harmonization-guard tests passed');