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 @@ + + + + Knowledge Graph Measurement Harmonization + kg-cardio-measurement-review + 4 comparable-edge blockers + 1. edge-rat-human-pressure: biological_context_mismatch + 2. edge-low-provenance: provenance_confidence_low + 3. edge-mean-vs-median: statistical_endpoint_mismatch + 4. edge-glucose-rfu: unit_not_convertible + audit digest: 6b18c9efd5c2126643baa9b6... + 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'), ` + + + Knowledge Graph Measurement Harmonization + ${packet.graphId} + ${packet.findings.length} comparable-edge blockers + ${rows} + audit digest: ${packet.auditDigest.slice(0, 24)}... + +`); + +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');