From a41db0fbf12a0b6e7c7d6f720cc48635d8ddfbfa Mon Sep 17 00:00:00 2001 From: ReiDo Date: Sat, 16 May 2026 00:43:06 +0900 Subject: [PATCH] Add a research gap replication planner The SCIBASE bounty has already rewarded broad assistant-suite, protocol-trace, and evidence-grounding modules. This contribution targets the research-gap-to-replication planning boundary: peer-review risk, reproducibility confidence, and lab-fit opportunity ranking in one dependency-free package. Constraint: Must avoid credentials, live services, and duplicate issue 16 submissions Rejected: Broad research assistant clone | overlaps with existing rewarded modules Confidence: medium Scope-risk: narrow Directive: Keep this module self-contained unless maintainers ask for integration Tested: npm run check; ffprobe demo.mp4; git diff --check; secret pattern scan Not-tested: Maintainer reward decision and Algora payout --- research-gap-replication-planner/README.md | 70 +++++ .../docs/demo.mp4 | Bin 0 -> 37828 bytes .../docs/demo.svg | 16 ++ .../docs/requirement-map.md | 13 + research-gap-replication-planner/package.json | 12 + .../sample/corpus.json | 109 +++++++ .../scripts/demo.js | 21 ++ .../src/planner.js | 272 ++++++++++++++++++ .../test/planner.test.js | 48 ++++ 9 files changed, 561 insertions(+) create mode 100644 research-gap-replication-planner/README.md create mode 100644 research-gap-replication-planner/docs/demo.mp4 create mode 100644 research-gap-replication-planner/docs/demo.svg create mode 100644 research-gap-replication-planner/docs/requirement-map.md create mode 100644 research-gap-replication-planner/package.json create mode 100644 research-gap-replication-planner/sample/corpus.json create mode 100644 research-gap-replication-planner/scripts/demo.js create mode 100644 research-gap-replication-planner/src/planner.js create mode 100644 research-gap-replication-planner/test/planner.test.js diff --git a/research-gap-replication-planner/README.md b/research-gap-replication-planner/README.md new file mode 100644 index 0000000..c9024f6 --- /dev/null +++ b/research-gap-replication-planner/README.md @@ -0,0 +1,70 @@ +# Research Gap Replication Planner + +Self-contained contribution for `SCIBASE-AI/SCIBASE.AI#16`, focused on the boundary between the AI Research Assistant Suite's peer-review, reproducibility, and research-gap capabilities. + +This slice is intentionally not another broad assistant demo. It builds a deterministic planner that turns corpus limitations, prior reproducibility attempts, manuscript evidence coverage, and lab capabilities into reviewer-ready replication priorities. + +## What It Does + +- Generates auto peer-review flags for claim/evidence/citation alignment, statistical risk, and domain-specific omissions. +- Scores project reproducibility readiness from raw data, clean pipelines, environment locks, tests, reported outputs, and previous run attempts. +- Finds under-replicated research intersections with unresolved findings, negative-result signals, and lab-fit evidence. +- Produces a prioritized replication plan with required artifacts, first actions, and a stable audit digest. +- Uses only local synthetic data and Node.js standard library APIs. + +## Requirement Map + +| Issue #16 requirement | Implementation evidence | +| --- | --- | +| Auto peer review reports | `reviewManuscript` emits structured review findings with severity, category, claim id, and domain-specific template checks. | +| Claims vs. evidence alignment | Claims without evidence, citations, or registered support are flagged before release. | +| Statistical or methodological red flags | Small-sample p-value edge cases, missing controls, and missing domain evidence are detected. | +| Reproducibility checker | `scoreReproducibility` checks required artifacts, prior attempts, environment integrity, and output consistency. | +| Reproducibility confidence score | Each project receives a numeric confidence score plus pass/hold status and remediation actions. | +| Links to previous reproducibility attempts | Prior attempt ids and outcomes are included in each report and used in scoring. | +| Research gap finder | `rankResearchOpportunities` ranks under-replicated topic intersections using unresolved findings, negative signals, replication counts, and corpus activity. | +| User opportunity feed | Lab capabilities and researcher interests shape the replication-priority output. | + +See `docs/requirement-map.md` for the detailed checklist. + +## Demo + +```bash +cd research-gap-replication-planner +npm run demo +``` + +Expected summary: + +```text +Planner status: hold +Top opportunity: alzheimer + crispr + single-cell +Top replication decision: prioritize +Top peer-review flag: Claim has no supporting evidence artifact. +Weak reproducibility project: circadian-metabolomics-2026 +Audit hash: de1b1fa44644dc24... +``` + +Visual demo assets: + +- `docs/demo.svg` +- `docs/demo.mp4` + +## Verification + +```bash +cd research-gap-replication-planner +npm run check +``` + +Local verification covers: + +- opportunity ranking +- peer-review flag generation +- reproducibility scoring +- deterministic audit hash generation +- CLI demo output + +## AI-Assisted Disclosure + +This contribution was produced with AI assistance and reviewed before submission. It uses no credentials, no live user data, and no external services. diff --git a/research-gap-replication-planner/docs/demo.mp4 b/research-gap-replication-planner/docs/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..2557917758eb83bfcfdb9a541a5f0c3c4cd22e55 GIT binary patch literal 37828 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 z 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 + + + Research Gap Replication Planner + Issue #16 AI Research Assistant Suite + + 1. Auto peer-review flags unsupported claims + 2. Reproducibility confidence scores project artifacts + 3. Gap finder ranks under-replicated topic intersections + 4. Replication plan lists required artifacts and first actions + + + Top: CRISPR + Alzheimer + single-cell + + Decision: prioritize + diff --git a/research-gap-replication-planner/docs/requirement-map.md b/research-gap-replication-planner/docs/requirement-map.md new file mode 100644 index 0000000..6e4e6a6 --- /dev/null +++ b/research-gap-replication-planner/docs/requirement-map.md @@ -0,0 +1,13 @@ +# Requirement Map + +| Requirement | Evidence in this module | Verification | +| --- | --- | --- | +| Natural-language review suggestions | `src/planner.js` creates reviewer action text for unsupported claims, missing citations, small samples, and domain-specific omissions. | `test/planner.test.js` checks the highest severity review finding. | +| Clarity and coherence checks | Review packets flag overbroad claims that lack an evidence artifact or citation trail. | `npm test` validates unsupported-claim detection. | +| Statistical or methodological red flags | Small sample sizes near significance thresholds and missing domain controls are flagged. | `npm test` checks statistical risk categories. | +| Claims vs. evidence alignment | Every claim is checked for evidence ids, citation ids, protocol linkage, and method support. | `npm test` checks claim alignment output. | +| Reproducibility confidence | Projects are scored from raw data, clean pipeline, environment lock, tests, reported output, and prior attempts. | `npm test` verifies low-confidence and high-confidence cases. | +| Previous reproducibility attempts | Attempt records are attached to project reports and affect the score. | `npm test` verifies failed attempts reduce confidence. | +| Research gap finder | Corpus findings are grouped by topic intersection and scored for unresolved status, replication count, corpus activity, lab fit, and feasibility. | `npm test` verifies the top ranked topic intersection. | +| Personalized opportunity feed | Lab capabilities and researcher interests are used to shape priority and first actions. | `npm run demo` prints the ranked plan and audit hash. | +| Reviewer-ready evidence | Output includes required artifacts, first actions, gap rationale, and audit digest. | `npm run check` runs tests plus demo. | diff --git a/research-gap-replication-planner/package.json b/research-gap-replication-planner/package.json new file mode 100644 index 0000000..a52f858 --- /dev/null +++ b/research-gap-replication-planner/package.json @@ -0,0 +1,12 @@ +{ + "name": "research-gap-replication-planner", + "version": "0.1.0", + "description": "Deterministic research gap and replication-priority planner for SCIBASE issue #16.", + "type": "module", + "scripts": { + "demo": "node scripts/demo.js", + "test": "node test/planner.test.js", + "check": "npm test && npm run demo" + }, + "license": "MIT" +} diff --git a/research-gap-replication-planner/sample/corpus.json b/research-gap-replication-planner/sample/corpus.json new file mode 100644 index 0000000..1ce8001 --- /dev/null +++ b/research-gap-replication-planner/sample/corpus.json @@ -0,0 +1,109 @@ +{ + "lab": { + "name": "Neurogenomics Replication Lab", + "interests": ["Alzheimer", "CRISPR", "single-cell", "metabolomics"], + "capabilities": ["single-cell", "RNA-seq", "CRISPR perturbation", "metabolomics"], + "methods": ["notebook rerun", "containerized workflow"] + }, + "manuscripts": [ + { + "id": "ms-neuro-042", + "title": "Perturbation response in single-cell Alzheimer models", + "domain": "molecular-biology", + "claims": [ + { + "id": "c1", + "text": "CRISPR perturbation improves cell viability across all donor lines.", + "sampleSize": 18, + "pValue": 0.051, + "evidenceIds": [], + "citationIds": ["doi:10.1000/example-a"] + }, + { + "id": "c2", + "text": "A subtype-specific response appears in microglia-like cells.", + "sampleSize": 64, + "pValue": 0.02, + "evidenceIds": ["notebook:microglia-response"], + "citationIds": [] + } + ] + } + ], + "projects": [ + { + "id": "circadian-metabolomics-2026", + "title": "Circadian metabolomics benchmark", + "dependencyIntegrity": "stale", + "outputConsistency": "drifted", + "artifacts": { + "rawData": true, + "cleanPipeline": true, + "environmentLock": false, + "testSet": false, + "reportedResults": true + } + }, + { + "id": "microglia-rnaseq-2026", + "title": "Microglia RNA-seq replication packet", + "dependencyIntegrity": "locked", + "outputConsistency": "consistent", + "artifacts": { + "rawData": true, + "cleanPipeline": true, + "environmentLock": true, + "testSet": true, + "reportedResults": true + } + } + ], + "reproducibilityAttempts": [ + { + "id": "run-031", + "projectId": "circadian-metabolomics-2026", + "topicId": "finding-crispr-ad", + "outcome": "failed", + "note": "Pipeline required an unavailable R package version." + }, + { + "id": "run-044", + "projectId": "microglia-rnaseq-2026", + "topicId": "finding-crispr-ad", + "outcome": "passed", + "note": "Notebook reran with matching summary metrics." + } + ], + "corpusFindings": [ + { + "id": "finding-crispr-ad", + "title": "CRISPR perturbation in Alzheimer's single-cell models", + "topics": ["CRISPR", "Alzheimer", "single-cell"], + "methods": ["RNA-seq", "CRISPR perturbation"], + "citations": 126, + "activeProjects": 4, + "replicationCount": 1, + "limitation": "Unresolved donor variability and low replication across single-cell CRISPR Alzheimer studies." + }, + { + "id": "finding-sleep-metabolomics", + "title": "Sleep disruption metabolomics in aging cohorts", + "topics": ["sleep", "metabolomics", "aging"], + "methods": ["metabolomics"], + "citations": 38, + "activeProjects": 2, + "replicationCount": 3, + "limitation": "Negative results were rarely reported and methods varied across cohorts." + }, + { + "id": "finding-protein-folding", + "title": "Protein folding benchmark coverage", + "topics": ["protein folding", "benchmark"], + "methods": ["simulation"], + "citations": 14, + "activeProjects": 1, + "replicationCount": 5, + "limitation": "Benchmark appears stable with several successful replications." + } + ] +} diff --git a/research-gap-replication-planner/scripts/demo.js b/research-gap-replication-planner/scripts/demo.js new file mode 100644 index 0000000..279eea6 --- /dev/null +++ b/research-gap-replication-planner/scripts/demo.js @@ -0,0 +1,21 @@ +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { createResearchAssistantPlan } from "../src/planner.js" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const samplePath = path.join(__dirname, "..", "sample", "corpus.json") +const sample = JSON.parse(fs.readFileSync(samplePath, "utf8")) +const plan = createResearchAssistantPlan(sample) + +const topOpportunity = plan.opportunityFeed[0] +const topPlan = plan.replicationPlans[0] +const topFinding = plan.peerReviewReports[0].findings[0] +const weakProject = plan.reproducibilityReports.find((report) => report.status !== "ready") + +console.log(`Planner status: ${plan.status}`) +console.log(`Top opportunity: ${topOpportunity.topic}`) +console.log(`Top replication decision: ${topPlan.decision}`) +console.log(`Top peer-review flag: ${topFinding.message}`) +console.log(`Weak reproducibility project: ${weakProject.projectId}`) +console.log(`Audit hash: ${plan.auditHash.slice(0, 16)}...`) diff --git a/research-gap-replication-planner/src/planner.js b/research-gap-replication-planner/src/planner.js new file mode 100644 index 0000000..abe254d --- /dev/null +++ b/research-gap-replication-planner/src/planner.js @@ -0,0 +1,272 @@ +import crypto from "node:crypto" + +const REVIEW_FINDING_WEIGHTS = { + critical: 30, + high: 22, + medium: 12, + low: 6, +} + +const REQUIRED_REPRO_ARTIFACTS = [ + "rawData", + "cleanPipeline", + "environmentLock", + "testSet", + "reportedResults", +] + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)) +} + +function unique(values) { + return [...new Set(values.filter(Boolean))] +} + +function normalizeText(value) { + return String(value || "").toLowerCase().replace(/[^a-z0-9.+-]+/g, " ").trim() +} + +function hasAny(text, terms) { + const normalized = normalizeText(text) + return terms.some((term) => normalized.includes(normalizeText(term))) +} + +function intersectionCount(left = [], right = []) { + const rightSet = new Set(right.map((item) => normalizeText(item))) + return unique(left.map((item) => normalizeText(item))).filter((item) => rightSet.has(item)).length +} + +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 auditHash(value) { + return crypto.createHash("sha256").update(stableJson(value)).digest("hex") +} + +function topicKey(topics = []) { + return unique(topics.map(normalizeText)).sort().join(" + ") +} + +function scoreReproducibility(project, attempts = []) { + const artifacts = project.artifacts || {} + const missingArtifacts = REQUIRED_REPRO_ARTIFACTS.filter((name) => !artifacts[name]) + const projectAttempts = attempts.filter((attempt) => attempt.projectId === project.id) + const failedAttempts = projectAttempts.filter((attempt) => attempt.outcome === "failed") + const successfulAttempts = projectAttempts.filter((attempt) => attempt.outcome === "passed") + const nondeterministicAttempts = projectAttempts.filter((attempt) => attempt.outcome === "nondeterministic") + + const dependencyPenalty = project.dependencyIntegrity === "stale" ? 10 : 0 + const outputPenalty = project.outputConsistency === "drifted" ? 16 : 0 + const confidence = clamp( + 100 - + missingArtifacts.length * 12 - + failedAttempts.length * 14 - + nondeterministicAttempts.length * 10 - + dependencyPenalty - + outputPenalty + + successfulAttempts.length * 4, + 0, + 100, + ) + + const remediation = [] + if (missingArtifacts.length) remediation.push(`Add missing artifacts: ${missingArtifacts.join(", ")}`) + if (failedAttempts.length) remediation.push("Attach failure logs and rerun after environment repair") + if (nondeterministicAttempts.length) remediation.push("Stabilize random seeds and execution order") + if (project.dependencyIntegrity === "stale") remediation.push("Refresh dependency lock and runtime metadata") + if (project.outputConsistency === "drifted") remediation.push("Reconcile reported results against rerun outputs") + + return { + projectId: project.id, + title: project.title, + confidence, + status: confidence >= 75 ? "ready" : confidence >= 55 ? "review-needed" : "hold", + missingArtifacts, + priorAttempts: projectAttempts.map((attempt) => ({ + id: attempt.id, + outcome: attempt.outcome, + note: attempt.note, + })), + remediation, + } +} + +function reviewManuscript(manuscript) { + const findings = [] + for (const claim of manuscript.claims || []) { + if (!claim.evidenceIds?.length) { + findings.push({ + severity: "high", + category: "claims-vs-evidence", + claimId: claim.id, + message: "Claim has no supporting evidence artifact.", + action: "Link the claim to raw data, analysis output, or a reproducibility run before release.", + }) + } + if (!claim.citationIds?.length) { + findings.push({ + severity: "medium", + category: "citation-gap", + claimId: claim.id, + message: "Claim lacks a citation trail.", + action: "Add a citation or mark the claim as a new result with evidence context.", + }) + } + if (claim.sampleSize && claim.sampleSize < 30 && claim.pValue && claim.pValue > 0.045 && claim.pValue <= 0.055) { + findings.push({ + severity: "high", + category: "statistical-risk", + claimId: claim.id, + message: "Small-sample result is close to the significance threshold.", + action: "Add a power analysis, confidence interval, or replication plan.", + }) + } + if (manuscript.domain === "clinical-trials" && !claim.protocolOutcomeId) { + findings.push({ + severity: "medium", + category: "domain-template", + claimId: claim.id, + message: "Clinical claim is not linked to a registered protocol outcome.", + action: "Map the claim to a registered primary or secondary endpoint.", + }) + } + } + + const riskScore = findings.reduce((total, finding) => total + REVIEW_FINDING_WEIGHTS[finding.severity], 0) + findings.sort((a, b) => REVIEW_FINDING_WEIGHTS[b.severity] - REVIEW_FINDING_WEIGHTS[a.severity]) + + return { + manuscriptId: manuscript.id, + title: manuscript.title, + domain: manuscript.domain, + recommendation: riskScore >= 40 ? "hold" : riskScore >= 18 ? "revise" : "ready", + riskScore, + findings, + } +} + +function rankResearchOpportunities(input) { + const lab = input.lab || {} + const interests = lab.interests || [] + const capabilities = [...(lab.capabilities || []), ...(lab.methods || [])] + const attempts = input.reproducibilityAttempts || [] + + return (input.corpusFindings || []) + .map((finding) => { + const topics = finding.topics || [] + const methods = finding.methods || [] + const projectAttempts = attempts.filter((attempt) => attempt.topicId === finding.id) + const failedAttempts = projectAttempts.filter((attempt) => attempt.outcome !== "passed") + const unresolvedScore = hasAny(finding.limitation || "", [ + "unresolved", + "negative", + "null", + "underpowered", + "inconsistent", + "open question", + "low replication", + ]) + ? 22 + : 4 + const replicationScore = clamp((5 - (finding.replicationCount || 0)) * 9, 0, 45) + const activityScore = clamp((finding.citations || 0) / 8 + (finding.activeProjects || 0) * 3, 0, 22) + const fitScore = clamp(intersectionCount([...topics, ...methods], [...interests, ...capabilities]) * 8, 0, 24) + const failedAttemptScore = clamp(failedAttempts.length * 8, 0, 24) + const total = Math.round(unresolvedScore + replicationScore + activityScore + fitScore + failedAttemptScore) + + return { + id: finding.id, + topic: topicKey(topics), + title: finding.title, + score: total, + decision: total >= 80 ? "prioritize" : total >= 55 ? "review" : "watch", + rationale: [ + replicationScore >= 27 ? "low replication count" : null, + unresolvedScore >= 22 ? "unresolved or negative-result signal" : null, + activityScore >= 12 ? "active corpus momentum" : null, + fitScore >= 8 ? "fits lab capabilities" : null, + failedAttemptScore ? "previous reproducibility friction" : null, + ].filter(Boolean), + requiredArtifacts: ["raw data access", "protocol summary", "environment lock", "replication notebook"], + firstActions: [ + `Confirm scope for ${topicKey(topics)}`, + "Request raw data and analysis environment", + "Run minimal replication on a single endpoint", + ], + } + }) + .sort((a, b) => b.score - a.score || a.topic.localeCompare(b.topic)) +} + +function buildReplicationPlans(opportunities, reproducibilityReports) { + const weakestProjects = reproducibilityReports.filter((report) => report.status !== "ready").slice(0, 3) + return opportunities.slice(0, 5).map((opportunity, index) => ({ + opportunityId: opportunity.id, + topic: opportunity.topic, + priority: index + 1, + decision: opportunity.decision, + requiredArtifacts: opportunity.requiredArtifacts, + firstActions: opportunity.firstActions, + pairedReproducibilityRisks: weakestProjects.map((project) => ({ + projectId: project.projectId, + status: project.status, + topRemediation: project.remediation[0] || "No immediate remediation", + })), + })) +} + +function createResearchAssistantPlan(input) { + const peerReviewReports = (input.manuscripts || []).map(reviewManuscript) + const reproducibilityReports = (input.projects || []).map((project) => + scoreReproducibility(project, input.reproducibilityAttempts || []), + ) + const opportunityFeed = rankResearchOpportunities(input) + const replicationPlans = buildReplicationPlans(opportunityFeed, reproducibilityReports) + const status = [ + ...peerReviewReports.map((report) => report.recommendation), + ...reproducibilityReports.map((report) => report.status), + ...opportunityFeed.slice(0, 1).map((item) => item.decision), + ].includes("hold") + ? "hold" + : "review-needed" + + const result = { + status, + peerReviewReports, + reproducibilityReports, + opportunityFeed, + replicationPlans, + requirementCoverage: { + autoPeerReviewReports: peerReviewReports.length > 0, + claimsVsEvidenceAlignment: peerReviewReports.some((report) => + report.findings.some((finding) => finding.category === "claims-vs-evidence"), + ), + reproducibilityConfidence: reproducibilityReports.every((report) => Number.isFinite(report.confidence)), + previousAttemptLinks: reproducibilityReports.some((report) => report.priorAttempts.length > 0), + researchGapFinder: opportunityFeed.length > 0, + personalizedOpportunityFeed: replicationPlans.length > 0, + }, + } + + return { + ...result, + auditHash: auditHash(result), + } +} + +export { + auditHash, + createResearchAssistantPlan, + rankResearchOpportunities, + reviewManuscript, + scoreReproducibility, +} diff --git a/research-gap-replication-planner/test/planner.test.js b/research-gap-replication-planner/test/planner.test.js new file mode 100644 index 0000000..12f7434 --- /dev/null +++ b/research-gap-replication-planner/test/planner.test.js @@ -0,0 +1,48 @@ +import assert from "node:assert/strict" +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { + auditHash, + createResearchAssistantPlan, + rankResearchOpportunities, + reviewManuscript, + scoreReproducibility, +} from "../src/planner.js" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const sample = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "sample", "corpus.json"), "utf8")) + +const opportunities = rankResearchOpportunities(sample) +assert.equal(opportunities[0].topic, "alzheimer + crispr + single-cell") +assert.equal(opportunities[0].decision, "prioritize") +assert.ok(opportunities[0].rationale.includes("low replication count")) +assert.ok(opportunities[0].rationale.includes("fits lab capabilities")) + +const review = reviewManuscript(sample.manuscripts[0]) +assert.equal(review.recommendation, "hold") +assert.equal(review.findings[0].category, "claims-vs-evidence") +assert.equal(review.findings[0].message, "Claim has no supporting evidence artifact.") +assert.ok(review.findings.some((finding) => finding.category === "statistical-risk")) + +const weakRepro = scoreReproducibility(sample.projects[0], sample.reproducibilityAttempts) +assert.equal(weakRepro.status, "hold") +assert.ok(weakRepro.missingArtifacts.includes("environmentLock")) +assert.ok(weakRepro.priorAttempts.some((attempt) => attempt.outcome === "failed")) + +const strongRepro = scoreReproducibility(sample.projects[1], sample.reproducibilityAttempts) +assert.equal(strongRepro.status, "ready") +assert.equal(strongRepro.missingArtifacts.length, 0) + +const plan = createResearchAssistantPlan(sample) +assert.equal(plan.status, "hold") +assert.equal(plan.requirementCoverage.autoPeerReviewReports, true) +assert.equal(plan.requirementCoverage.claimsVsEvidenceAlignment, true) +assert.equal(plan.requirementCoverage.reproducibilityConfidence, true) +assert.equal(plan.requirementCoverage.previousAttemptLinks, true) +assert.equal(plan.requirementCoverage.researchGapFinder, true) +assert.equal(plan.requirementCoverage.personalizedOpportunityFeed, true) +assert.match(plan.auditHash, /^[a-f0-9]{64}$/) +assert.equal(auditHash({ b: 2, a: 1 }), auditHash({ a: 1, b: 2 })) + +console.log("research-gap-replication-planner tests passed")