Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions study-power-feasibility-assistant/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Study Power Feasibility Assistant

This module adds a focused statistical power review slice for SCIBASE issue
[#16](https://github.com/SCIBASE-AI/SCIBASE.AI/issues/16). It helps the AI
Research Assistant Suite detect manuscript claims that overstate what the study
design can support.

The contribution is intentionally narrow. It does not duplicate prompt-safety,
supplement readiness, rebuttal generation, uncertainty calibration, grant-fit,
citation-context, benchmark leakage, figure-claim, or analysis-variable
assistants already submitted for the same broad issue.

## What It Checks

- Required sample size per group for continuous and binary outcomes.
- Actual sample size coverage against target alpha and power.
- Expected effect size versus the minimum detectable effect.
- Multiple-comparison load when no analysis plan is declared.
- Missing preregistration, missing outcome declaration, or missing analysis plan.
- Reviewer actions for revise/hold decisions.

## Local Usage

```bash
cd study-power-feasibility-assistant
npm run check
npm test
npm run demo
```

`npm run demo` writes reviewer artifacts under `reports/`:

- `study-power-review-packet.json`
- `study-power-review-report.md`
- `summary.svg`
- `demo.mp4`

All examples use synthetic study metadata. No external services, API keys,
accounts, or private research records are required.
26 changes: 26 additions & 0 deletions study-power-feasibility-assistant/acceptance-notes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Acceptance Notes

## Reviewer Checklist

- Self-contained under `study-power-feasibility-assistant/`.
- Dependency-free Node.js implementation.
- Synthetic study metadata only.
- Tests cover pass, revise, and hold decisions.
- Demo artifacts are generated locally and include an MP4 proof artifact.

## Commands Run

```bash
npm run check
npm test
npm run demo
ffprobe -v error -show_entries format=duration,size -show_entries stream=codec_name,width,height -of default=noprint_wrappers=1 reports/demo.mp4
git diff --check
```

## Limitations

- The sample-size formulas are deterministic screening heuristics for review
triage, not a substitute for a full statistical analysis plan.
- Production integration should replace the sample policy constants with
SCIBASE domain-specific statistical review templates.
81 changes: 81 additions & 0 deletions study-power-feasibility-assistant/demo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
const fs = require("node:fs")
const path = require("node:path")
const { spawnSync } = require("node:child_process")
const { evaluateStudyPortfolio } = require("./index")
const { reviewPolicy, studyClaims } = require("./sample-data")

const reportsDir = path.join(__dirname, "reports")
fs.mkdirSync(reportsDir, { recursive: true })

const packet = evaluateStudyPortfolio({ studyClaims, reviewPolicy })
const { summary } = packet

fs.writeFileSync(
path.join(reportsDir, "study-power-review-packet.json"),
`${JSON.stringify(packet, null, 2)}\n`,
)

const markdown = [
"# Study Power Feasibility Assistant Report",
"",
`Generated claims: ${summary.totalClaims}`,
`Passed: ${summary.passed}`,
`Needs revision: ${summary.revise}`,
`Held: ${summary.held}`,
`Reviewer actions: ${summary.reviewerActions}`,
`Audit digest: \`${packet.audit.digest}\``,
"",
"## Claim Decisions",
...packet.decisions.flatMap((decision) => [
"",
`### ${decision.id}: ${decision.title}`,
`- Status: ${decision.status}`,
`- Domain: ${decision.domain}`,
`- Required n/group: ${decision.powerAssessment.requiredPerGroup}`,
`- Actual n/group: ${decision.powerAssessment.sampleSizePerGroup}`,
`- Coverage ratio: ${decision.powerAssessment.coverageRatio}`,
`- Findings: ${decision.findings.map((finding) => finding.code).join(", ") || "none"}`,
`- First action: ${decision.reviewerActions[0]?.message || "none"}`,
]),
"",
]

fs.writeFileSync(path.join(reportsDir, "study-power-review-report.md"), markdown.join("\n"))

const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
<rect width="960" height="540" fill="#111827"/>
<text x="48" y="78" fill="#f9fafb" font-family="Arial" font-size="34" font-weight="700">Study Power Feasibility Assistant</text>
<text x="48" y="124" fill="#cbd5e1" font-family="Arial" font-size="18">Synthetic statistical review packet for SCIBASE issue #16</text>
<rect x="48" y="170" width="250" height="150" rx="14" fill="#0f766e"/>
<text x="78" y="230" fill="#ecfeff" font-family="Arial" font-size="56" font-weight="700">${summary.passed}</text>
<text x="78" y="270" fill="#ccfbf1" font-family="Arial" font-size="22">passed</text>
<rect x="355" y="170" width="250" height="150" rx="14" fill="#b45309"/>
<text x="385" y="230" fill="#fff7ed" font-family="Arial" font-size="56" font-weight="700">${summary.revise}</text>
<text x="385" y="270" fill="#ffedd5" font-family="Arial" font-size="22">revise</text>
<rect x="662" y="170" width="250" height="150" rx="14" fill="#991b1b"/>
<text x="692" y="230" fill="#fef2f2" font-family="Arial" font-size="56" font-weight="700">${summary.held}</text>
<text x="692" y="270" fill="#fee2e2" font-family="Arial" font-size="22">held</text>
<text x="48" y="390" fill="#e5e7eb" font-family="Arial" font-size="20">Checks: n per group, effect size, variance, alpha, target power, multiple testing, preregistration evidence.</text>
<text x="48" y="430" fill="#9ca3af" font-family="Arial" font-size="16">Digest ${packet.audit.digest.slice(0, 24)}...</text>
</svg>
`
fs.writeFileSync(path.join(reportsDir, "summary.svg"), svg)

const ffmpeg = spawnSync("ffmpeg", [
"-y",
"-f",
"lavfi",
"-i",
"color=c=0x111827:s=960x540:d=6:r=15",
"-vf",
"drawbox=x=48:y=170:w=250:h=150:color=0x0f766e@1:t=fill,drawbox=x=355:y=170:w=250:h=150:color=0xb45309@1:t=fill,drawbox=x=662:y=170:w=250:h=150:color=0x991b1b@1:t=fill,drawbox=x=48:y=370:w=864:h=18:color=0x22d3ee@1:t=fill",
"-pix_fmt",
"yuv420p",
path.join(reportsDir, "demo.mp4"),
], { stdio: "ignore" })

if (ffmpeg.status !== 0) {
console.warn("ffmpeg video generation failed; summary.svg and JSON/Markdown reports were still generated.")
}

console.log(`Wrote study power artifacts to ${reportsDir}`)
204 changes: 204 additions & 0 deletions study-power-feasibility-assistant/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
const crypto = require("node:crypto")

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 digestFor(value) {
return crypto.createHash("sha256").update(stableJson(value)).digest("hex")
}

function finding(code, severity, message, detail = {}) {
return { code, severity, message, detail }
}

function getZAlpha(alpha, policy) {
return policy.alphaByFamily[String(alpha)] ?? 1.96
}

function getZPower(targetPower, policy) {
return policy.zPowerByTarget[String(targetPower)] ?? policy.zPowerByTarget[String(policy.targetPower)] ?? 0.84
}

function requiredContinuousN(claim, policy) {
const zAlpha = getZAlpha(claim.alpha, policy)
const zPower = getZPower(claim.targetPower, policy)
const variance = claim.observedStandardDeviation ** 2
const effect = Math.abs(claim.expectedEffectSize)

if (effect <= 0) {
return Number.POSITIVE_INFINITY
}

return Math.ceil((2 * (zAlpha + zPower) ** 2 * variance) / (effect ** 2))
}

function requiredBinaryN(claim, policy) {
const zAlpha = getZAlpha(claim.alpha, policy)
const zPower = getZPower(claim.targetPower, policy)
const p1 = claim.baselineRate
const p2 = claim.expectedRate
const delta = Math.abs(p1 - p2)

if (!Number.isFinite(delta) || delta <= 0) {
return Number.POSITIVE_INFINITY
}

const pooled = (p1 + p2) / 2
const pooledVariance = 2 * pooled * (1 - pooled)
const armVariance = p1 * (1 - p1) + p2 * (1 - p2)
return Math.ceil(((zAlpha * Math.sqrt(pooledVariance) + zPower * Math.sqrt(armVariance)) ** 2) / (delta ** 2))
}

function requiredPerGroup(claim, policy) {
if (claim.outcomeType === "binary") {
return requiredBinaryN(claim, policy)
}
return requiredContinuousN(claim, policy)
}

function estimateCoverageRatio(claim, requiredN) {
if (!Number.isFinite(requiredN) || requiredN <= 0) {
return 0
}
return Number((claim.sampleSizePerGroup / requiredN).toFixed(3))
}

function minimumDetectableContinuousEffect(claim, policy) {
const zAlpha = getZAlpha(claim.alpha, policy)
const zPower = getZPower(claim.targetPower, policy)
const variance = claim.observedStandardDeviation ** 2
return Math.sqrt((2 * (zAlpha + zPower) ** 2 * variance) / claim.sampleSizePerGroup)
}

function evaluatePreregistration(claim) {
const findings = []
if (!claim.preregistration.present) {
findings.push(finding("PREREGISTRATION_MISSING", "blocker", "Power-sensitive confirmatory claim lacks preregistration evidence."))
return findings
}
if (!claim.preregistration.outcomeDeclared) {
findings.push(finding("OUTCOME_NOT_PREREGISTERED", "warning", "Primary outcome is not declared in the preregistration metadata."))
}
if (!claim.preregistration.analysisPlanDeclared) {
findings.push(finding("ANALYSIS_PLAN_NOT_DECLARED", "warning", "Analysis plan is missing from preregistration metadata."))
}
return findings
}

function evaluateClaim(claim, policy) {
const requiredN = requiredPerGroup(claim, policy)
const coverageRatio = estimateCoverageRatio(claim, requiredN)
const findings = []

if (coverageRatio < policy.minPowerCoverageRatio) {
findings.push(finding("UNDERPOWERED_CLAIM", coverageRatio < 0.45 ? "blocker" : "warning", "Claimed effect is not supported by the available sample size.", {
sampleSizePerGroup: claim.sampleSizePerGroup,
requiredPerGroup: requiredN,
coverageRatio,
}))
}

if (claim.outcomeType === "continuous") {
const minimumDetectableEffect = minimumDetectableContinuousEffect(claim, policy)
const effectCoverage = Number((Math.abs(claim.expectedEffectSize) / minimumDetectableEffect).toFixed(3))
if (effectCoverage < policy.minDetectableEffectCoverage) {
findings.push(finding("EFFECT_SIZE_TOO_SMALL_FOR_DESIGN", "warning", "Expected effect is below the design's detectable effect threshold.", {
expectedEffectSize: claim.expectedEffectSize,
minimumDetectableEffect: Number(minimumDetectableEffect.toFixed(3)),
effectCoverage,
}))
}
}

if (claim.plannedComparisons > policy.maxUndisclosedTests && !claim.preregistration.analysisPlanDeclared) {
findings.push(finding("MULTIPLE_TESTING_PLAN_MISSING", "warning", "Large comparison set needs an explicit multiplicity correction plan.", {
plannedComparisons: claim.plannedComparisons,
}))
}

findings.push(...evaluatePreregistration(claim))

const blockers = findings.filter((item) => item.severity === "blocker")
const warnings = findings.filter((item) => item.severity === "warning")
const status = blockers.length > 0 ? "hold" : (warnings.length > 0 ? "revise" : "pass")

const decision = {
id: claim.id,
title: claim.title,
domain: claim.domain,
status,
manuscriptClaim: claim.manuscriptClaim,
powerAssessment: {
outcomeType: claim.outcomeType,
sampleSizePerGroup: claim.sampleSizePerGroup,
requiredPerGroup: requiredN,
coverageRatio,
targetPower: claim.targetPower,
alpha: claim.alpha,
plannedComparisons: claim.plannedComparisons,
},
findings,
reviewerActions: buildReviewerActions(status, findings),
}

return {
...decision,
auditDigest: digestFor(decision),
}
}

function buildReviewerActions(status, findings) {
if (status === "pass") {
return [{ code: "KEEP_POWER_CLAIM", owner: "author", message: "Keep the current power claim with the existing methods disclosure." }]
}

const actions = findings.map((item) => {
if (item.code === "UNDERPOWERED_CLAIM") {
return { code: "SOFTEN_OR_REPOWER_CLAIM", owner: "author", message: "Revise the claim, increase sample size, or report it as exploratory." }
}
if (item.code === "MULTIPLE_TESTING_PLAN_MISSING") {
return { code: "ADD_MULTIPLICITY_PLAN", owner: "statistics-reviewer", message: "Add correction strategy or clearly label exploratory endpoints." }
}
if (item.code === "PREREGISTRATION_MISSING") {
return { code: "ADD_PREREGISTRATION_LIMITATION", owner: "author", message: "Add preregistration status and limitation text before submission." }
}
return { code: `ADDRESS_${item.code}`, owner: "author", message: item.message }
})

return [...new Map(actions.map((item) => [item.code, item])).values()]
}

function evaluateStudyPortfolio({ studyClaims, reviewPolicy }) {
const decisions = studyClaims.map((claim) => evaluateClaim(claim, reviewPolicy))
const summary = {
totalClaims: decisions.length,
passed: decisions.filter((decision) => decision.status === "pass").length,
revise: decisions.filter((decision) => decision.status === "revise").length,
held: decisions.filter((decision) => decision.status === "hold").length,
reviewerActions: decisions.reduce((sum, decision) => sum + decision.reviewerActions.length, 0),
}

return {
generatedAt: "2026-05-21T14:05:00.000Z",
policy: reviewPolicy,
summary,
decisions,
audit: {
source: "synthetic-study-power-feasibility-review",
digest: digestFor({ summary, decisions }),
},
}
}

module.exports = {
evaluateClaim,
evaluateStudyPortfolio,
requiredPerGroup,
}
12 changes: 12 additions & 0 deletions study-power-feasibility-assistant/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "study-power-feasibility-assistant",
"version": "1.0.0",
"description": "Statistical power and effect-size feasibility assistant for SCIBASE issue #16",
"private": true,
"scripts": {
"check": "node --check index.js && node --check sample-data.js && node --check test.js && node --check demo.js",
"test": "node test.js",
"demo": "node demo.js"
},
"license": "MIT"
}
Binary file not shown.
Loading