diff --git a/scientific-terminology-unit-guard/README.md b/scientific-terminology-unit-guard/README.md
new file mode 100644
index 00000000..f2e86785
--- /dev/null
+++ b/scientific-terminology-unit-guard/README.md
@@ -0,0 +1,17 @@
+# Scientific Terminology And Unit Guard
+
+Self-contained SCIBASE real-time collaborative editor slice for issue #12. The guard checks whether collaborator edits keep scientific terminology, acronyms, units, and equation variables consistent before WYSIWYG, Markdown, LaTeX, or publication exports proceed.
+
+## Why this slice is distinct
+
+Existing #12 submissions cover broad editor foundations, operation replay, offline conflict resolution, notebook/kernel collaboration, reference formatting, round-trip export, accessibility parity, suggestion provenance, chat mentions, notification visibility, task dependencies, section locks, and figure/table review lanes. This module focuses only on shared scientific language consistency while multiple collaborators edit the same manuscript.
+
+## Run
+
+```bash
+npm test
+npm run demo
+npm run demo:video
+```
+
+Demo artifacts are written to `reports/`, including JSON, Markdown, SVG, GIF, and MP4 files.
diff --git a/scientific-terminology-unit-guard/demo.js b/scientific-terminology-unit-guard/demo.js
new file mode 100644
index 00000000..2b8982fa
--- /dev/null
+++ b/scientific-terminology-unit-guard/demo.js
@@ -0,0 +1,59 @@
+const fs = require("fs");
+const path = require("path");
+
+const { assessTerminologyAndUnits } = require("./index");
+const { cleanDocument, riskyDocument } = require("./sample-data");
+
+const reportsDir = path.join(__dirname, "reports");
+fs.mkdirSync(reportsDir, { recursive: true });
+
+function markdownReport(name, report) {
+ const findings = report.findings.length
+ ? report.findings
+ .map((item) => `- ${item.severity.toUpperCase()} ${item.code}: ${item.message}`)
+ .join("\n")
+ : "- No terminology or unit findings.";
+ return `# ${report.title}
+
+Scenario: ${name}
+
+Decision: ${report.decision.toUpperCase()}
+
+Reviewed ${report.summary.blocksReviewed} document blocks and ${report.summary.suggestionsReviewed} collaborator suggestions.
+
+## Findings
+
+${findings}
+
+## Release Criteria
+
+${report.releaseCriteria.map((item) => `- ${item}`).join("\n")}
+`;
+}
+
+function svgReport(report) {
+ const color = report.decision === "hold" ? "#dc2626" : report.decision === "revise" ? "#d97706" : "#16a34a";
+ return ``;
+}
+
+for (const [name, document] of [
+ ["clean-document", cleanDocument],
+ ["risky-document", riskyDocument],
+]) {
+ const report = assessTerminologyAndUnits(document);
+ fs.writeFileSync(path.join(reportsDir, `${name}.json`), JSON.stringify(report, null, 2));
+ fs.writeFileSync(path.join(reportsDir, `${name}.md`), markdownReport(name, report));
+ fs.writeFileSync(path.join(reportsDir, `${name}.svg`), svgReport(report));
+ console.log(`${name}: ${report.decision} (${report.summary.findings} findings)`);
+}
diff --git a/scientific-terminology-unit-guard/demo_video.py b/scientific-terminology-unit-guard/demo_video.py
new file mode 100644
index 00000000..109ffcca
--- /dev/null
+++ b/scientific-terminology-unit-guard/demo_video.py
@@ -0,0 +1,46 @@
+from pathlib import Path
+
+import imageio.v3 as iio
+import numpy as np
+from PIL import Image, ImageDraw, ImageFont
+
+
+ROOT = Path(__file__).resolve().parent
+REPORTS = ROOT / "reports"
+REPORTS.mkdir(exist_ok=True)
+
+
+def font(size):
+ for name in ("arial.ttf", "segoeui.ttf"):
+ try:
+ return ImageFont.truetype(name, size)
+ except OSError:
+ pass
+ return ImageFont.load_default()
+
+
+slides = [
+ ("Terminology + Unit Guard", "Real-time collaborative editor #12"),
+ ("Checks", "glossary drift + acronym first-use gaps"),
+ ("Checks", "unit conflicts + equation variable mismatch"),
+ ("Decision", "hold publication export until shared language is consistent"),
+]
+
+frames = []
+for index, (title, subtitle) in enumerate(slides, start=1):
+ image = Image.new("RGB", (960, 544), "#172033")
+ draw = ImageDraw.Draw(image)
+ draw.rectangle((46, 54, 914, 490), outline="#a3e635", width=3)
+ draw.text((82, 124), title, fill="#f8fafc", font=font(42))
+ draw.text((82, 206), subtitle, fill="#ecfccb", font=font(26))
+ draw.rectangle((82, 326, 742, 382), fill="#365314")
+ draw.text((104, 342), "collaborator suggestions cannot introduce scientific drift", fill="#f7fee7", font=font(22))
+ draw.text((82, 438), f"Slide {index}/4 - synthetic reviewer artifact", fill="#cbd5e1", font=font(20))
+ frames.extend([image] * 14)
+
+gif_path = REPORTS / "demo.gif"
+mp4_path = REPORTS / "demo.mp4"
+frames[0].save(gif_path, save_all=True, append_images=frames[1:], duration=120, loop=0)
+iio.imwrite(mp4_path, [np.asarray(frame) for frame in frames], fps=8, codec="libx264")
+print(f"wrote {gif_path}")
+print(f"wrote {mp4_path}")
diff --git a/scientific-terminology-unit-guard/index.js b/scientific-terminology-unit-guard/index.js
new file mode 100644
index 00000000..3e111647
--- /dev/null
+++ b/scientific-terminology-unit-guard/index.js
@@ -0,0 +1,212 @@
+const HIGH = "high";
+const MEDIUM = "medium";
+const LOW = "low";
+
+function requireString(value, field) {
+ if (typeof value !== "string" || value.trim() === "") {
+ throw new TypeError(`${field} must be a non-empty string`);
+ }
+ return value.trim();
+}
+
+function list(value, field) {
+ if (!Array.isArray(value)) {
+ throw new TypeError(`${field} must be an array`);
+ }
+ return value;
+}
+
+function normalizeToken(value) {
+ return String(value || "").trim();
+}
+
+function normalizeDocument(raw) {
+ return {
+ documentId: requireString(raw.documentId, "documentId"),
+ title: requireString(raw.title, "title"),
+ glossary: raw.glossary || {},
+ units: raw.units || {},
+ variables: raw.variables || {},
+ blocks: list(raw.blocks || [], "blocks").map((block) => ({
+ id: requireString(block.id, "block.id"),
+ author: requireString(block.author, "block.author"),
+ text: String(block.text || ""),
+ acronyms: list(block.acronyms || [], "block.acronyms"),
+ units: list(block.units || [], "block.units"),
+ variables: list(block.variables || [], "block.variables"),
+ })),
+ suggestions: list(raw.suggestions || [], "suggestions").map((suggestion) => ({
+ id: requireString(suggestion.id, "suggestion.id"),
+ author: requireString(suggestion.author, "suggestion.author"),
+ targetBlock: requireString(suggestion.targetBlock, "suggestion.targetBlock"),
+ text: String(suggestion.text || ""),
+ acronyms: list(suggestion.acronyms || [], "suggestion.acronyms"),
+ units: list(suggestion.units || [], "suggestion.units"),
+ variables: list(suggestion.variables || [], "suggestion.variables"),
+ })),
+ };
+}
+
+function finding(code, severity, source, message, remediation) {
+ return {
+ code,
+ severity,
+ sourceId: source.id,
+ author: source.author,
+ message,
+ remediation,
+ };
+}
+
+function analyzeSource(doc, source, findings) {
+ for (const item of source.acronyms) {
+ const short = normalizeToken(item.short);
+ const expected = normalizeToken(doc.glossary[short]);
+ const observed = normalizeToken(item.long);
+ if (!short) {
+ continue;
+ }
+ if (!expected) {
+ findings.push(
+ finding(
+ "UNKNOWN_ACRONYM",
+ MEDIUM,
+ source,
+ `${short} is used but is absent from the shared glossary.`,
+ "Add the acronym to the shared glossary or replace it before publication export."
+ )
+ );
+ } else if (!observed) {
+ findings.push(
+ finding(
+ "MISSING_FIRST_USE_EXPANSION",
+ MEDIUM,
+ source,
+ `${short} appears without its first-use expansion.`,
+ `Expand ${short} as "${expected}" at first use in the collaborative document.`
+ )
+ );
+ } else if (observed.toLowerCase() !== expected.toLowerCase()) {
+ findings.push(
+ finding(
+ "ACRONYM_DRIFT",
+ HIGH,
+ source,
+ `${short} is defined as "${observed}" but glossary expects "${expected}".`,
+ "Resolve the conflicting expansion in the suggestion or block before accepting changes."
+ )
+ );
+ }
+ }
+
+ for (const item of source.units) {
+ const quantity = normalizeToken(item.quantity);
+ const unit = normalizeToken(item.unit);
+ const expected = normalizeToken(doc.units[quantity]);
+ if (!expected) {
+ findings.push(
+ finding(
+ "UNKNOWN_QUANTITY_UNIT",
+ LOW,
+ source,
+ `${quantity} has unit "${unit}" but no canonical unit is registered.`,
+ "Register a canonical unit or mark the quantity as intentionally free-form."
+ )
+ );
+ } else if (unit !== expected) {
+ findings.push(
+ finding(
+ "UNIT_CONFLICT",
+ HIGH,
+ source,
+ `${quantity} uses "${unit}" but canonical unit is "${expected}".`,
+ "Convert or annotate the value before export so collaborators compare like with like."
+ )
+ );
+ }
+ }
+
+ for (const item of source.variables) {
+ const symbol = normalizeToken(item.symbol);
+ const meaning = normalizeToken(item.meaning);
+ const expected = normalizeToken(doc.variables[symbol]);
+ if (!expected) {
+ findings.push(
+ finding(
+ "UNREGISTERED_VARIABLE",
+ LOW,
+ source,
+ `${symbol} is used in an equation context without a shared definition.`,
+ "Add the variable to the equation legend before accepting the edit."
+ )
+ );
+ } else if (meaning.toLowerCase() !== expected.toLowerCase()) {
+ findings.push(
+ finding(
+ "VARIABLE_MEANING_DRIFT",
+ HIGH,
+ source,
+ `${symbol} means "${meaning}" here but shared definition is "${expected}".`,
+ "Resolve the variable definition mismatch before rendering equations or exports."
+ )
+ );
+ }
+ }
+}
+
+function assessTerminologyAndUnits(rawDocument) {
+ const doc = normalizeDocument(rawDocument);
+ const findings = [];
+
+ for (const block of doc.blocks) {
+ analyzeSource(doc, block, findings);
+ }
+ for (const suggestion of doc.suggestions) {
+ analyzeSource(doc, suggestion, findings);
+ }
+
+ const sourceIds = new Set([...doc.blocks, ...doc.suggestions].map((item) => item.id));
+ for (const suggestion of doc.suggestions) {
+ if (!sourceIds.has(suggestion.targetBlock)) {
+ findings.push(
+ finding(
+ "ORPHAN_SUGGESTION_TARGET",
+ MEDIUM,
+ suggestion,
+ `Suggestion targets missing block ${suggestion.targetBlock}.`,
+ "Retarget or close the suggestion before merging collaborative edits."
+ )
+ );
+ }
+ }
+
+ const high = findings.filter((item) => item.severity === HIGH).length;
+ const medium = findings.filter((item) => item.severity === MEDIUM).length;
+ const decision = high > 0 ? "hold" : medium > 0 ? "revise" : "release";
+
+ return {
+ documentId: doc.documentId,
+ title: doc.title,
+ decision,
+ summary: {
+ blocksReviewed: doc.blocks.length,
+ suggestionsReviewed: doc.suggestions.length,
+ findings: findings.length,
+ high,
+ medium,
+ low: findings.filter((item) => item.severity === LOW).length,
+ },
+ findings,
+ releaseCriteria: [
+ "Shared glossary acronyms keep one expansion across all blocks and suggestions.",
+ "Scientific quantities use the canonical unit selected for the collaborative document.",
+ "Equation variables keep one meaning before WYSIWYG, Markdown, LaTeX, or export render.",
+ "Suggestions cannot introduce terminology drift while being accepted into the manuscript.",
+ ],
+ };
+}
+
+module.exports = {
+ assessTerminologyAndUnits,
+ normalizeDocument,
+};
diff --git a/scientific-terminology-unit-guard/package.json b/scientific-terminology-unit-guard/package.json
new file mode 100644
index 00000000..eaaf4759
--- /dev/null
+++ b/scientific-terminology-unit-guard/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "scientific-terminology-unit-guard",
+ "version": "1.0.0",
+ "description": "Collaborative terminology and unit consistency guard for SCIBASE editor issue #12",
+ "main": "index.js",
+ "type": "commonjs",
+ "scripts": {
+ "test": "node test.js",
+ "demo": "node demo.js",
+ "demo:video": "python demo_video.py"
+ },
+ "license": "MIT"
+}
diff --git a/scientific-terminology-unit-guard/reports/clean-document.json b/scientific-terminology-unit-guard/reports/clean-document.json
new file mode 100644
index 00000000..346e8425
--- /dev/null
+++ b/scientific-terminology-unit-guard/reports/clean-document.json
@@ -0,0 +1,20 @@
+{
+ "documentId": "editor-term-001",
+ "title": "Collaborative catalyst manuscript",
+ "decision": "release",
+ "summary": {
+ "blocksReviewed": 2,
+ "suggestionsReviewed": 1,
+ "findings": 0,
+ "high": 0,
+ "medium": 0,
+ "low": 0
+ },
+ "findings": [],
+ "releaseCriteria": [
+ "Shared glossary acronyms keep one expansion across all blocks and suggestions.",
+ "Scientific quantities use the canonical unit selected for the collaborative document.",
+ "Equation variables keep one meaning before WYSIWYG, Markdown, LaTeX, or export render.",
+ "Suggestions cannot introduce terminology drift while being accepted into the manuscript."
+ ]
+}
\ No newline at end of file
diff --git a/scientific-terminology-unit-guard/reports/clean-document.md b/scientific-terminology-unit-guard/reports/clean-document.md
new file mode 100644
index 00000000..3dc5c2b8
--- /dev/null
+++ b/scientific-terminology-unit-guard/reports/clean-document.md
@@ -0,0 +1,18 @@
+# Collaborative catalyst manuscript
+
+Scenario: clean-document
+
+Decision: RELEASE
+
+Reviewed 2 document blocks and 1 collaborator suggestions.
+
+## Findings
+
+- No terminology or unit findings.
+
+## Release Criteria
+
+- Shared glossary acronyms keep one expansion across all blocks and suggestions.
+- Scientific quantities use the canonical unit selected for the collaborative document.
+- Equation variables keep one meaning before WYSIWYG, Markdown, LaTeX, or export render.
+- Suggestions cannot introduce terminology drift while being accepted into the manuscript.
diff --git a/scientific-terminology-unit-guard/reports/clean-document.svg b/scientific-terminology-unit-guard/reports/clean-document.svg
new file mode 100644
index 00000000..cd96cc57
--- /dev/null
+++ b/scientific-terminology-unit-guard/reports/clean-document.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/scientific-terminology-unit-guard/reports/demo.gif b/scientific-terminology-unit-guard/reports/demo.gif
new file mode 100644
index 00000000..1d224ff8
Binary files /dev/null and b/scientific-terminology-unit-guard/reports/demo.gif differ
diff --git a/scientific-terminology-unit-guard/reports/demo.mp4 b/scientific-terminology-unit-guard/reports/demo.mp4
new file mode 100644
index 00000000..5b736d03
Binary files /dev/null and b/scientific-terminology-unit-guard/reports/demo.mp4 differ
diff --git a/scientific-terminology-unit-guard/reports/risky-document.json b/scientific-terminology-unit-guard/reports/risky-document.json
new file mode 100644
index 00000000..33797da1
--- /dev/null
+++ b/scientific-terminology-unit-guard/reports/risky-document.json
@@ -0,0 +1,101 @@
+{
+ "documentId": "editor-term-002",
+ "title": "Collaborative catalyst manuscript",
+ "decision": "hold",
+ "summary": {
+ "blocksReviewed": 2,
+ "suggestionsReviewed": 1,
+ "findings": 10,
+ "high": 7,
+ "medium": 2,
+ "low": 1
+ },
+ "findings": [
+ {
+ "code": "MISSING_FIRST_USE_EXPANSION",
+ "severity": "medium",
+ "sourceId": "abstract",
+ "author": "alice",
+ "message": "HER appears without its first-use expansion.",
+ "remediation": "Expand HER as \"hydrogen evolution reaction\" at first use in the collaborative document."
+ },
+ {
+ "code": "MISSING_FIRST_USE_EXPANSION",
+ "severity": "medium",
+ "sourceId": "abstract",
+ "author": "alice",
+ "message": "FE appears without its first-use expansion.",
+ "remediation": "Expand FE as \"faradaic efficiency\" at first use in the collaborative document."
+ },
+ {
+ "code": "UNIT_CONFLICT",
+ "severity": "high",
+ "sourceId": "abstract",
+ "author": "alice",
+ "message": "temperature uses \"C\" but canonical unit is \"K\".",
+ "remediation": "Convert or annotate the value before export so collaborators compare like with like."
+ },
+ {
+ "code": "VARIABLE_MEANING_DRIFT",
+ "severity": "high",
+ "sourceId": "abstract",
+ "author": "alice",
+ "message": "eta means \"efficiency\" here but shared definition is \"overpotential\".",
+ "remediation": "Resolve the variable definition mismatch before rendering equations or exports."
+ },
+ {
+ "code": "ACRONYM_DRIFT",
+ "severity": "high",
+ "sourceId": "methods",
+ "author": "bob",
+ "message": "RHE is defined as \"rapid heat exchange\" but glossary expects \"reversible hydrogen electrode\".",
+ "remediation": "Resolve the conflicting expansion in the suggestion or block before accepting changes."
+ },
+ {
+ "code": "UNIT_CONFLICT",
+ "severity": "high",
+ "sourceId": "methods",
+ "author": "bob",
+ "message": "currentDensity uses \"A m^-2\" but canonical unit is \"mA cm^-2\".",
+ "remediation": "Convert or annotate the value before export so collaborators compare like with like."
+ },
+ {
+ "code": "UNIT_CONFLICT",
+ "severity": "high",
+ "sourceId": "methods",
+ "author": "bob",
+ "message": "potential uses \"V vs Ag/AgCl\" but canonical unit is \"V vs RHE\".",
+ "remediation": "Convert or annotate the value before export so collaborators compare like with like."
+ },
+ {
+ "code": "VARIABLE_MEANING_DRIFT",
+ "severity": "high",
+ "sourceId": "methods",
+ "author": "bob",
+ "message": "j means \"flux\" here but shared definition is \"current density\".",
+ "remediation": "Resolve the variable definition mismatch before rendering equations or exports."
+ },
+ {
+ "code": "UNREGISTERED_VARIABLE",
+ "severity": "low",
+ "sourceId": "methods",
+ "author": "bob",
+ "message": "E is used in an equation context without a shared definition.",
+ "remediation": "Add the variable to the equation legend before accepting the edit."
+ },
+ {
+ "code": "ACRONYM_DRIFT",
+ "severity": "high",
+ "sourceId": "sug-risk",
+ "author": "dana",
+ "message": "RHE is defined as \"rapid heat exchange\" but glossary expects \"reversible hydrogen electrode\".",
+ "remediation": "Resolve the conflicting expansion in the suggestion or block before accepting changes."
+ }
+ ],
+ "releaseCriteria": [
+ "Shared glossary acronyms keep one expansion across all blocks and suggestions.",
+ "Scientific quantities use the canonical unit selected for the collaborative document.",
+ "Equation variables keep one meaning before WYSIWYG, Markdown, LaTeX, or export render.",
+ "Suggestions cannot introduce terminology drift while being accepted into the manuscript."
+ ]
+}
\ No newline at end of file
diff --git a/scientific-terminology-unit-guard/reports/risky-document.md b/scientific-terminology-unit-guard/reports/risky-document.md
new file mode 100644
index 00000000..94277715
--- /dev/null
+++ b/scientific-terminology-unit-guard/reports/risky-document.md
@@ -0,0 +1,27 @@
+# Collaborative catalyst manuscript
+
+Scenario: risky-document
+
+Decision: HOLD
+
+Reviewed 2 document blocks and 1 collaborator suggestions.
+
+## Findings
+
+- MEDIUM MISSING_FIRST_USE_EXPANSION: HER appears without its first-use expansion.
+- MEDIUM MISSING_FIRST_USE_EXPANSION: FE appears without its first-use expansion.
+- HIGH UNIT_CONFLICT: temperature uses "C" but canonical unit is "K".
+- HIGH VARIABLE_MEANING_DRIFT: eta means "efficiency" here but shared definition is "overpotential".
+- HIGH ACRONYM_DRIFT: RHE is defined as "rapid heat exchange" but glossary expects "reversible hydrogen electrode".
+- HIGH UNIT_CONFLICT: currentDensity uses "A m^-2" but canonical unit is "mA cm^-2".
+- HIGH UNIT_CONFLICT: potential uses "V vs Ag/AgCl" but canonical unit is "V vs RHE".
+- HIGH VARIABLE_MEANING_DRIFT: j means "flux" here but shared definition is "current density".
+- LOW UNREGISTERED_VARIABLE: E is used in an equation context without a shared definition.
+- HIGH ACRONYM_DRIFT: RHE is defined as "rapid heat exchange" but glossary expects "reversible hydrogen electrode".
+
+## Release Criteria
+
+- Shared glossary acronyms keep one expansion across all blocks and suggestions.
+- Scientific quantities use the canonical unit selected for the collaborative document.
+- Equation variables keep one meaning before WYSIWYG, Markdown, LaTeX, or export render.
+- Suggestions cannot introduce terminology drift while being accepted into the manuscript.
diff --git a/scientific-terminology-unit-guard/reports/risky-document.svg b/scientific-terminology-unit-guard/reports/risky-document.svg
new file mode 100644
index 00000000..7cc08d9e
--- /dev/null
+++ b/scientific-terminology-unit-guard/reports/risky-document.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/scientific-terminology-unit-guard/requirements-map.md b/scientific-terminology-unit-guard/requirements-map.md
new file mode 100644
index 00000000..a7e88e79
--- /dev/null
+++ b/scientific-terminology-unit-guard/requirements-map.md
@@ -0,0 +1,13 @@
+# Requirements Map
+
+Issue #12 asks for a real-time collaborative research editor with rich scientific formatting, suggestions/change tracking, version history, publication workflows, and collaborative review.
+
+| Issue capability | This implementation |
+| --- | --- |
+| Rich scientific formatting | Validates acronyms, scientific terms, units, and equation variable definitions before Markdown/LaTeX/export rendering. |
+| Suggestions and change tracking | Checks collaborator suggestions for glossary drift before they are accepted into the manuscript. |
+| Multi-user collaboration | Tracks source block and author on each finding so collaborators can resolve the exact edit. |
+| Publication-ready fidelity | Holds export when unit or variable meaning drift would make figures, tables, equations, or text inconsistent. |
+| Version/review workflow | Emits deterministic `release`, `revise`, or `hold` decisions with remediation steps for reviewer packets. |
+
+The module uses synthetic manuscript data only and does not contact credentials, private documents, external APIs, or live editor services.
diff --git a/scientific-terminology-unit-guard/sample-data.js b/scientific-terminology-unit-guard/sample-data.js
new file mode 100644
index 00000000..2d29edec
--- /dev/null
+++ b/scientific-terminology-unit-guard/sample-data.js
@@ -0,0 +1,99 @@
+const cleanDocument = {
+ documentId: "editor-term-001",
+ title: "Collaborative catalyst manuscript",
+ glossary: {
+ HER: "hydrogen evolution reaction",
+ RHE: "reversible hydrogen electrode",
+ FE: "faradaic efficiency",
+ },
+ units: {
+ currentDensity: "mA cm^-2",
+ potential: "V vs RHE",
+ temperature: "K",
+ },
+ variables: {
+ eta: "overpotential",
+ j: "current density",
+ T: "temperature",
+ },
+ blocks: [
+ {
+ id: "intro",
+ author: "alice",
+ text: "The hydrogen evolution reaction (HER) was measured versus the reversible hydrogen electrode (RHE).",
+ acronyms: [{ short: "HER", long: "hydrogen evolution reaction" }, { short: "RHE", long: "reversible hydrogen electrode" }],
+ units: [{ quantity: "potential", unit: "V vs RHE" }],
+ variables: [],
+ },
+ {
+ id: "results",
+ author: "bob",
+ text: "The HER reached 42 mA cm^-2 and 91% faradaic efficiency (FE) at 298 K.",
+ acronyms: [{ short: "FE", long: "faradaic efficiency" }],
+ units: [
+ { quantity: "currentDensity", unit: "mA cm^-2" },
+ { quantity: "temperature", unit: "K" },
+ ],
+ variables: [
+ { symbol: "eta", meaning: "overpotential" },
+ { symbol: "j", meaning: "current density" },
+ ],
+ },
+ ],
+ suggestions: [
+ {
+ id: "sug-1",
+ author: "carol",
+ targetBlock: "results",
+ text: "Clarify that eta is the overpotential used for Tafel analysis.",
+ acronyms: [],
+ units: [],
+ variables: [{ symbol: "eta", meaning: "overpotential" }],
+ },
+ ],
+};
+
+const riskyDocument = {
+ ...cleanDocument,
+ documentId: "editor-term-002",
+ blocks: [
+ {
+ id: "abstract",
+ author: "alice",
+ text: "HER improves at 25 C with strong FE, but no first-use expansions are provided.",
+ acronyms: [{ short: "HER", long: "" }, { short: "FE", long: "" }],
+ units: [{ quantity: "temperature", unit: "C" }],
+ variables: [{ symbol: "eta", meaning: "efficiency" }],
+ },
+ {
+ id: "methods",
+ author: "bob",
+ text: "Current density j was normalized as A/m2, and E was reported versus Ag/AgCl.",
+ acronyms: [{ short: "RHE", long: "rapid heat exchange" }],
+ units: [
+ { quantity: "currentDensity", unit: "A m^-2" },
+ { quantity: "potential", unit: "V vs Ag/AgCl" },
+ ],
+ variables: [
+ { symbol: "j", meaning: "flux" },
+ { symbol: "E", meaning: "electrode potential" },
+ ],
+ },
+ ],
+ suggestions: [
+ {
+ id: "sug-risk",
+ author: "dana",
+ targetBlock: "methods",
+ text: "Rename RHE to rapid heat exchange throughout.",
+ acronyms: [{ short: "RHE", long: "rapid heat exchange" }],
+ units: [],
+ variables: [],
+ },
+ ],
+};
+
+module.exports = {
+ cleanDocument,
+ riskyDocument,
+};
diff --git a/scientific-terminology-unit-guard/test.js b/scientific-terminology-unit-guard/test.js
new file mode 100644
index 00000000..60accca2
--- /dev/null
+++ b/scientific-terminology-unit-guard/test.js
@@ -0,0 +1,45 @@
+const assert = require("assert");
+
+const { assessTerminologyAndUnits, normalizeDocument } = require("./index");
+const { cleanDocument, riskyDocument } = require("./sample-data");
+
+const clean = assessTerminologyAndUnits(cleanDocument);
+assert.strictEqual(clean.decision, "release");
+assert.strictEqual(clean.summary.findings, 0);
+
+const risky = assessTerminologyAndUnits(riskyDocument);
+assert.strictEqual(risky.decision, "hold");
+for (const code of [
+ "MISSING_FIRST_USE_EXPANSION",
+ "ACRONYM_DRIFT",
+ "UNIT_CONFLICT",
+ "VARIABLE_MEANING_DRIFT",
+ "UNREGISTERED_VARIABLE",
+]) {
+ assert(risky.findings.some((finding) => finding.code === code), `missing ${code}`);
+}
+
+const unknown = assessTerminologyAndUnits({
+ ...cleanDocument,
+ blocks: [
+ {
+ id: "new-block",
+ author: "eve",
+ text: "New metric ABC is introduced.",
+ acronyms: [{ short: "ABC", long: "adaptive binding coefficient" }],
+ units: [{ quantity: "bindingRate", unit: "s^-1" }],
+ variables: [],
+ },
+ ],
+ suggestions: [],
+});
+assert.strictEqual(unknown.decision, "revise");
+assert(unknown.findings.some((finding) => finding.code === "UNKNOWN_ACRONYM"));
+assert(unknown.findings.some((finding) => finding.code === "UNKNOWN_QUANTITY_UNIT"));
+
+assert.throws(
+ () => normalizeDocument({ ...cleanDocument, documentId: "" }),
+ /documentId must be a non-empty string/
+);
+
+console.log("scientific terminology and unit guard tests passed");