From 7dd5b3df9023de907b3b4b7c13224c8283e8c343 Mon Sep 17 00:00:00 2001 From: tuanadr Date: Wed, 20 May 2026 17:29:02 +0700 Subject: [PATCH] Add analysis variable provenance assistant --- .../README.md | 50 +++ .../acceptance-notes.md | 27 ++ .../demo-output/demo.mp4 | Bin 0 -> 47254 bytes .../demo-output/demo.svg | 57 +++ .../demo-output/provenance-audit.json | 180 ++++++++++ .../demo.js | 101 ++++++ .../index.js | 332 ++++++++++++++++++ .../requirements-map.md | 17 + .../sample-data.js | 102 ++++++ .../test.js | 166 +++++++++ 10 files changed, 1032 insertions(+) create mode 100644 analysis-variable-provenance-assistant/README.md create mode 100644 analysis-variable-provenance-assistant/acceptance-notes.md create mode 100644 analysis-variable-provenance-assistant/demo-output/demo.mp4 create mode 100644 analysis-variable-provenance-assistant/demo-output/demo.svg create mode 100644 analysis-variable-provenance-assistant/demo-output/provenance-audit.json create mode 100644 analysis-variable-provenance-assistant/demo.js create mode 100644 analysis-variable-provenance-assistant/index.js create mode 100644 analysis-variable-provenance-assistant/requirements-map.md create mode 100644 analysis-variable-provenance-assistant/sample-data.js create mode 100644 analysis-variable-provenance-assistant/test.js diff --git a/analysis-variable-provenance-assistant/README.md b/analysis-variable-provenance-assistant/README.md new file mode 100644 index 0000000..d36108f --- /dev/null +++ b/analysis-variable-provenance-assistant/README.md @@ -0,0 +1,50 @@ +# Analysis Variable Provenance Assistant + +This is a focused AI-Powered Research Assistant Suite slice for SCIBASE issue #16. It audits whether manuscript analysis variables can be traced back to the project data dictionary, producing pipeline transforms, cohort filters, transform hashes, and prior reproducibility attempts. + +## Scope + +- Checks manuscript variables against data dictionary ids and aliases. +- Detects unit drift between manuscript text and dictionary definitions. +- Checks cohort-filter alignment between manuscript analyses and producing transforms. +- Flags incomplete derived-variable lineage. +- Detects stale transform hashes and failing or non-deterministic pipelines. +- Links failed reproducibility attempts to affected manuscript analyses. +- Emits reviewer-ready findings, priority actions, confidence scores, and stable digests. + +It intentionally does not duplicate broad assistant-suite submissions, protocol-trace modules, evidence-grounding checks, statistical methods review, research-gap planners, rebuttal packs, ethics checks, citation-context reconciliation, reporting-guideline compliance, benchmark-leakage audits, or figure/table consistency modules. + +## Run + +```powershell +node analysis-variable-provenance-assistant/test.js +node analysis-variable-provenance-assistant/demo.js +``` + +The demo writes: + +- `analysis-variable-provenance-assistant/demo-output/provenance-audit.json` +- `analysis-variable-provenance-assistant/demo-output/demo.svg` + +This PR also includes the required short MP4 demo artifact: + +- `analysis-variable-provenance-assistant/demo-output/demo.mp4` + +## API + +```js +const { + auditVariableProvenance, + buildReviewerReport, + createFindingDigest, +} = require("./analysis-variable-provenance-assistant"); + +const audit = auditVariableProvenance({ + manuscript, + dataDictionary, + pipelines, + reproducibilityAttempts, +}); +``` + +`auditVariableProvenance` returns analysis-level packets with flags, findings, reviewer actions, reproducibility confidence, and deterministic finding digests. diff --git a/analysis-variable-provenance-assistant/acceptance-notes.md b/analysis-variable-provenance-assistant/acceptance-notes.md new file mode 100644 index 0000000..9bc376e --- /dev/null +++ b/analysis-variable-provenance-assistant/acceptance-notes.md @@ -0,0 +1,27 @@ +# Acceptance Notes + +## What This Adds + +- Dependency-free Node.js module under `analysis-variable-provenance-assistant/`. +- Deterministic analysis provenance audit packets for manuscript variables. +- Tests for undefined variables, unit drift, incomplete lineage, stale transform hashes, non-deterministic pipelines, failed reproducibility links, suite-level reporting, and stable digests. +- Demo JSON, SVG, and MP4 artifacts for bounty review. + +## Verification + +Use these commands from the repository root: + +```powershell +node analysis-variable-provenance-assistant/test.js +node analysis-variable-provenance-assistant/demo.js +node --check analysis-variable-provenance-assistant/index.js +node --check analysis-variable-provenance-assistant/test.js +node --check analysis-variable-provenance-assistant/demo.js +node --check analysis-variable-provenance-assistant/sample-data.js +ffprobe -v error -show_entries format=duration,size -show_entries stream=codec_name,width,height -of default=noprint_wrappers=1 analysis-variable-provenance-assistant/demo-output/demo.mp4 +git diff --check +``` + +## AI Assistance Disclosure + +This contribution was prepared with AI assistance from OpenAI Codex and reviewed through local deterministic tests and artifact checks before submission. diff --git a/analysis-variable-provenance-assistant/demo-output/demo.mp4 b/analysis-variable-provenance-assistant/demo-output/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..cef2d0e379a5f10993e35ba1d8a81a4a92a3994c GIT binary patch literal 47254 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 literal 0 HcmV?d00001 diff --git a/analysis-variable-provenance-assistant/demo-output/demo.svg b/analysis-variable-provenance-assistant/demo-output/demo.svg new file mode 100644 index 0000000..59b05e1 --- /dev/null +++ b/analysis-variable-provenance-assistant/demo-output/demo.svg @@ -0,0 +1,57 @@ + + + + Analysis Variable Provenance Assistant + Inflammation and glucose variability in post-acute cohorts - 2026-05-20T12:30:00.000Z + + + 3 + Analyses + + + + 2 + High risk + + + + 1 + Undefined vars + + + + 1 + Unit drift + + + + + analysis-primary + Hold For Provenance Fix - confidence 6 + INCOMPLETE_DERIVATION_LINEAGE | TRANSFORM_HASH_STALE | UNIT_DRIFT | NONDETERMINISTIC_PIPELINE | PIPELINE_TEST_FAILING | FAILED_REPRODUCIBILITY_ATTEMPT + Inflammation score predicts glucose variability in the post-acute cohort. + + + + analysis-secondary + Hold For Provenance Fix - confidence 50 + UNDEFINED_VARIABLE | PIPELINE_MISSING + Sleep fragmentation explains residual glucose variance. + + + + analysis-sensitivity + Ready For Review - confidence 88 + INCOMPLETE_DERIVATION_LINEAGE + Inflammation score remains stable after excluding medication switchers. + + Resolve 2 high-risk analysis provenance packets before pre-submission review. Define 1 manuscript variable in the project data dictionary. Re-run or explain 1 failed reproducibility attempt linked to manuscript analyses. + \ No newline at end of file diff --git a/analysis-variable-provenance-assistant/demo-output/provenance-audit.json b/analysis-variable-provenance-assistant/demo-output/provenance-audit.json new file mode 100644 index 0000000..575455a --- /dev/null +++ b/analysis-variable-provenance-assistant/demo-output/provenance-audit.json @@ -0,0 +1,180 @@ +{ + "generatedAt": "2026-05-20T12:30:00.000Z", + "projectId": "SCI-GLUCOSE-17", + "title": "Inflammation and glucose variability in post-acute cohorts", + "domain": "clinical trials", + "analysisPackets": [ + { + "analysisId": "analysis-primary", + "claim": "Inflammation score predicts glucose variability in the post-acute cohort.", + "cohort": "post-acute", + "flags": [ + "INCOMPLETE_DERIVATION_LINEAGE", + "TRANSFORM_HASH_STALE", + "UNIT_DRIFT", + "NONDETERMINISTIC_PIPELINE", + "PIPELINE_TEST_FAILING", + "FAILED_REPRODUCIBILITY_ATTEMPT" + ], + "findings": [ + { + "analysisId": "analysis-primary", + "variableName": "inflammation_score", + "flag": "INCOMPLETE_DERIVATION_LINEAGE", + "severity": "medium", + "message": "inflammation_score lineage omits il6_pg_ml from transform biomarker-transform.", + "reviewerAction": "Add il6_pg_ml to biomarker-transform lineage for inflammation_score.", + "generatedAt": "2026-05-20T12:30:00.000Z", + "digest": "avpa_dfcd8f323eb93f1e52fcb80c" + }, + { + "analysisId": "analysis-primary", + "variableName": "inflammation_score", + "flag": "TRANSFORM_HASH_STALE", + "severity": "medium", + "message": "biomarker-transform hash changed from sha256:old-glucose-transform to sha256:biomarker-transform.", + "reviewerAction": "Re-run analysis-primary or explain the biomarker-transform hash change.", + "generatedAt": "2026-05-20T12:30:00.000Z", + "digest": "avpa_326ea81261c504fdae920bd6" + }, + { + "analysisId": "analysis-primary", + "variableName": "glucose_variability", + "flag": "UNIT_DRIFT", + "severity": "medium", + "message": "glucose_variability is reported as mg/dL but the data dictionary uses mmol/L.", + "reviewerAction": "Reconcile glucose_variability units between manuscript and data dictionary.", + "generatedAt": "2026-05-20T12:30:00.000Z", + "digest": "avpa_bbc1aeaec39194870b243c1e" + }, + { + "analysisId": "analysis-primary", + "variableName": "glucose_variability", + "flag": "TRANSFORM_HASH_STALE", + "severity": "medium", + "message": "glucose-transform hash changed from sha256:old-glucose-transform to sha256:new-glucose-transform.", + "reviewerAction": "Re-run analysis-primary or explain the glucose-transform hash change.", + "generatedAt": "2026-05-20T12:30:00.000Z", + "digest": "avpa_1fd19d4fc0c424bfb0aa4965" + }, + { + "analysisId": "analysis-primary", + "variableName": "glucose_variability", + "flag": "NONDETERMINISTIC_PIPELINE", + "severity": "medium", + "message": "glucose-transform is marked non-deterministic.", + "reviewerAction": "Stabilize glucose-transform seeds or document accepted variance.", + "generatedAt": "2026-05-20T12:30:00.000Z", + "digest": "avpa_6c6005f9b02216b7ac7bdd63" + }, + { + "analysisId": "analysis-primary", + "variableName": "glucose_variability", + "flag": "PIPELINE_TEST_FAILING", + "severity": "high", + "message": "glucose-transform has test status fail.", + "reviewerAction": "Fix glucose-transform tests before relying on glucose_variability.", + "generatedAt": "2026-05-20T12:30:00.000Z", + "digest": "avpa_96929687755e683add74f88d" + }, + { + "analysisId": "analysis-primary", + "variableName": "*analysis*", + "flag": "FAILED_REPRODUCIBILITY_ATTEMPT", + "severity": "high", + "message": "analysis-primary has a fail reproducibility attempt: Output variance changed after glucose transform rerun.", + "reviewerAction": "Re-run or explain failed reproducibility attempt from 2026-05-18T09:00:00.000Z.", + "generatedAt": "2026-05-20T12:30:00.000Z", + "digest": "avpa_292d6ab7fb77ed68a1dbd6d7" + } + ], + "reviewerActions": [ + "Add il6_pg_ml to biomarker-transform lineage for inflammation_score.", + "Re-run analysis-primary or explain the biomarker-transform hash change.", + "Reconcile glucose_variability units between manuscript and data dictionary.", + "Re-run analysis-primary or explain the glucose-transform hash change.", + "Stabilize glucose-transform seeds or document accepted variance.", + "Fix glucose-transform tests before relying on glucose_variability.", + "Re-run or explain failed reproducibility attempt from 2026-05-18T09:00:00.000Z." + ], + "reproducibilityConfidence": 6, + "decision": "hold_for_provenance_fix" + }, + { + "analysisId": "analysis-secondary", + "claim": "Sleep fragmentation explains residual glucose variance.", + "cohort": "sleep-substudy", + "flags": [ + "UNDEFINED_VARIABLE", + "PIPELINE_MISSING" + ], + "findings": [ + { + "analysisId": "analysis-secondary", + "variableName": "sleep_fragmentation_index", + "flag": "UNDEFINED_VARIABLE", + "severity": "high", + "message": "sleep_fragmentation_index is used in the manuscript but is not defined in the data dictionary.", + "reviewerAction": "Define sleep_fragmentation_index in the project data dictionary before review.", + "generatedAt": "2026-05-20T12:30:00.000Z", + "digest": "avpa_477e32e8eb9040183ed73e71" + }, + { + "analysisId": "analysis-secondary", + "variableName": "sleep_fragmentation_index", + "flag": "PIPELINE_MISSING", + "severity": "high", + "message": "sleep_fragmentation_index has no producing pipeline transform.", + "reviewerAction": "Attach a producing transform or mark sleep_fragmentation_index as externally sourced.", + "generatedAt": "2026-05-20T12:30:00.000Z", + "digest": "avpa_bdc5a8bd4a299d99d80ab9e9" + } + ], + "reviewerActions": [ + "Define sleep_fragmentation_index in the project data dictionary before review.", + "Attach a producing transform or mark sleep_fragmentation_index as externally sourced." + ], + "reproducibilityConfidence": 50, + "decision": "hold_for_provenance_fix" + }, + { + "analysisId": "analysis-sensitivity", + "claim": "Inflammation score remains stable after excluding medication switchers.", + "cohort": "post-acute", + "flags": [ + "INCOMPLETE_DERIVATION_LINEAGE" + ], + "findings": [ + { + "analysisId": "analysis-sensitivity", + "variableName": "inflammation_score", + "flag": "INCOMPLETE_DERIVATION_LINEAGE", + "severity": "medium", + "message": "inflammation_score lineage omits il6_pg_ml from transform biomarker-transform.", + "reviewerAction": "Add il6_pg_ml to biomarker-transform lineage for inflammation_score.", + "generatedAt": "2026-05-20T12:30:00.000Z", + "digest": "avpa_60080ca167289503d0a3f4a7" + } + ], + "reviewerActions": [ + "Add il6_pg_ml to biomarker-transform lineage for inflammation_score." + ], + "reproducibilityConfidence": 88, + "decision": "ready_for_review" + } + ], + "reviewerReport": { + "counts": { + "analyses": 3, + "highRiskAnalyses": 2, + "undefinedVariables": 1, + "unitDriftFindings": 1, + "failedReproducibilityLinks": 1 + }, + "priorityActions": [ + "Resolve 2 high-risk analysis provenance packets before pre-submission review.", + "Define 1 manuscript variable in the project data dictionary.", + "Re-run or explain 1 failed reproducibility attempt linked to manuscript analyses." + ] + } +} diff --git a/analysis-variable-provenance-assistant/demo.js b/analysis-variable-provenance-assistant/demo.js new file mode 100644 index 0000000..7fb4399 --- /dev/null +++ b/analysis-variable-provenance-assistant/demo.js @@ -0,0 +1,101 @@ +const fs = require("fs"); +const path = require("path"); + +const { auditVariableProvenance } = require("./index"); +const { + manuscript, + dataDictionary, + pipelines, + reproducibilityAttempts, +} = require("./sample-data"); + +const generatedAt = "2026-05-20T12:30:00.000Z"; +const outputDir = path.join(__dirname, "demo-output"); + +fs.mkdirSync(outputDir, { recursive: true }); + +const audit = auditVariableProvenance({ + manuscript, + dataDictionary, + pipelines, + reproducibilityAttempts, + generatedAt, +}); + +fs.writeFileSync( + path.join(outputDir, "provenance-audit.json"), + `${JSON.stringify(audit, null, 2)}\n` +); +fs.writeFileSync(path.join(outputDir, "demo.svg"), buildSvg(audit)); + +console.log("Analysis variable provenance assistant demo"); +console.log(`Project: ${audit.title}`); +console.log(`Analyses audited: ${audit.reviewerReport.counts.analyses}`); +console.log(`High-risk analyses: ${audit.reviewerReport.counts.highRiskAnalyses}`); +console.log(`Undefined variables: ${audit.reviewerReport.counts.undefinedVariables}`); +console.log(`Unit drift findings: ${audit.reviewerReport.counts.unitDriftFindings}`); +console.log(`Wrote ${path.join(outputDir, "provenance-audit.json")}`); +console.log(`Wrote ${path.join(outputDir, "demo.svg")}`); + +function buildSvg(audit) { + const rows = audit.analysisPackets + .map((packet, index) => { + const y = 196 + index * 82; + const color = packet.decision === "ready_for_review" + ? "#1f8a5b" + : packet.decision === "needs_author_clarification" + ? "#ad6f00" + : "#b42318"; + const flags = packet.flags.length === 0 ? "No flags" : packet.flags.join(" | "); + return ` + + + ${escapeXml(packet.analysisId)} + ${escapeXml(formatDecision(packet.decision))} - confidence ${packet.reproducibilityConfidence} + ${escapeXml(flags)} + ${escapeXml(packet.claim)} + `; + }) + .join(""); + + return ` + + + Analysis Variable Provenance Assistant + ${escapeXml(audit.title)} - ${escapeXml(audit.generatedAt)} + ${metricCard(64, 112, "Analyses", audit.reviewerReport.counts.analyses, "#0b5fff")} + ${metricCard(252, 112, "High risk", audit.reviewerReport.counts.highRiskAnalyses, "#b42318")} + ${metricCard(440, 112, "Undefined vars", audit.reviewerReport.counts.undefinedVariables, "#ad6f00")} + ${metricCard(628, 112, "Unit drift", audit.reviewerReport.counts.unitDriftFindings, "#6f42c1")} + ${rows} + ${escapeXml(audit.reviewerReport.priorityActions.join(" "))} +`; +} + +function metricCard(x, y, label, value, color) { + return ` + + ${value} + ${escapeXml(label)} + `; +} + +function formatDecision(decision) { + return decision.split("_").map((part) => part[0].toUpperCase() + part.slice(1)).join(" "); +} + +function escapeXml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} diff --git a/analysis-variable-provenance-assistant/index.js b/analysis-variable-provenance-assistant/index.js new file mode 100644 index 0000000..db05b5c --- /dev/null +++ b/analysis-variable-provenance-assistant/index.js @@ -0,0 +1,332 @@ +const crypto = require("crypto"); + +const FLAG_WEIGHTS = { + UNDEFINED_VARIABLE: 30, + PIPELINE_MISSING: 20, + UNIT_DRIFT: 12, + COHORT_FILTER_MISSING: 12, + INCOMPLETE_DERIVATION_LINEAGE: 12, + TRANSFORM_HASH_STALE: 15, + NONDETERMINISTIC_PIPELINE: 12, + PIPELINE_TEST_FAILING: 18, + FAILED_REPRODUCIBILITY_ATTEMPT: 25, +}; + +function auditVariableProvenance({ + manuscript, + dataDictionary, + pipelines, + reproducibilityAttempts = [], + generatedAt = new Date().toISOString(), +}) { + if (!manuscript || !Array.isArray(manuscript.analyses)) { + throw new Error("manuscript.analyses is required"); + } + if (!dataDictionary || !Array.isArray(dataDictionary.variables)) { + throw new Error("dataDictionary.variables is required"); + } + if (!Array.isArray(pipelines)) { + throw new Error("pipelines must be an array"); + } + + const dictionary = buildDictionaryIndex(dataDictionary.variables); + const pipelineByOutput = buildPipelineIndex(pipelines); + const attemptsByAnalysis = groupBy(reproducibilityAttempts, "analysisId"); + const analysisPackets = manuscript.analyses.map((analysis) => + auditAnalysis({ + analysis, + dictionary, + pipelineByOutput, + attempts: attemptsByAnalysis.get(analysis.id) || [], + generatedAt, + }) + ); + + const audit = { + generatedAt, + projectId: manuscript.projectId, + title: manuscript.title, + domain: manuscript.domain, + analysisPackets, + }; + audit.reviewerReport = buildReviewerReport(audit); + return audit; +} + +function auditAnalysis({ analysis, dictionary, pipelineByOutput, attempts, generatedAt }) { + const findings = []; + const flags = []; + + for (const variable of analysis.variables || []) { + const normalizedName = normalize(variable.name); + const dictionaryEntry = dictionary.get(normalizedName); + + if (!dictionaryEntry) { + addFinding(findings, flags, { + analysisId: analysis.id, + variableName: variable.name, + flag: "UNDEFINED_VARIABLE", + severity: "high", + message: `${variable.name} is used in the manuscript but is not defined in the data dictionary.`, + reviewerAction: `Define ${variable.name} in the project data dictionary before review.`, + generatedAt, + }); + if (!pipelineByOutput.has(normalizedName)) { + addFinding(findings, flags, { + analysisId: analysis.id, + variableName: variable.name, + flag: "PIPELINE_MISSING", + severity: "high", + message: `${variable.name} has no producing pipeline transform.`, + reviewerAction: `Attach a producing transform or mark ${variable.name} as externally sourced.`, + generatedAt, + }); + } + continue; + } + + if (variable.unit && dictionaryEntry.unit && normalizeUnit(variable.unit) !== normalizeUnit(dictionaryEntry.unit)) { + addFinding(findings, flags, { + analysisId: analysis.id, + variableName: dictionaryEntry.id, + flag: "UNIT_DRIFT", + severity: "medium", + message: `${dictionaryEntry.id} is reported as ${variable.unit} but the data dictionary uses ${dictionaryEntry.unit}.`, + reviewerAction: `Reconcile ${dictionaryEntry.id} units between manuscript and data dictionary.`, + generatedAt, + }); + } + + if ( + analysis.requiredCohortFilter && + Array.isArray(dictionaryEntry.allowedCohorts) && + !dictionaryEntry.allowedCohorts.includes(analysis.cohort) + ) { + addFinding(findings, flags, { + analysisId: analysis.id, + variableName: dictionaryEntry.id, + flag: "COHORT_FILTER_MISSING", + severity: "medium", + message: `${dictionaryEntry.id} is not listed for cohort ${analysis.cohort}.`, + reviewerAction: `Document why ${dictionaryEntry.id} can be used for ${analysis.cohort}.`, + generatedAt, + }); + } + + const pipeline = pipelineByOutput.get(normalize(dictionaryEntry.id)); + if (!pipeline) { + addFinding(findings, flags, { + analysisId: analysis.id, + variableName: dictionaryEntry.id, + flag: "PIPELINE_MISSING", + severity: "high", + message: `${dictionaryEntry.id} has no producing pipeline transform.`, + reviewerAction: `Attach a producing transform for ${dictionaryEntry.id}.`, + generatedAt, + }); + continue; + } + + const missingLineageInputs = (dictionaryEntry.lineage || []).filter( + (parent) => !(pipeline.inputs || []).map(normalize).includes(normalize(parent)) + ); + if (missingLineageInputs.length > 0) { + addFinding(findings, flags, { + analysisId: analysis.id, + variableName: dictionaryEntry.id, + flag: "INCOMPLETE_DERIVATION_LINEAGE", + severity: "medium", + message: `${dictionaryEntry.id} lineage omits ${missingLineageInputs.join(", ")} from transform ${pipeline.id}.`, + reviewerAction: `Add ${missingLineageInputs.join(", ")} to ${pipeline.id} lineage for ${dictionaryEntry.id}.`, + generatedAt, + }); + } + + if (analysis.requiredCohortFilter && pipeline.cohortFilter !== analysis.requiredCohortFilter) { + addFinding(findings, flags, { + analysisId: analysis.id, + variableName: dictionaryEntry.id, + flag: "COHORT_FILTER_MISSING", + severity: "medium", + message: `${pipeline.id} cohort filter does not match ${analysis.requiredCohortFilter}.`, + reviewerAction: `Align ${pipeline.id} cohort filter with ${analysis.id}.`, + generatedAt, + }); + } + + if (analysis.reportedTransformHash && pipeline.currentHash !== analysis.reportedTransformHash) { + addFinding(findings, flags, { + analysisId: analysis.id, + variableName: dictionaryEntry.id, + flag: "TRANSFORM_HASH_STALE", + severity: "medium", + message: `${pipeline.id} hash changed from ${analysis.reportedTransformHash} to ${pipeline.currentHash}.`, + reviewerAction: `Re-run ${analysis.id} or explain the ${pipeline.id} hash change.`, + generatedAt, + }); + } + + if (pipeline.deterministic === false) { + addFinding(findings, flags, { + analysisId: analysis.id, + variableName: dictionaryEntry.id, + flag: "NONDETERMINISTIC_PIPELINE", + severity: "medium", + message: `${pipeline.id} is marked non-deterministic.`, + reviewerAction: `Stabilize ${pipeline.id} seeds or document accepted variance.`, + generatedAt, + }); + } + + if (pipeline.testStatus && pipeline.testStatus !== "pass") { + addFinding(findings, flags, { + analysisId: analysis.id, + variableName: dictionaryEntry.id, + flag: "PIPELINE_TEST_FAILING", + severity: "high", + message: `${pipeline.id} has test status ${pipeline.testStatus}.`, + reviewerAction: `Fix ${pipeline.id} tests before relying on ${dictionaryEntry.id}.`, + generatedAt, + }); + } + } + + for (const attempt of attempts.filter((item) => item.status !== "pass")) { + addFinding(findings, flags, { + analysisId: analysis.id, + variableName: "*analysis*", + flag: "FAILED_REPRODUCIBILITY_ATTEMPT", + severity: "high", + message: `${analysis.id} has a ${attempt.status} reproducibility attempt: ${attempt.reason}`, + reviewerAction: `Re-run or explain failed reproducibility attempt from ${attempt.attemptedAt}.`, + generatedAt, + }); + } + + const riskPenalty = flags.reduce((total, flag) => total + (FLAG_WEIGHTS[flag] || 5), 0); + const reproducibilityConfidence = Math.max(0, 100 - riskPenalty); + + return { + analysisId: analysis.id, + claim: analysis.claim, + cohort: analysis.cohort, + flags, + findings, + reviewerActions: findings.map((finding) => finding.reviewerAction), + reproducibilityConfidence, + decision: reproducibilityConfidence >= 80 + ? "ready_for_review" + : reproducibilityConfidence >= 60 + ? "needs_author_clarification" + : "hold_for_provenance_fix", + }; +} + +function buildReviewerReport(audit) { + const packets = audit.analysisPackets || []; + const allFindings = packets.flatMap((packet) => packet.findings || []); + const counts = { + analyses: packets.length, + highRiskAnalyses: packets.filter((packet) => packet.reproducibilityConfidence < 70).length, + undefinedVariables: countFlags(allFindings, "UNDEFINED_VARIABLE"), + unitDriftFindings: countFlags(allFindings, "UNIT_DRIFT"), + failedReproducibilityLinks: countFlags(allFindings, "FAILED_REPRODUCIBILITY_ATTEMPT"), + }; + + const priorityActions = []; + if (counts.highRiskAnalyses > 0) { + priorityActions.push( + `Resolve ${formatCount(counts.highRiskAnalyses, "high-risk analysis provenance packet")} before pre-submission review.` + ); + } + if (counts.undefinedVariables > 0) { + priorityActions.push( + `Define ${formatCount(counts.undefinedVariables, "manuscript variable")} in the project data dictionary.` + ); + } + if (counts.failedReproducibilityLinks > 0) { + priorityActions.push( + `Re-run or explain ${formatCount(counts.failedReproducibilityLinks, "failed reproducibility attempt")} linked to manuscript analyses.` + ); + } + + return { counts, priorityActions }; +} + +function createFindingDigest(finding) { + const stableFacts = { + analysisId: finding.analysisId, + variableName: finding.variableName, + flag: finding.flag, + severity: finding.severity, + message: finding.message, + reviewerAction: finding.reviewerAction, + }; + return `avpa_${crypto.createHash("sha256").update(JSON.stringify(stableFacts)).digest("hex").slice(0, 24)}`; +} + +function addFinding(findings, flags, finding) { + const completeFinding = { + ...finding, + }; + completeFinding.digest = createFindingDigest(completeFinding); + findings.push(completeFinding); + if (!flags.includes(finding.flag)) { + flags.push(finding.flag); + } +} + +function buildDictionaryIndex(variables) { + const index = new Map(); + for (const variable of variables) { + index.set(normalize(variable.id), variable); + for (const alias of variable.aliases || []) { + index.set(normalize(alias), variable); + } + } + return index; +} + +function buildPipelineIndex(pipelines) { + const index = new Map(); + for (const pipeline of pipelines) { + for (const output of pipeline.outputs || []) { + index.set(normalize(output), pipeline); + } + } + return index; +} + +function groupBy(items, key) { + const grouped = new Map(); + for (const item of items) { + const value = item[key]; + if (!grouped.has(value)) { + grouped.set(value, []); + } + grouped.get(value).push(item); + } + return grouped; +} + +function countFlags(findings, flag) { + return findings.filter((finding) => finding.flag === flag).length; +} + +function normalize(value) { + return String(value || "").trim().toLowerCase().replace(/[\s-]+/g, "_"); +} + +function normalizeUnit(value) { + return String(value || "").trim().toLowerCase().replace(/\s+/g, ""); +} + +function formatCount(count, label) { + return `${count} ${label}${count === 1 ? "" : "s"}`; +} + +module.exports = { + auditVariableProvenance, + buildReviewerReport, + createFindingDigest, +}; diff --git a/analysis-variable-provenance-assistant/requirements-map.md b/analysis-variable-provenance-assistant/requirements-map.md new file mode 100644 index 0000000..5ce49a0 --- /dev/null +++ b/analysis-variable-provenance-assistant/requirements-map.md @@ -0,0 +1,17 @@ +# Requirements Map + +| Issue #16 requirement | Implementation coverage | +| --- | --- | +| Auto peer review reports | Emits reviewer-ready findings and priority actions for manuscript analysis-variable risks. | +| Statistical or methodological red flags | Flags unit drift, missing cohort filters, stale transform hashes, incomplete lineage, and failed transforms. | +| Claims vs. evidence alignment | Maps manuscript analysis claims to variables, dictionary records, producing transforms, and prior reproducibility attempts. | +| Reproducibility checker | Checks dependency-like transform hashes, deterministic flags, pipeline test status, and failed prior attempts. | +| Output consistency with reported results | Flags stale transform hashes and failed reproducibility attempts that can change reported outputs. | +| Dependency/version integrity | Treats transform hashes and producing pipeline records as deterministic provenance evidence. | +| Presence of raw data, clean pipelines, and test sets | Requires data dictionary entries, producing transforms, lineage inputs, and passing pipeline tests. | +| Reproducibility confidence score | Assigns each analysis a confidence score and decision. | +| Links to previous reproducibility attempts | Links failed attempts to affected manuscript analyses and reviewer actions. | + +## Non-Overlap Statement + +This slice focuses on analysis-variable provenance. It does not duplicate broad assistant-suite foundations, protocol trace, evidence grounding, statistics review, gap planning, rebuttal response packs, ethics/data-availability checks, citation-context reconciliation, reporting-guideline compliance, benchmark-leakage auditing, or figure/table consistency checks. diff --git a/analysis-variable-provenance-assistant/sample-data.js b/analysis-variable-provenance-assistant/sample-data.js new file mode 100644 index 0000000..9dd083f --- /dev/null +++ b/analysis-variable-provenance-assistant/sample-data.js @@ -0,0 +1,102 @@ +const manuscript = { + projectId: "SCI-GLUCOSE-17", + title: "Inflammation and glucose variability in post-acute cohorts", + domain: "clinical trials", + analyses: [ + { + id: "analysis-primary", + claim: "Inflammation score predicts glucose variability in the post-acute cohort.", + cohort: "post-acute", + variables: [ + { name: "inflammation_score", unit: "score", role: "predictor" }, + { name: "glucose_variability", unit: "mg/dL", role: "outcome" }, + ], + requiredCohortFilter: "post_acute == true", + reportedTransformHash: "sha256:old-glucose-transform", + }, + { + id: "analysis-secondary", + claim: "Sleep fragmentation explains residual glucose variance.", + cohort: "sleep-substudy", + variables: [ + { name: "sleep_fragmentation_index", unit: "index", role: "predictor" }, + ], + requiredCohortFilter: "sleep_substudy == true", + reportedTransformHash: "sha256:sleep-transform", + }, + { + id: "analysis-sensitivity", + claim: "Inflammation score remains stable after excluding medication switchers.", + cohort: "post-acute", + variables: [ + { name: "inflammation_score", unit: "score", role: "predictor" }, + ], + requiredCohortFilter: "post_acute == true", + reportedTransformHash: "sha256:biomarker-transform", + }, + ], +}; + +const dataDictionary = { + variables: [ + { + id: "inflammation_score", + aliases: ["inflammation score", "crp_il6_score"], + unit: "score", + dataset: "derived_biomarkers.csv", + lineage: ["crp_mg_l", "il6_pg_ml"], + allowedCohorts: ["post-acute", "all"], + }, + { + id: "glucose_variability", + aliases: ["glucose variability", "gv"], + unit: "mmol/L", + dataset: "wearable_glucose.csv", + lineage: [], + allowedCohorts: ["post-acute"], + }, + ], +}; + +const pipelines = [ + { + id: "biomarker-transform", + outputs: ["inflammation_score"], + inputs: ["crp_mg_l"], + cohortFilter: "post_acute == true", + currentHash: "sha256:biomarker-transform", + deterministic: true, + testStatus: "pass", + }, + { + id: "glucose-transform", + outputs: ["glucose_variability"], + inputs: ["glucose_reading"], + cohortFilter: "post_acute == true", + currentHash: "sha256:new-glucose-transform", + deterministic: false, + testStatus: "fail", + }, +]; + +const reproducibilityAttempts = [ + { + analysisId: "analysis-primary", + status: "fail", + reason: "Output variance changed after glucose transform rerun.", + attemptedAt: "2026-05-18T09:00:00.000Z", + }, + { + analysisId: "analysis-sensitivity", + status: "pass", + reason: "Sensitivity analysis reproduced from locked transform hash.", + attemptedAt: "2026-05-19T15:30:00.000Z", + }, +]; + +module.exports = { + manuscript, + dataDictionary, + pipelines, + reproducibilityAttempts, +}; diff --git a/analysis-variable-provenance-assistant/test.js b/analysis-variable-provenance-assistant/test.js new file mode 100644 index 0000000..6175d31 --- /dev/null +++ b/analysis-variable-provenance-assistant/test.js @@ -0,0 +1,166 @@ +const assert = require("assert"); + +const { + auditVariableProvenance, + buildReviewerReport, + createFindingDigest, +} = require("./index"); + +const generatedAt = "2026-05-20T12:30:00.000Z"; + +const manuscript = { + projectId: "SCI-GLUCOSE-17", + title: "Inflammation and glucose variability in post-acute cohorts", + domain: "clinical trials", + analyses: [ + { + id: "analysis-primary", + claim: "Inflammation score predicts glucose variability in the post-acute cohort.", + cohort: "post-acute", + variables: [ + { name: "inflammation_score", unit: "score", role: "predictor" }, + { name: "glucose_variability", unit: "mg/dL", role: "outcome" }, + ], + requiredCohortFilter: "post_acute == true", + reportedTransformHash: "sha256:old-glucose-transform", + }, + { + id: "analysis-secondary", + claim: "Novel sleep fragmentation index explains residual variance.", + cohort: "sleep-substudy", + variables: [ + { name: "sleep_fragmentation_index", unit: "index", role: "predictor" }, + ], + requiredCohortFilter: "sleep_substudy == true", + reportedTransformHash: "sha256:sleep-transform", + }, + ], +}; + +const dataDictionary = { + variables: [ + { + id: "inflammation_score", + aliases: ["inflammation score", "crp_il6_score"], + unit: "score", + dataset: "derived_biomarkers.csv", + lineage: ["crp_mg_l", "il6_pg_ml"], + allowedCohorts: ["post-acute", "all"], + }, + { + id: "glucose_variability", + aliases: ["glucose variability", "gv"], + unit: "mmol/L", + dataset: "wearable_glucose.csv", + lineage: [], + allowedCohorts: ["post-acute"], + }, + ], +}; + +const pipelines = [ + { + id: "biomarker-transform", + outputs: ["inflammation_score"], + inputs: ["crp_mg_l"], + cohortFilter: "post_acute == true", + currentHash: "sha256:biomarker-transform", + deterministic: true, + testStatus: "pass", + }, + { + id: "glucose-transform", + outputs: ["glucose_variability"], + inputs: ["glucose_reading"], + cohortFilter: "post_acute == true", + currentHash: "sha256:new-glucose-transform", + deterministic: false, + testStatus: "fail", + }, +]; + +const reproducibilityAttempts = [ + { + analysisId: "analysis-primary", + status: "fail", + reason: "Output variance changed after glucose transform rerun.", + attemptedAt: "2026-05-18T09:00:00.000Z", + }, +]; + +function test(name, fn) { + try { + fn(); + console.log(`ok - ${name}`); + } catch (error) { + console.error(`not ok - ${name}`); + console.error(error); + process.exitCode = 1; + } +} + +test("audits variable provenance risks with reviewer-ready actions", () => { + const audit = auditVariableProvenance({ + manuscript, + dataDictionary, + pipelines, + reproducibilityAttempts, + generatedAt, + }); + + const primary = audit.analysisPackets.find((packet) => packet.analysisId === "analysis-primary"); + assert(primary, "expected primary analysis packet"); + assert(primary.flags.includes("UNIT_DRIFT")); + assert(primary.flags.includes("INCOMPLETE_DERIVATION_LINEAGE")); + assert(primary.flags.includes("TRANSFORM_HASH_STALE")); + assert(primary.flags.includes("NONDETERMINISTIC_PIPELINE")); + assert(primary.flags.includes("FAILED_REPRODUCIBILITY_ATTEMPT")); + assert(primary.reviewerActions.some((action) => action.includes("glucose_variability"))); + assert(primary.reproducibilityConfidence < 70); + + const secondary = audit.analysisPackets.find((packet) => packet.analysisId === "analysis-secondary"); + assert(secondary, "expected secondary analysis packet"); + assert.deepStrictEqual(secondary.flags, ["UNDEFINED_VARIABLE", "PIPELINE_MISSING"]); + assert(secondary.reviewerActions.some((action) => action.includes("Define sleep_fragmentation_index"))); +}); + +test("builds a deterministic suite-level reviewer report", () => { + const audit = auditVariableProvenance({ + manuscript, + dataDictionary, + pipelines, + reproducibilityAttempts, + generatedAt, + }); + const report = buildReviewerReport(audit); + + assert.deepStrictEqual(report.counts, { + analyses: 2, + highRiskAnalyses: 2, + undefinedVariables: 1, + unitDriftFindings: 1, + failedReproducibilityLinks: 1, + }); + assert.deepStrictEqual(report.priorityActions, [ + "Resolve 2 high-risk analysis provenance packets before pre-submission review.", + "Define 1 manuscript variable in the project data dictionary.", + "Re-run or explain 1 failed reproducibility attempt linked to manuscript analyses.", + ]); +}); + +test("creates stable finding digests from finding facts", () => { + const audit = auditVariableProvenance({ + manuscript, + dataDictionary, + pipelines, + reproducibilityAttempts, + generatedAt, + }); + const finding = audit.analysisPackets[0].findings[0]; + + const first = createFindingDigest(finding); + const second = createFindingDigest({ ...finding, reviewerAction: finding.reviewerAction }); + + assert.strictEqual(first, second); + assert.match(first, /^avpa_[a-f0-9]{24}$/); +});