From 2ece20b2cf14bdce7a5068f4f4b17cf6380cc3bc Mon Sep 17 00:00:00 2001 From: AlonePenguin <187998801+AlonePenguin@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:58:27 -0400 Subject: [PATCH] Add collaborative statistical reporting guard --- .../README.md | 43 ++ .../demo.js | 106 +++++ .../index.js | 369 ++++++++++++++++++ .../make-demo-video.js | 97 +++++ .../package.json | 15 + .../clean-statistical-reporting-report.json | 31 ++ .../reports/demo.mp4 | Bin 0 -> 9408 bytes .../risky-statistical-reporting-handoff.md | 32 ++ .../risky-statistical-reporting-report.json | 210 ++++++++++ .../statistical-reporting-dashboard.svg | 30 ++ .../sample-data.js | 191 +++++++++ .../test.js | 39 ++ .../verify-video.js | 37 ++ 13 files changed, 1200 insertions(+) create mode 100644 collaborative-statistical-reporting-guard/README.md create mode 100644 collaborative-statistical-reporting-guard/demo.js create mode 100644 collaborative-statistical-reporting-guard/index.js create mode 100644 collaborative-statistical-reporting-guard/make-demo-video.js create mode 100644 collaborative-statistical-reporting-guard/package.json create mode 100644 collaborative-statistical-reporting-guard/reports/clean-statistical-reporting-report.json create mode 100644 collaborative-statistical-reporting-guard/reports/demo.mp4 create mode 100644 collaborative-statistical-reporting-guard/reports/risky-statistical-reporting-handoff.md create mode 100644 collaborative-statistical-reporting-guard/reports/risky-statistical-reporting-report.json create mode 100644 collaborative-statistical-reporting-guard/reports/statistical-reporting-dashboard.svg create mode 100644 collaborative-statistical-reporting-guard/sample-data.js create mode 100644 collaborative-statistical-reporting-guard/test.js create mode 100644 collaborative-statistical-reporting-guard/verify-video.js 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 = ` + + + + Collaborative statistical reporting guard + Checks text, tables, figures, locked sections, and review comments before export. + Clean manuscript findings + + + ${cleanReport.summary.findingCount} findings + Risky critical findings + + + ${riskyReport.summary.criticalFindings} critical + Risky total findings + + + ${riskyReport.summary.findingCount} total + + Top blockers + ${rows} + Decision: ${escapeXml(riskyReport.summary.decision)} | ${riskyReport.summary.auditDigest.slice(0, 28)}... + +`; + 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 0000000000000000000000000000000000000000..6a207b4e6f7c754f02385ad64cae60d1105c477e GIT binary patch literal 9408 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 JS+nM#{vW%#*gF6K literal 0 HcmV?d00001 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 @@ + + + + + Collaborative statistical reporting guard + Checks text, tables, figures, locked sections, and review comments before export. + Clean manuscript findings + + + 0 findings + Risky critical findings + + + 2 critical + Risky total findings + + + 16 total + + Top blockers + LOCKED_SECTION_STAT_EDIT_UNAPPROVED +SAMPLE_SIZE_DRIFT +BLOCKING_STAT_REVIEW_COMMENT_OPEN +BLOCKING_STAT_REVIEW_COMMENT_OPEN +CONFIDENCE_INTERVAL_MISMATCH +CONFIDENCE_INTERVAL_MISMATCH +EFFECT_SIZE_MISMATCH +P_VALUE_TABLE_MISMATCH + Decision: hold_collaborative_export | sha256:6043784e7d3140a56405b... + 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}`);