diff --git a/collaborative-statistical-reporting-guard/README.md b/collaborative-statistical-reporting-guard/README.md
new file mode 100644
index 00000000..c252fdbe
--- /dev/null
+++ b/collaborative-statistical-reporting-guard/README.md
@@ -0,0 +1,43 @@
+# Collaborative Statistical Reporting Guard
+
+This self-contained module adds a deterministic statistics consistency gate for the real-time collaborative research editor. It is scoped to SCIBASE issue #12 and focuses on whether statistics inside a coauthored manuscript are safe to export after collaborative edits.
+
+The guard does not call external APIs, payment systems, identity providers, live projects, or private data stores. Fixtures are synthetic and every check runs with Node built-ins.
+
+## What It Checks
+
+- Sample-size parity across manuscript text, tables, and figures.
+- P-value wording consistency with the configured significance threshold.
+- Text/table p-value drift.
+- Effect-size parity across text and tables.
+- Confidence-interval parity across text and tables.
+- Figure statistic parity against manuscript effect sizes.
+- Statistical edits inside locked sections without approval.
+- Unresolved blocking statistical reviewer comments.
+- Export-ready, statistical-review, and hold/remediation decisions.
+
+## Local Validation
+
+```sh
+npm --prefix collaborative-statistical-reporting-guard run check
+npm --prefix collaborative-statistical-reporting-guard test
+npm --prefix collaborative-statistical-reporting-guard run demo
+npm --prefix collaborative-statistical-reporting-guard run make-demo-video
+npm --prefix collaborative-statistical-reporting-guard run verify-video
+```
+
+## Generated Artifacts
+
+Running the demo writes:
+
+- `reports/clean-statistical-reporting-report.json`
+- `reports/risky-statistical-reporting-report.json`
+- `reports/risky-statistical-reporting-handoff.md`
+- `reports/statistical-reporting-dashboard.svg`
+- `reports/demo.mp4`
+
+The risky packet intentionally demonstrates release blockers: sample-size drift, contradictory p-value wording, table/text p-value mismatch, effect-size mismatch, confidence-interval mismatch, figure/statistic mismatch, unapproved locked-section statistical edits, and unresolved statistical-review comments.
+
+## Issue Fit
+
+This is a distinct collaborative editor slice. It complements the existing broad editor, operation replay, offline conflict, notebook/kernel, reference formatting, equation/figure anchors, table formula, terminology/unit consistency, accessibility, suggestion provenance, notification, private-comment export, and data-availability work by focusing specifically on statistical reporting consistency before manuscript export.
diff --git a/collaborative-statistical-reporting-guard/demo.js b/collaborative-statistical-reporting-guard/demo.js
new file mode 100644
index 00000000..30e79fd9
--- /dev/null
+++ b/collaborative-statistical-reporting-guard/demo.js
@@ -0,0 +1,106 @@
+const fs = require("node:fs");
+const path = require("node:path");
+const { evaluateStatisticalReporting } = require("./index");
+const { cleanPacket, riskyPacket } = require("./sample-data");
+
+const reportsDir = path.join(__dirname, "reports");
+fs.mkdirSync(reportsDir, { recursive: true });
+
+const clean = evaluateStatisticalReporting(cleanPacket);
+const risky = evaluateStatisticalReporting(riskyPacket);
+
+function writeJson(name, value) {
+ fs.writeFileSync(path.join(reportsDir, name), `${JSON.stringify(value, null, 2)}\n`);
+}
+
+function escapeXml(value) {
+ return String(value)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+function findingTable(report) {
+ return report.findings
+ .slice(0, 12)
+ .map((finding) => `| ${finding.severity} | ${finding.code} | ${finding.action} |`)
+ .join("\n");
+}
+
+function writeHandoff(report) {
+ const lines = [
+ "# Collaborative Statistical Reporting Handoff",
+ "",
+ `Decision: ${report.summary.decision}`,
+ `Analyses reviewed: ${report.summary.analysesReviewed}`,
+ `Held analyses: ${report.summary.heldAnalyses}`,
+ `Audit digest: ${report.summary.auditDigest}`,
+ "",
+ "## Priority Findings",
+ "",
+ "| Severity | Code | Remediation |",
+ "| --- | --- | --- |",
+ findingTable(report),
+ "",
+ "## Analysis Actions",
+ "",
+ "| Analysis | Status | Actions |",
+ "| --- | --- | --- |",
+ ...report.analyses.map((analysis) => (
+ `| ${analysis.id} | ${analysis.status} | ${analysis.requiredActions.join(", ") || "none"} |`
+ )),
+ ""
+ ];
+ fs.writeFileSync(path.join(reportsDir, "risky-statistical-reporting-handoff.md"), `${lines.join("\n")}\n`);
+}
+
+function writeSvg(cleanReport, riskyReport) {
+ const width = 960;
+ const height = 540;
+ const criticalWidth = Math.round((riskyReport.summary.criticalFindings / 4) * 300);
+ const highWidth = Math.round((riskyReport.summary.highOrCriticalFindings / 12) * 300);
+ const findingWidth = Math.round((riskyReport.summary.findingCount / 16) * 300);
+ const rows = riskyReport.findings.slice(0, 8).map((finding, index) => {
+ const y = 244 + index * 26;
+ const color = finding.severity === "critical" ? "#991b1b" : finding.severity === "high" ? "#dc2626" : "#d97706";
+ return `${escapeXml(finding.code)}`;
+ }).join("\n");
+
+ const svg = `
+`;
+ fs.writeFileSync(path.join(reportsDir, "statistical-reporting-dashboard.svg"), svg);
+}
+
+writeJson("clean-statistical-reporting-report.json", clean);
+writeJson("risky-statistical-reporting-report.json", risky);
+writeHandoff(risky);
+writeSvg(clean, risky);
+
+console.log("Wrote collaborative statistical reporting guard reports:");
+console.log(`- ${path.join(reportsDir, "clean-statistical-reporting-report.json")}`);
+console.log(`- ${path.join(reportsDir, "risky-statistical-reporting-report.json")}`);
+console.log(`- ${path.join(reportsDir, "risky-statistical-reporting-handoff.md")}`);
+console.log(`- ${path.join(reportsDir, "statistical-reporting-dashboard.svg")}`);
diff --git a/collaborative-statistical-reporting-guard/index.js b/collaborative-statistical-reporting-guard/index.js
new file mode 100644
index 00000000..d34101d7
--- /dev/null
+++ b/collaborative-statistical-reporting-guard/index.js
@@ -0,0 +1,369 @@
+const crypto = require("node:crypto");
+
+function asArray(value) {
+ return Array.isArray(value) ? value : [];
+}
+
+function stableJson(value) {
+ if (Array.isArray(value)) {
+ return `[${value.map(stableJson).join(",")}]`;
+ }
+ if (value && typeof value === "object") {
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
+ }
+ return JSON.stringify(value);
+}
+
+function sha256(value) {
+ return crypto.createHash("sha256").update(stableJson(value)).digest("hex");
+}
+
+function toDate(value) {
+ const parsed = new Date(value || "");
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
+}
+
+function toNumber(value) {
+ const numeric = Number(value);
+ return Number.isFinite(numeric) ? numeric : null;
+}
+
+function severityRank(severity) {
+ return { critical: 4, high: 3, medium: 2, low: 1 }[severity] || 0;
+}
+
+function addFinding(findings, severity, code, message, refs, action) {
+ findings.push({
+ severity,
+ code,
+ message,
+ refs: asArray(refs),
+ action
+ });
+}
+
+function numericSources(values) {
+ return Object.entries(values || {})
+ .map(([source, value]) => ({ source, value: toNumber(value) }))
+ .filter((entry) => entry.value !== null);
+}
+
+function maxDelta(entries) {
+ if (entries.length < 2) {
+ return 0;
+ }
+ const values = entries.map((entry) => entry.value);
+ return Math.max(...values) - Math.min(...values);
+}
+
+function approxEqual(left, right, tolerance) {
+ const leftNumber = toNumber(left);
+ const rightNumber = toNumber(right);
+ if (leftNumber === null || rightNumber === null) {
+ return false;
+ }
+ return Math.abs(leftNumber - rightNumber) <= tolerance;
+}
+
+function ciEqual(left, right, tolerance) {
+ if (!left || !right) {
+ return false;
+ }
+ return approxEqual(left.low, right.low, tolerance) && approxEqual(left.high, right.high, tolerance);
+}
+
+function ciCrossesZero(interval) {
+ const low = toNumber(interval && interval.low);
+ const high = toNumber(interval && interval.high);
+ return low !== null && high !== null && low <= 0 && high >= 0;
+}
+
+function normalizePhrase(value) {
+ return String(value || "").toLowerCase();
+}
+
+function phraseSaysSignificant(phrase) {
+ const text = normalizePhrase(phrase);
+ if (phraseSaysNotSignificant(text)) {
+ return false;
+ }
+ return text.includes("significant") || text.includes("p <") || text.includes("p<");
+}
+
+function phraseSaysNotSignificant(phrase) {
+ const text = normalizePhrase(phrase);
+ return /\bnot\b.{0,30}\bsignificant\b/.test(text)
+ || text.includes("non-significant")
+ || text.includes("nonsignificant")
+ || text.includes("p >")
+ || text.includes("p>");
+}
+
+function daysBetween(laterValue, earlierValue) {
+ const later = toDate(laterValue);
+ const earlier = toDate(earlierValue);
+ if (!later || !earlier) {
+ return null;
+ }
+ return Math.floor((later.getTime() - earlier.getTime()) / (24 * 60 * 60 * 1000));
+}
+
+function evaluateAnalysis(analysis, policy, reviewDate, findings) {
+ const refs = [analysis.id || "analysis"];
+ const requiredActions = [];
+ const summary = {
+ id: analysis.id,
+ label: analysis.label || analysis.id,
+ sectionId: analysis.sectionId || "unknown-section",
+ status: "export_ready",
+ sampleSizeDelta: 0,
+ requiredActions
+ };
+
+ const sampleEntries = numericSources(analysis.sampleSizes);
+ if (sampleEntries.length < Number(policy.requiredSampleSizeSources || 2)) {
+ addFinding(
+ findings,
+ "high",
+ "SAMPLE_SIZE_SOURCE_MISSING",
+ `${analysis.id || "Analysis"} has fewer than ${policy.requiredSampleSizeSources || 2} sample-size sources.`,
+ refs,
+ "add_text_table_or_figure_sample_size_evidence"
+ );
+ requiredActions.push("add_text_table_or_figure_sample_size_evidence");
+ }
+
+ const sampleDelta = maxDelta(sampleEntries);
+ summary.sampleSizeDelta = sampleDelta;
+ if (sampleDelta > Number(policy.sampleSizeTolerance || 0)) {
+ addFinding(
+ findings,
+ "critical",
+ "SAMPLE_SIZE_DRIFT",
+ `${analysis.id || "Analysis"} reports inconsistent sample sizes across manuscript surfaces.`,
+ refs.concat(sampleEntries.map((entry) => entry.source)),
+ "reconcile_sample_size_text_table_and_figure"
+ );
+ requiredActions.push("reconcile_sample_size_text_table_and_figure");
+ }
+
+ const pValue = toNumber(analysis.pValue && analysis.pValue.reported);
+ if (pValue === null) {
+ addFinding(
+ findings,
+ "medium",
+ "P_VALUE_MISSING",
+ `${analysis.id || "Analysis"} has no structured p-value for export validation.`,
+ refs,
+ "attach_structured_p_value_or_mark_descriptive_only"
+ );
+ requiredActions.push("attach_structured_p_value_or_mark_descriptive_only");
+ } else {
+ const alpha = Number(policy.alpha || 0.05);
+ const phrase = analysis.pValue && analysis.pValue.wording;
+ if (pValue < alpha && phraseSaysNotSignificant(phrase)) {
+ addFinding(
+ findings,
+ "high",
+ "P_VALUE_WORDING_CONTRADICTION",
+ `${analysis.id || "Analysis"} has p=${pValue} but the manuscript wording says not significant.`,
+ refs,
+ "align_p_value_threshold_wording"
+ );
+ requiredActions.push("align_p_value_threshold_wording");
+ }
+ if (pValue >= alpha && phraseSaysSignificant(phrase)) {
+ addFinding(
+ findings,
+ "high",
+ "P_VALUE_WORDING_CONTRADICTION",
+ `${analysis.id || "Analysis"} has p=${pValue} but the manuscript wording says significant.`,
+ refs,
+ "align_p_value_threshold_wording"
+ );
+ requiredActions.push("align_p_value_threshold_wording");
+ }
+ if (analysis.pValue && toNumber(analysis.pValue.table) !== null && !approxEqual(pValue, analysis.pValue.table, Number(policy.pValueTolerance || 0.0005))) {
+ addFinding(
+ findings,
+ "high",
+ "P_VALUE_TABLE_MISMATCH",
+ `${analysis.id || "Analysis"} has different text and table p-values.`,
+ refs,
+ "sync_text_and_table_p_values"
+ );
+ requiredActions.push("sync_text_and_table_p_values");
+ }
+ }
+
+ if (analysis.effectSize) {
+ const tolerance = Number(policy.effectSizeTolerance || 0.01);
+ if (toNumber(analysis.effectSize.text) === null || toNumber(analysis.effectSize.table) === null) {
+ addFinding(
+ findings,
+ "medium",
+ "EFFECT_SIZE_SOURCE_MISSING",
+ `${analysis.id || "Analysis"} lacks a paired text/table effect-size value.`,
+ refs,
+ "add_effect_size_to_text_and_table"
+ );
+ requiredActions.push("add_effect_size_to_text_and_table");
+ } else if (!approxEqual(analysis.effectSize.text, analysis.effectSize.table, tolerance)) {
+ addFinding(
+ findings,
+ "high",
+ "EFFECT_SIZE_MISMATCH",
+ `${analysis.id || "Analysis"} has inconsistent effect sizes in text and table.`,
+ refs,
+ "reconcile_effect_size_text_and_table"
+ );
+ requiredActions.push("reconcile_effect_size_text_and_table");
+ }
+ }
+
+ if (analysis.confidenceInterval) {
+ const tolerance = Number(policy.confidenceIntervalTolerance || 0.01);
+ if (!ciEqual(analysis.confidenceInterval.text, analysis.confidenceInterval.table, tolerance)) {
+ addFinding(
+ findings,
+ "high",
+ "CONFIDENCE_INTERVAL_MISMATCH",
+ `${analysis.id || "Analysis"} has inconsistent confidence intervals in text and table.`,
+ refs,
+ "reconcile_confidence_interval_text_and_table"
+ );
+ requiredActions.push("reconcile_confidence_interval_text_and_table");
+ }
+ if (analysis.pValue && toNumber(analysis.pValue.reported) !== null) {
+ const crossesZero = ciCrossesZero(analysis.confidenceInterval.text);
+ const saysSignificant = phraseSaysSignificant(analysis.pValue.wording);
+ if (crossesZero && saysSignificant) {
+ addFinding(
+ findings,
+ "medium",
+ "CI_SIGNIFICANCE_WORDING_CONFLICT",
+ `${analysis.id || "Analysis"} confidence interval crosses zero while wording says significant.`,
+ refs,
+ "review_ci_and_statistical_significance_wording"
+ );
+ requiredActions.push("review_ci_and_statistical_significance_wording");
+ }
+ }
+ }
+
+ if (analysis.figureStatistic) {
+ if (!analysis.figureStatistic.figureId || toNumber(analysis.figureStatistic.value) === null) {
+ addFinding(
+ findings,
+ "medium",
+ "FIGURE_STATISTIC_INCOMPLETE",
+ `${analysis.id || "Analysis"} has incomplete figure statistic metadata.`,
+ refs,
+ "attach_figure_statistic_value_and_anchor"
+ );
+ requiredActions.push("attach_figure_statistic_value_and_anchor");
+ } else if (analysis.effectSize && toNumber(analysis.effectSize.text) !== null && !approxEqual(analysis.figureStatistic.value, analysis.effectSize.text, Number(policy.figureStatisticTolerance || 0.02))) {
+ addFinding(
+ findings,
+ "medium",
+ "FIGURE_TEXT_STATISTIC_MISMATCH",
+ `${analysis.id || "Analysis"} figure statistic differs from the manuscript effect size.`,
+ refs.concat([analysis.figureStatistic.figureId]),
+ "sync_figure_statistic_with_text_and_table"
+ );
+ requiredActions.push("sync_figure_statistic_with_text_and_table");
+ }
+ }
+
+ const locked = analysis.lockedSection || {};
+ if (locked.locked === true) {
+ const editAge = daysBetween(locked.approvedAt, locked.lastStatisticEditAt);
+ if (!locked.approvedAt || editAge === null || editAge < 0) {
+ addFinding(
+ findings,
+ "critical",
+ "LOCKED_SECTION_STAT_EDIT_UNAPPROVED",
+ `${analysis.id || "Analysis"} changed statistics inside a locked section without approval.`,
+ refs.concat([locked.sectionId || analysis.sectionId || "section"]),
+ "approve_or_revert_locked_section_statistical_edit"
+ );
+ requiredActions.push("approve_or_revert_locked_section_statistical_edit");
+ }
+ }
+
+ for (const comment of asArray(analysis.reviewerComments)) {
+ if (comment.blocking === true && !comment.resolvedAt) {
+ addFinding(
+ findings,
+ "high",
+ "BLOCKING_STAT_REVIEW_COMMENT_OPEN",
+ `${analysis.id || "Analysis"} has unresolved blocking statistical review comment ${comment.id || "unknown"}.`,
+ refs.concat([comment.id || "comment"]),
+ "resolve_blocking_statistical_review_comment"
+ );
+ requiredActions.push("resolve_blocking_statistical_review_comment");
+ }
+ }
+
+ if (requiredActions.length > 0) {
+ summary.status = requiredActions.some((action) => action.includes("reconcile") || action.includes("locked_section"))
+ ? "hold_collaborative_export"
+ : "needs_statistical_review";
+ }
+
+ return summary;
+}
+
+function evaluateStatisticalReporting(packet) {
+ const findings = [];
+ const reviewDate = packet.reviewDate || new Date().toISOString().slice(0, 10);
+ const policy = {
+ alpha: 0.05,
+ requiredSampleSizeSources: 2,
+ sampleSizeTolerance: 0,
+ pValueTolerance: 0.0005,
+ effectSizeTolerance: 0.01,
+ confidenceIntervalTolerance: 0.01,
+ figureStatisticTolerance: 0.02,
+ ...(packet.policy || {})
+ };
+ const analyses = asArray(packet.analyses).map((analysis) => evaluateAnalysis(analysis, policy, reviewDate, findings));
+ const criticalFindings = findings.filter((finding) => finding.severity === "critical").length;
+ const highOrCriticalFindings = findings.filter((finding) => severityRank(finding.severity) >= severityRank("high")).length;
+ const heldAnalyses = analyses.filter((analysis) => analysis.status === "hold_collaborative_export").length;
+ let decision = "release_collaborative_export";
+
+ if (criticalFindings > 0 || heldAnalyses > 0) {
+ decision = "hold_collaborative_export";
+ } else if (highOrCriticalFindings > 0) {
+ decision = "route_to_statistical_review";
+ }
+
+ const auditSubject = {
+ manuscriptId: packet.manuscriptId,
+ reviewDate,
+ policy,
+ analyses,
+ findingCodes: findings.map((finding) => finding.code).sort()
+ };
+
+ return {
+ summary: {
+ decision,
+ manuscriptId: packet.manuscriptId || "manuscript",
+ analysesReviewed: analyses.length,
+ heldAnalyses,
+ findingCount: findings.length,
+ criticalFindings,
+ highOrCriticalFindings,
+ auditDigest: `sha256:${sha256(auditSubject)}`
+ },
+ analyses,
+ findings: findings.sort((a, b) => severityRank(b.severity) - severityRank(a.severity) || a.code.localeCompare(b.code))
+ };
+}
+
+module.exports = {
+ evaluateStatisticalReporting,
+ sha256
+};
diff --git a/collaborative-statistical-reporting-guard/make-demo-video.js b/collaborative-statistical-reporting-guard/make-demo-video.js
new file mode 100644
index 00000000..c0752f22
--- /dev/null
+++ b/collaborative-statistical-reporting-guard/make-demo-video.js
@@ -0,0 +1,97 @@
+const fs = require("node:fs");
+const path = require("node:path");
+const { spawnSync } = require("node:child_process");
+const { evaluateStatisticalReporting } = require("./index");
+const { cleanPacket, riskyPacket } = require("./sample-data");
+
+const reportsDir = path.join(__dirname, "reports");
+const framesDir = path.join(reportsDir, "frames");
+fs.mkdirSync(framesDir, { recursive: true });
+
+const clean = evaluateStatisticalReporting(cleanPacket);
+const risky = evaluateStatisticalReporting(riskyPacket);
+const width = 960;
+const height = 540;
+const frames = 72;
+const fps = 18;
+
+function setPixel(buffer, x, y, r, g, b) {
+ if (x < 0 || y < 0 || x >= width || y >= height) {
+ return;
+ }
+ const offset = (y * width + x) * 3;
+ buffer[offset] = r;
+ buffer[offset + 1] = g;
+ buffer[offset + 2] = b;
+}
+
+function fillRect(buffer, x, y, w, h, r, g, b) {
+ for (let row = y; row < y + h; row += 1) {
+ for (let col = x; col < x + w; col += 1) {
+ setPixel(buffer, col, row, r, g, b);
+ }
+ }
+}
+
+function drawFindingBars(buffer, x, baseline, count, color) {
+ for (let index = 0; index < count; index += 1) {
+ const barHeight = 28 + (index % 6) * 16;
+ fillRect(buffer, x + index * 24, baseline - barHeight, 16, barHeight, color[0], color[1], color[2]);
+ }
+}
+
+function writeFrame(index, progress) {
+ const buffer = Buffer.alloc(width * height * 3, 248);
+ fillRect(buffer, 0, 0, width, height, 248, 250, 252);
+ fillRect(buffer, 48, 44, 864, 452, 255, 255, 255);
+ fillRect(buffer, 48, 44, 864, 8, 15, 23, 42);
+
+ const cleanWidth = Math.floor(300 * Math.min(1, progress * 1.5) * Math.max(0.04, clean.summary.analysesReviewed / 4));
+ const riskyWidth = Math.floor(300 * Math.max(0, (progress - 0.1) * 1.4) * Math.min(1, risky.summary.findingCount / 16));
+ const heldWidth = Math.floor(300 * Math.max(0, (progress - 0.2) * 1.3) * Math.min(1, risky.summary.heldAnalyses / 3));
+
+ fillRect(buffer, 96, 126, 300, 42, 226, 232, 240);
+ fillRect(buffer, 96, 126, cleanWidth, 42, 16, 185, 129);
+ fillRect(buffer, 96, 222, 300, 42, 226, 232, 240);
+ fillRect(buffer, 96, 222, riskyWidth, 42, 239, 68, 68);
+ fillRect(buffer, 96, 318, 300, 42, 226, 232, 240);
+ fillRect(buffer, 96, 318, heldWidth, 42, 245, 158, 11);
+
+ for (let i = 0; i < risky.summary.analysesReviewed; i += 1) {
+ fillRect(buffer, 112 + i * 76, 404, 52, 52, 99, 102, 241);
+ fillRect(buffer, 122 + i * 76, 416, 32, 8, 255, 255, 255);
+ fillRect(buffer, 122 + i * 76, 434, 32, 8, 255, 255, 255);
+ }
+
+ drawFindingBars(buffer, 536, 408, Math.min(12, risky.summary.findingCount), [220, 38, 38]);
+ fillRect(buffer, 536, 436, Math.floor(310 * progress), 14, 37, 99, 235);
+
+ const header = Buffer.from(`P6\n${width} ${height}\n255\n`, "ascii");
+ fs.writeFileSync(path.join(framesDir, `frame-${String(index).padStart(3, "0")}.ppm`), Buffer.concat([header, buffer]));
+}
+
+for (let index = 0; index < frames; index += 1) {
+ writeFrame(index, index / (frames - 1));
+}
+
+const output = path.join(reportsDir, "demo.mp4");
+const result = spawnSync(process.env.FFMPEG_PATH || "ffmpeg", [
+ "-y",
+ "-framerate",
+ String(fps),
+ "-i",
+ path.join(framesDir, "frame-%03d.ppm"),
+ "-pix_fmt",
+ "yuv420p",
+ "-movflags",
+ "+faststart",
+ output
+], { stdio: "inherit" });
+
+fs.rmSync(framesDir, { recursive: true, force: true });
+
+if (result.status !== 0) {
+ process.exit(result.status || 1);
+}
+
+console.log(`Wrote ${output}`);
diff --git a/collaborative-statistical-reporting-guard/package.json b/collaborative-statistical-reporting-guard/package.json
new file mode 100644
index 00000000..6fdef557
--- /dev/null
+++ b/collaborative-statistical-reporting-guard/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "collaborative-statistical-reporting-guard",
+ "version": "1.0.0",
+ "description": "Dependency-free statistical reporting consistency guard for collaborative scientific manuscripts.",
+ "main": "index.js",
+ "scripts": {
+ "check": "node test.js",
+ "test": "node test.js",
+ "demo": "node demo.js",
+ "make-demo-video": "node make-demo-video.js",
+ "verify-video": "node verify-video.js"
+ },
+ "license": "MIT",
+ "private": true
+}
diff --git a/collaborative-statistical-reporting-guard/reports/clean-statistical-reporting-report.json b/collaborative-statistical-reporting-guard/reports/clean-statistical-reporting-report.json
new file mode 100644
index 00000000..f8240003
--- /dev/null
+++ b/collaborative-statistical-reporting-guard/reports/clean-statistical-reporting-report.json
@@ -0,0 +1,31 @@
+{
+ "summary": {
+ "decision": "release_collaborative_export",
+ "manuscriptId": "MS-COLLAB-STATS-CLEAN",
+ "analysesReviewed": 2,
+ "heldAnalyses": 0,
+ "findingCount": 0,
+ "criticalFindings": 0,
+ "highOrCriticalFindings": 0,
+ "auditDigest": "sha256:8bee866a9c5481439d7e15b97711f780b61d4fba3692b5727dd960b1192ded00"
+ },
+ "analyses": [
+ {
+ "id": "analysis-primary-response",
+ "label": "Primary response difference",
+ "sectionId": "results-primary",
+ "status": "export_ready",
+ "sampleSizeDelta": 0,
+ "requiredActions": []
+ },
+ {
+ "id": "analysis-secondary-response",
+ "label": "Secondary response difference",
+ "sectionId": "results-secondary",
+ "status": "export_ready",
+ "sampleSizeDelta": 0,
+ "requiredActions": []
+ }
+ ],
+ "findings": []
+}
diff --git a/collaborative-statistical-reporting-guard/reports/demo.mp4 b/collaborative-statistical-reporting-guard/reports/demo.mp4
new file mode 100644
index 00000000..6a207b4e
Binary files /dev/null and b/collaborative-statistical-reporting-guard/reports/demo.mp4 differ
diff --git a/collaborative-statistical-reporting-guard/reports/risky-statistical-reporting-handoff.md b/collaborative-statistical-reporting-guard/reports/risky-statistical-reporting-handoff.md
new file mode 100644
index 00000000..49172b59
--- /dev/null
+++ b/collaborative-statistical-reporting-guard/reports/risky-statistical-reporting-handoff.md
@@ -0,0 +1,32 @@
+# Collaborative Statistical Reporting Handoff
+
+Decision: hold_collaborative_export
+Analyses reviewed: 3
+Held analyses: 2
+Audit digest: sha256:6043784e7d3140a56405bd2bc05ea1cb61c66dac75ea83ca824907f266668a62
+
+## Priority Findings
+
+| Severity | Code | Remediation |
+| --- | --- | --- |
+| critical | LOCKED_SECTION_STAT_EDIT_UNAPPROVED | approve_or_revert_locked_section_statistical_edit |
+| critical | SAMPLE_SIZE_DRIFT | reconcile_sample_size_text_table_and_figure |
+| high | BLOCKING_STAT_REVIEW_COMMENT_OPEN | resolve_blocking_statistical_review_comment |
+| high | BLOCKING_STAT_REVIEW_COMMENT_OPEN | resolve_blocking_statistical_review_comment |
+| high | CONFIDENCE_INTERVAL_MISMATCH | reconcile_confidence_interval_text_and_table |
+| high | CONFIDENCE_INTERVAL_MISMATCH | reconcile_confidence_interval_text_and_table |
+| high | EFFECT_SIZE_MISMATCH | reconcile_effect_size_text_and_table |
+| high | P_VALUE_TABLE_MISMATCH | sync_text_and_table_p_values |
+| high | P_VALUE_WORDING_CONTRADICTION | align_p_value_threshold_wording |
+| high | P_VALUE_WORDING_CONTRADICTION | align_p_value_threshold_wording |
+| high | SAMPLE_SIZE_SOURCE_MISSING | add_text_table_or_figure_sample_size_evidence |
+| medium | CI_SIGNIFICANCE_WORDING_CONFLICT | review_ci_and_statistical_significance_wording |
+
+## Analysis Actions
+
+| Analysis | Status | Actions |
+| --- | --- | --- |
+| analysis-survival-primary | hold_collaborative_export | reconcile_sample_size_text_table_and_figure, align_p_value_threshold_wording, sync_text_and_table_p_values, reconcile_effect_size_text_and_table, reconcile_confidence_interval_text_and_table, sync_figure_statistic_with_text_and_table, approve_or_revert_locked_section_statistical_edit, resolve_blocking_statistical_review_comment |
+| analysis-biomarker-subgroup | needs_statistical_review | add_text_table_or_figure_sample_size_evidence, align_p_value_threshold_wording, add_effect_size_to_text_and_table, review_ci_and_statistical_significance_wording, attach_figure_statistic_value_and_anchor |
+| analysis-exploratory-dose | hold_collaborative_export | attach_structured_p_value_or_mark_descriptive_only, reconcile_confidence_interval_text_and_table, resolve_blocking_statistical_review_comment |
+
diff --git a/collaborative-statistical-reporting-guard/reports/risky-statistical-reporting-report.json b/collaborative-statistical-reporting-guard/reports/risky-statistical-reporting-report.json
new file mode 100644
index 00000000..c0eebb96
--- /dev/null
+++ b/collaborative-statistical-reporting-guard/reports/risky-statistical-reporting-report.json
@@ -0,0 +1,210 @@
+{
+ "summary": {
+ "decision": "hold_collaborative_export",
+ "manuscriptId": "MS-COLLAB-STATS-RISK",
+ "analysesReviewed": 3,
+ "heldAnalyses": 2,
+ "findingCount": 16,
+ "criticalFindings": 2,
+ "highOrCriticalFindings": 11,
+ "auditDigest": "sha256:6043784e7d3140a56405bd2bc05ea1cb61c66dac75ea83ca824907f266668a62"
+ },
+ "analyses": [
+ {
+ "id": "analysis-survival-primary",
+ "label": "Survival model primary endpoint",
+ "sectionId": "locked-results",
+ "status": "hold_collaborative_export",
+ "sampleSizeDelta": 5,
+ "requiredActions": [
+ "reconcile_sample_size_text_table_and_figure",
+ "align_p_value_threshold_wording",
+ "sync_text_and_table_p_values",
+ "reconcile_effect_size_text_and_table",
+ "reconcile_confidence_interval_text_and_table",
+ "sync_figure_statistic_with_text_and_table",
+ "approve_or_revert_locked_section_statistical_edit",
+ "resolve_blocking_statistical_review_comment"
+ ]
+ },
+ {
+ "id": "analysis-biomarker-subgroup",
+ "label": "Biomarker subgroup model",
+ "sectionId": "results-subgroups",
+ "status": "needs_statistical_review",
+ "sampleSizeDelta": 0,
+ "requiredActions": [
+ "add_text_table_or_figure_sample_size_evidence",
+ "align_p_value_threshold_wording",
+ "add_effect_size_to_text_and_table",
+ "review_ci_and_statistical_significance_wording",
+ "attach_figure_statistic_value_and_anchor"
+ ]
+ },
+ {
+ "id": "analysis-exploratory-dose",
+ "label": "Exploratory dose trend",
+ "sectionId": "results-exploratory",
+ "status": "hold_collaborative_export",
+ "sampleSizeDelta": 0,
+ "requiredActions": [
+ "attach_structured_p_value_or_mark_descriptive_only",
+ "reconcile_confidence_interval_text_and_table",
+ "resolve_blocking_statistical_review_comment"
+ ]
+ }
+ ],
+ "findings": [
+ {
+ "severity": "critical",
+ "code": "LOCKED_SECTION_STAT_EDIT_UNAPPROVED",
+ "message": "analysis-survival-primary changed statistics inside a locked section without approval.",
+ "refs": [
+ "analysis-survival-primary",
+ "locked-results"
+ ],
+ "action": "approve_or_revert_locked_section_statistical_edit"
+ },
+ {
+ "severity": "critical",
+ "code": "SAMPLE_SIZE_DRIFT",
+ "message": "analysis-survival-primary reports inconsistent sample sizes across manuscript surfaces.",
+ "refs": [
+ "analysis-survival-primary",
+ "text",
+ "table",
+ "figure"
+ ],
+ "action": "reconcile_sample_size_text_table_and_figure"
+ },
+ {
+ "severity": "high",
+ "code": "BLOCKING_STAT_REVIEW_COMMENT_OPEN",
+ "message": "analysis-survival-primary has unresolved blocking statistical review comment stat-cmt-9.",
+ "refs": [
+ "analysis-survival-primary",
+ "stat-cmt-9"
+ ],
+ "action": "resolve_blocking_statistical_review_comment"
+ },
+ {
+ "severity": "high",
+ "code": "BLOCKING_STAT_REVIEW_COMMENT_OPEN",
+ "message": "analysis-exploratory-dose has unresolved blocking statistical review comment stat-cmt-13.",
+ "refs": [
+ "analysis-exploratory-dose",
+ "stat-cmt-13"
+ ],
+ "action": "resolve_blocking_statistical_review_comment"
+ },
+ {
+ "severity": "high",
+ "code": "CONFIDENCE_INTERVAL_MISMATCH",
+ "message": "analysis-survival-primary has inconsistent confidence intervals in text and table.",
+ "refs": [
+ "analysis-survival-primary"
+ ],
+ "action": "reconcile_confidence_interval_text_and_table"
+ },
+ {
+ "severity": "high",
+ "code": "CONFIDENCE_INTERVAL_MISMATCH",
+ "message": "analysis-exploratory-dose has inconsistent confidence intervals in text and table.",
+ "refs": [
+ "analysis-exploratory-dose"
+ ],
+ "action": "reconcile_confidence_interval_text_and_table"
+ },
+ {
+ "severity": "high",
+ "code": "EFFECT_SIZE_MISMATCH",
+ "message": "analysis-survival-primary has inconsistent effect sizes in text and table.",
+ "refs": [
+ "analysis-survival-primary"
+ ],
+ "action": "reconcile_effect_size_text_and_table"
+ },
+ {
+ "severity": "high",
+ "code": "P_VALUE_TABLE_MISMATCH",
+ "message": "analysis-survival-primary has different text and table p-values.",
+ "refs": [
+ "analysis-survival-primary"
+ ],
+ "action": "sync_text_and_table_p_values"
+ },
+ {
+ "severity": "high",
+ "code": "P_VALUE_WORDING_CONTRADICTION",
+ "message": "analysis-survival-primary has p=0.041 but the manuscript wording says not significant.",
+ "refs": [
+ "analysis-survival-primary"
+ ],
+ "action": "align_p_value_threshold_wording"
+ },
+ {
+ "severity": "high",
+ "code": "P_VALUE_WORDING_CONTRADICTION",
+ "message": "analysis-biomarker-subgroup has p=0.12 but the manuscript wording says significant.",
+ "refs": [
+ "analysis-biomarker-subgroup"
+ ],
+ "action": "align_p_value_threshold_wording"
+ },
+ {
+ "severity": "high",
+ "code": "SAMPLE_SIZE_SOURCE_MISSING",
+ "message": "analysis-biomarker-subgroup has fewer than 2 sample-size sources.",
+ "refs": [
+ "analysis-biomarker-subgroup"
+ ],
+ "action": "add_text_table_or_figure_sample_size_evidence"
+ },
+ {
+ "severity": "medium",
+ "code": "CI_SIGNIFICANCE_WORDING_CONFLICT",
+ "message": "analysis-biomarker-subgroup confidence interval crosses zero while wording says significant.",
+ "refs": [
+ "analysis-biomarker-subgroup"
+ ],
+ "action": "review_ci_and_statistical_significance_wording"
+ },
+ {
+ "severity": "medium",
+ "code": "EFFECT_SIZE_SOURCE_MISSING",
+ "message": "analysis-biomarker-subgroup lacks a paired text/table effect-size value.",
+ "refs": [
+ "analysis-biomarker-subgroup"
+ ],
+ "action": "add_effect_size_to_text_and_table"
+ },
+ {
+ "severity": "medium",
+ "code": "FIGURE_STATISTIC_INCOMPLETE",
+ "message": "analysis-biomarker-subgroup has incomplete figure statistic metadata.",
+ "refs": [
+ "analysis-biomarker-subgroup"
+ ],
+ "action": "attach_figure_statistic_value_and_anchor"
+ },
+ {
+ "severity": "medium",
+ "code": "FIGURE_TEXT_STATISTIC_MISMATCH",
+ "message": "analysis-survival-primary figure statistic differs from the manuscript effect size.",
+ "refs": [
+ "analysis-survival-primary",
+ "fig-4"
+ ],
+ "action": "sync_figure_statistic_with_text_and_table"
+ },
+ {
+ "severity": "medium",
+ "code": "P_VALUE_MISSING",
+ "message": "analysis-exploratory-dose has no structured p-value for export validation.",
+ "refs": [
+ "analysis-exploratory-dose"
+ ],
+ "action": "attach_structured_p_value_or_mark_descriptive_only"
+ }
+ ]
+}
diff --git a/collaborative-statistical-reporting-guard/reports/statistical-reporting-dashboard.svg b/collaborative-statistical-reporting-guard/reports/statistical-reporting-dashboard.svg
new file mode 100644
index 00000000..373aa4e4
--- /dev/null
+++ b/collaborative-statistical-reporting-guard/reports/statistical-reporting-dashboard.svg
@@ -0,0 +1,30 @@
+
diff --git a/collaborative-statistical-reporting-guard/sample-data.js b/collaborative-statistical-reporting-guard/sample-data.js
new file mode 100644
index 00000000..cdc051fe
--- /dev/null
+++ b/collaborative-statistical-reporting-guard/sample-data.js
@@ -0,0 +1,191 @@
+const cleanPacket = {
+ manuscriptId: "MS-COLLAB-STATS-CLEAN",
+ reviewDate: "2026-06-01",
+ policy: {
+ alpha: 0.05,
+ sampleSizeTolerance: 0
+ },
+ analyses: [
+ {
+ id: "analysis-primary-response",
+ label: "Primary response difference",
+ sectionId: "results-primary",
+ sampleSizes: {
+ text: 128,
+ table: 128,
+ figure: 128
+ },
+ pValue: {
+ reported: 0.031,
+ table: 0.031,
+ wording: "statistically significant at alpha 0.05"
+ },
+ effectSize: {
+ text: 0.42,
+ table: 0.42
+ },
+ confidenceInterval: {
+ text: { low: 0.08, high: 0.76 },
+ table: { low: 0.08, high: 0.76 }
+ },
+ figureStatistic: {
+ figureId: "fig-2",
+ value: 0.42
+ },
+ lockedSection: {
+ sectionId: "results-primary",
+ locked: true,
+ lastStatisticEditAt: "2026-05-28T10:00:00Z",
+ approvedAt: "2026-05-29T09:00:00Z"
+ },
+ reviewerComments: [
+ {
+ id: "stat-cmt-1",
+ blocking: true,
+ resolvedAt: "2026-05-30T12:00:00Z"
+ }
+ ]
+ },
+ {
+ id: "analysis-secondary-response",
+ label: "Secondary response difference",
+ sectionId: "results-secondary",
+ sampleSizes: {
+ text: 96,
+ table: 96
+ },
+ pValue: {
+ reported: 0.18,
+ table: 0.18,
+ wording: "not statistically significant"
+ },
+ effectSize: {
+ text: 0.11,
+ table: 0.11
+ },
+ confidenceInterval: {
+ text: { low: -0.07, high: 0.29 },
+ table: { low: -0.07, high: 0.29 }
+ },
+ figureStatistic: {
+ figureId: "fig-s1",
+ value: 0.11
+ },
+ lockedSection: {
+ sectionId: "results-secondary",
+ locked: false
+ },
+ reviewerComments: []
+ }
+ ]
+};
+
+const riskyPacket = {
+ manuscriptId: "MS-COLLAB-STATS-RISK",
+ reviewDate: "2026-06-01",
+ policy: {
+ alpha: 0.05,
+ sampleSizeTolerance: 0
+ },
+ analyses: [
+ {
+ id: "analysis-survival-primary",
+ label: "Survival model primary endpoint",
+ sectionId: "locked-results",
+ sampleSizes: {
+ text: 214,
+ table: 209,
+ figure: 214
+ },
+ pValue: {
+ reported: 0.041,
+ table: 0.064,
+ wording: "not significant after adjustment"
+ },
+ effectSize: {
+ text: 0.38,
+ table: 0.51
+ },
+ confidenceInterval: {
+ text: { low: 0.04, high: 0.72 },
+ table: { low: -0.02, high: 0.78 }
+ },
+ figureStatistic: {
+ figureId: "fig-4",
+ value: 0.6
+ },
+ lockedSection: {
+ sectionId: "locked-results",
+ locked: true,
+ lastStatisticEditAt: "2026-05-31T19:00:00Z",
+ approvedAt: ""
+ },
+ reviewerComments: [
+ {
+ id: "stat-cmt-9",
+ blocking: true,
+ resolvedAt: ""
+ }
+ ]
+ },
+ {
+ id: "analysis-biomarker-subgroup",
+ label: "Biomarker subgroup model",
+ sectionId: "results-subgroups",
+ sampleSizes: {
+ text: 48
+ },
+ pValue: {
+ reported: 0.12,
+ table: 0.12,
+ wording: "statistically significant subgroup effect"
+ },
+ effectSize: {
+ text: 0.27
+ },
+ confidenceInterval: {
+ text: { low: -0.18, high: 0.61 },
+ table: { low: -0.18, high: 0.61 }
+ },
+ figureStatistic: {
+ figureId: "",
+ value: null
+ },
+ lockedSection: {
+ sectionId: "results-subgroups",
+ locked: false
+ },
+ reviewerComments: []
+ },
+ {
+ id: "analysis-exploratory-dose",
+ label: "Exploratory dose trend",
+ sectionId: "results-exploratory",
+ sampleSizes: {
+ table: 72,
+ figure: 72
+ },
+ pValue: {},
+ confidenceInterval: {
+ text: { low: -0.01, high: 0.32 },
+ table: { low: -0.04, high: 0.35 }
+ },
+ lockedSection: {
+ sectionId: "results-exploratory",
+ locked: false
+ },
+ reviewerComments: [
+ {
+ id: "stat-cmt-13",
+ blocking: true,
+ resolvedAt: ""
+ }
+ ]
+ }
+ ]
+};
+
+module.exports = {
+ cleanPacket,
+ riskyPacket
+};
diff --git a/collaborative-statistical-reporting-guard/test.js b/collaborative-statistical-reporting-guard/test.js
new file mode 100644
index 00000000..406d70eb
--- /dev/null
+++ b/collaborative-statistical-reporting-guard/test.js
@@ -0,0 +1,39 @@
+const assert = require("node:assert/strict");
+const { evaluateStatisticalReporting, sha256 } = require("./index");
+const { cleanPacket, riskyPacket } = require("./sample-data");
+
+const clean = evaluateStatisticalReporting(cleanPacket);
+assert.equal(clean.summary.decision, "release_collaborative_export");
+assert.equal(clean.summary.findingCount, 0);
+assert.equal(clean.summary.analysesReviewed, 2);
+assert.equal(clean.summary.heldAnalyses, 0);
+assert.ok(clean.summary.auditDigest.startsWith("sha256:"));
+
+const risky = evaluateStatisticalReporting(riskyPacket);
+assert.equal(risky.summary.decision, "hold_collaborative_export");
+assert.equal(risky.summary.analysesReviewed, 3);
+assert.equal(risky.summary.heldAnalyses, 2);
+assert.ok(risky.summary.findingCount >= 13);
+assert.ok(risky.summary.criticalFindings >= 2);
+assert.ok(risky.summary.highOrCriticalFindings >= 8);
+
+const findingCodes = new Set(risky.findings.map((finding) => finding.code));
+assert.ok(findingCodes.has("SAMPLE_SIZE_DRIFT"));
+assert.ok(findingCodes.has("SAMPLE_SIZE_SOURCE_MISSING"));
+assert.ok(findingCodes.has("P_VALUE_WORDING_CONTRADICTION"));
+assert.ok(findingCodes.has("P_VALUE_TABLE_MISMATCH"));
+assert.ok(findingCodes.has("EFFECT_SIZE_MISMATCH"));
+assert.ok(findingCodes.has("EFFECT_SIZE_SOURCE_MISSING"));
+assert.ok(findingCodes.has("CONFIDENCE_INTERVAL_MISMATCH"));
+assert.ok(findingCodes.has("FIGURE_TEXT_STATISTIC_MISMATCH"));
+assert.ok(findingCodes.has("FIGURE_STATISTIC_INCOMPLETE"));
+assert.ok(findingCodes.has("LOCKED_SECTION_STAT_EDIT_UNAPPROVED"));
+assert.ok(findingCodes.has("BLOCKING_STAT_REVIEW_COMMENT_OPEN"));
+assert.ok(findingCodes.has("P_VALUE_MISSING"));
+
+const firstDigest = evaluateStatisticalReporting(riskyPacket).summary.auditDigest;
+const secondDigest = evaluateStatisticalReporting(riskyPacket).summary.auditDigest;
+assert.equal(firstDigest, secondDigest);
+assert.equal(sha256({ b: 2, a: 1 }), sha256({ a: 1, b: 2 }));
+
+console.log("collaborative statistical reporting guard tests passed");
diff --git a/collaborative-statistical-reporting-guard/verify-video.js b/collaborative-statistical-reporting-guard/verify-video.js
new file mode 100644
index 00000000..39af983c
--- /dev/null
+++ b/collaborative-statistical-reporting-guard/verify-video.js
@@ -0,0 +1,37 @@
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const path = require("node:path");
+const { spawnSync } = require("node:child_process");
+
+const videoPath = path.join(__dirname, "reports", "demo.mp4");
+assert.ok(fs.existsSync(videoPath), "reports/demo.mp4 must exist");
+assert.ok(fs.statSync(videoPath).size > 5000, "reports/demo.mp4 should not be empty");
+
+const probe = spawnSync(process.env.FFPROBE_PATH || "ffprobe", [
+ "-v",
+ "error",
+ "-select_streams",
+ "v:0",
+ "-show_entries",
+ "stream=codec_name,width,height,r_frame_rate:format=duration",
+ "-of",
+ "json",
+ videoPath
+], { encoding: "utf8" });
+
+if (probe.status !== 0) {
+ process.stderr.write(probe.stderr || "ffprobe failed\n");
+ process.exit(probe.status || 1);
+}
+
+const metadata = JSON.parse(probe.stdout);
+const stream = metadata.streams && metadata.streams[0];
+assert.equal(stream.codec_name, "h264");
+assert.equal(stream.width, 960);
+assert.equal(stream.height, 540);
+assert.equal(stream.r_frame_rate, "18/1");
+
+const duration = Number(metadata.format && metadata.format.duration);
+assert.ok(duration >= 3.9 && duration <= 4.2, `unexpected duration ${duration}`);
+
+console.log(`demo.mp4 verified: ${stream.codec_name}, ${stream.width}x${stream.height}, ${duration.toFixed(3)}s, ${stream.r_frame_rate}`);