diff --git a/ai-research-redline-packet/README.md b/ai-research-redline-packet/README.md new file mode 100644 index 0000000..674c968 --- /dev/null +++ b/ai-research-redline-packet/README.md @@ -0,0 +1,69 @@ +# AI Research Redline Packet + +This module adds a focused AI-assisted research-tool slice for SCIBASE. It does +not try to be another broad paper summarizer or generic citation formatter. +Instead, it produces an evidence-backed pre-submission redline packet that tells +researchers what must be fixed before sharing a manuscript. + +The workflow is deterministic and dependency-free, making it suitable for local +review checks, future API handlers, or reviewer-facing demo artifacts. + +## What It Covers + +- Generates abstract, executive, and layperson summary deltas from a structured + manuscript sample. +- Maps manuscript claims to evidence spans and flags unsupported claims. +- Detects statistical redlines such as missing confidence intervals and + inconsistent p-value language. +- Checks compliance items such as ethics statements and data availability. +- Ranks citation gap recommendations with transparent reasons. +- Routes issues to reviewer templates and creates insertion-ready revision + tasks. +- Includes tests, synthetic sample data, a CLI demo, requirement mapping, and a + short demo video. + +## Quick Start + +```bash +npm run check +npm test +npm run demo +``` + +Expected demo summary: + +```text +Packet decision: revise-before-submit +Redlines: 5 +Revision tasks: 5 +Citation recommendations: 3 +``` + +## Repository Layout + +```text +ai-research-redline-packet/ + data/sample-manuscript.json + docs/demo.svg + docs/demo.mp4 + docs/requirement-map.md + scripts/demo.js + src/redline-packet.js + test/redline-packet.test.js +``` + +## Design Notes + +The redline packet sits between AI summarization, AI peer review, and AI +citation assistance: + +1. A manuscript is parsed into claims, evidence spans, results, compliance + statements, and citations. +2. Summary modes are generated from the same evidence inventory. +3. Unsupported claims, statistical issues, compliance gaps, and citation gaps + become reviewer-facing redlines. +4. Redlines are converted into concrete revision tasks with insertion targets. +5. The final packet has an audit digest so reviewers can reproduce the decision. + +This gives researchers an actionable pre-review artifact rather than a loose AI +chat transcript. diff --git a/ai-research-redline-packet/data/sample-manuscript.json b/ai-research-redline-packet/data/sample-manuscript.json new file mode 100644 index 0000000..0d72b27 --- /dev/null +++ b/ai-research-redline-packet/data/sample-manuscript.json @@ -0,0 +1,168 @@ +{ + "schemaVersion": "ai-redline-packet.v1", + "manuscript": { + "id": "ms-neuro-organoid-response", + "title": "Transcriptomic Response of Neural Organoids to Microfluidic Oxygen Gradients", + "domain": "biology", + "targetJournal": "Nature Biomedical Engineering", + "stage": "pre-submission", + "sections": [ + { + "id": "abstract", + "heading": "Abstract", + "text": "We report a microfluidic oxygen-gradient platform for neural organoids. Treated organoids showed increased maturation markers and improved viability after seven days." + }, + { + "id": "results", + "heading": "Results", + "text": "Marker GFAP increased by 22 percent after treatment. Organoids had significantly better viability with p = 0.08. The platform eliminates batch effects across all donor lines." + }, + { + "id": "methods", + "heading": "Methods", + "text": "Organoids from three donor lines were cultured in a controlled oxygen gradient. Analysis code and raw count tables were deposited with the project repository." + }, + { + "id": "ethics", + "heading": "Ethics", + "text": "Human induced pluripotent stem cell lines were obtained from a certified biobank." + } + ], + "claims": [ + { + "id": "claim-marker-maturation", + "text": "GFAP expression increased by 22 percent after seven days of treatment.", + "sectionId": "results", + "evidenceSpanIds": ["span-gfap-table"], + "citationIds": ["smith-2024-organoid-maturation"], + "importance": "key-finding" + }, + { + "id": "claim-viability-significance", + "text": "Treated organoids had significantly better viability.", + "sectionId": "results", + "evidenceSpanIds": ["span-viability-pvalue"], + "citationIds": [], + "importance": "key-finding" + }, + { + "id": "claim-eliminates-batch-effects", + "text": "The platform eliminates batch effects across all donor lines.", + "sectionId": "results", + "evidenceSpanIds": [], + "citationIds": [], + "importance": "broad-claim" + }, + { + "id": "claim-data-deposited", + "text": "Analysis code and raw count tables were deposited with the project repository.", + "sectionId": "methods", + "evidenceSpanIds": ["span-repository-link"], + "citationIds": ["project-repository-v2"], + "importance": "reproducibility" + } + ], + "evidenceSpans": [ + { + "id": "span-gfap-table", + "sectionId": "results", + "quote": "Marker GFAP increased by 22 percent after treatment.", + "artifact": "results/gfap-differential-expression.csv", + "confidence": 0.91 + }, + { + "id": "span-viability-pvalue", + "sectionId": "results", + "quote": "Organoids had significantly better viability with p = 0.08.", + "artifact": "results/viability-analysis.json", + "confidence": 0.72 + }, + { + "id": "span-repository-link", + "sectionId": "methods", + "quote": "Analysis code and raw count tables were deposited with the project repository.", + "artifact": "metadata/repository-export.json", + "confidence": 0.86 + } + ], + "statisticalChecks": [ + { + "id": "stat-viability-pvalue", + "claimId": "claim-viability-significance", + "metric": "viability", + "pValue": 0.08, + "claimsSignificant": true, + "confidenceInterval": null, + "sampleSize": 3 + }, + { + "id": "stat-gfap-effect", + "claimId": "claim-marker-maturation", + "metric": "GFAP expression", + "pValue": 0.012, + "claimsSignificant": true, + "confidenceInterval": [0.14, 0.31], + "sampleSize": 3 + } + ], + "compliance": { + "ethicsStatementPresent": true, + "irbProtocolId": null, + "dataAvailabilityStatementPresent": true, + "codeAvailabilityStatementPresent": true, + "conflictOfInterestPresent": false, + "fundingStatementPresent": true + }, + "citations": [ + { + "id": "smith-2024-organoid-maturation", + "title": "Organoid maturation markers under controlled oxygen gradients", + "year": 2024, + "style": "Nature", + "supportsClaimIds": ["claim-marker-maturation"] + }, + { + "id": "project-repository-v2", + "title": "SCIBASE project repository export preprint-v2", + "year": 2026, + "style": "DataCite", + "supportsClaimIds": ["claim-data-deposited"] + } + ], + "candidateCitations": [ + { + "id": "lee-2025-donor-batch-effects", + "title": "Donor-line batch effects in neural organoid transcriptomics", + "year": 2025, + "reason": "Needed before making a broad batch-effect claim.", + "claimIds": ["claim-eliminates-batch-effects"] + }, + { + "id": "nguyen-2024-viability-statistics", + "title": "Statistical reporting standards for organoid viability assays", + "year": 2024, + "reason": "Supports corrected viability reporting and confidence intervals.", + "claimIds": ["claim-viability-significance"] + }, + { + "id": "icmje-2025-ethics-reporting", + "title": "Research ethics and disclosure reporting guidance", + "year": 2025, + "reason": "Helps complete ethics protocol and disclosure statements.", + "claimIds": [] + } + ] + }, + "reviewTemplates": [ + { + "id": "biology-pre-submission", + "name": "Biology pre-submission review", + "requiredTopics": ["evidence", "statistics", "ethics", "data-availability", "citations"] + }, + { + "id": "statistical-methods", + "name": "Statistical methods review", + "requiredTopics": ["statistics", "sample-size", "effect-size"] + } + ] +} diff --git a/ai-research-redline-packet/docs/demo.mp4 b/ai-research-redline-packet/docs/demo.mp4 new file mode 100644 index 0000000..63a943b Binary files /dev/null and b/ai-research-redline-packet/docs/demo.mp4 differ diff --git a/ai-research-redline-packet/docs/demo.svg b/ai-research-redline-packet/docs/demo.svg new file mode 100644 index 0000000..d4378f0 --- /dev/null +++ b/ai-research-redline-packet/docs/demo.svg @@ -0,0 +1,55 @@ + + AI research redline packet demo + Dashboard showing AI-assisted manuscript redlines, citation recommendations, and revision tasks. + + + AI Research Redline Packet + Pre-submission action packet for organoid manuscript review + + REVISE BEFORE SUBMIT + + + Redlines + 5 + issues found + + + Blockers + 2 + must fix first + + + Revision tasks + 5 + ready to insert + + + Citation recs + 3 + ranked gaps + + + Reviewer redlines + + + + Unsupported broad claim + Batch-effect elimination claim has no linked evidence span. + + blocker + + + p-value language + Viability described as significant with p = 0.08. + + blocker + + + Citation gap + Recommend donor-line batch effects and viability reporting sources. + + ranked + + + Revision packet: 5 insertion-ready tasks routed to biology pre-submission and statistical-methods templates. + diff --git a/ai-research-redline-packet/docs/requirement-map.md b/ai-research-redline-packet/docs/requirement-map.md new file mode 100644 index 0000000..c44b36f --- /dev/null +++ b/ai-research-redline-packet/docs/requirement-map.md @@ -0,0 +1,40 @@ +# Requirement Map + +Issue: SCIBASE-AI/SCIBASE.AI#13, AI-Assisted Research Tools (MVP Level). + +## AI Paper Summarizer + +The packet emits abstract, executive, and layperson summary modes from the same +structured manuscript evidence. It also reports summary deltas such as how many +key findings, reproducibility claims, and broad claims require review. + +## AI Peer Review Aid + +The redline engine flags unsupported claims, statistical reporting issues, +missing confidence intervals, ethics protocol gaps, and missing disclosure +statements. Each redline carries severity, insertion target, and evidence digest +so reviewers can audit why it was raised. + +## AI Citation Tool + +Citation recommendations are ranked against the redlines they would help fix. +The output includes transparent reasons and claim ids, making citations +insertion-ready rather than opaque suggestions. + +## Review Templates + +The packet routes redlines to domain-specific reviewer templates such as biology +pre-submission review and statistical methods review. This maps AI findings into +review workflows that institutions can standardize. + +## Actionable Output + +Every redline becomes a revision task with an insertion target and action text. +This supports one-click manuscript editing or PR-like review queues in a future +SCIBASE interface. + +## Scope Boundary + +This is not another generic summarizer or chatbot transcript. It is a +deterministic pre-submission redline packet that ties summaries, peer-review +diagnostics, citation recommendations, and revision tasks together. diff --git a/ai-research-redline-packet/package.json b/ai-research-redline-packet/package.json new file mode 100644 index 0000000..0d36f34 --- /dev/null +++ b/ai-research-redline-packet/package.json @@ -0,0 +1,15 @@ +{ + "name": "ai-research-redline-packet", + "version": "0.1.0", + "description": "Dependency-free evidence redline packet for AI-assisted research review workflows.", + "private": true, + "scripts": { + "check": "node scripts/demo.js --json > /dev/null", + "demo": "node scripts/demo.js", + "test": "node --test test/*.test.js" + }, + "engines": { + "node": ">=18" + }, + "license": "MIT" +} diff --git a/ai-research-redline-packet/scripts/demo.js b/ai-research-redline-packet/scripts/demo.js new file mode 100644 index 0000000..3acfe21 --- /dev/null +++ b/ai-research-redline-packet/scripts/demo.js @@ -0,0 +1,17 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { createRedlinePacket, formatPacketReport } = require("../src/redline-packet"); + +const manifestPath = path.join(__dirname, "..", "data", "sample-manuscript.json"); +const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); +const packet = createRedlinePacket(manifest); + +if (process.argv.includes("--json")) { + process.stdout.write(`${JSON.stringify(packet, null, 2)}\n`); +} else { + process.stdout.write(`${formatPacketReport(packet)}\n`); +} + +if (packet.decision === "invalid-manifest") { + process.exitCode = 1; +} diff --git a/ai-research-redline-packet/src/redline-packet.js b/ai-research-redline-packet/src/redline-packet.js new file mode 100644 index 0000000..a73eaf1 --- /dev/null +++ b/ai-research-redline-packet/src/redline-packet.js @@ -0,0 +1,307 @@ +const crypto = require("node:crypto"); + +function stableStringify(value) { + if (Array.isArray(value)) { + return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; + } + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function digest(value) { + return `sha256:${crypto.createHash("sha256").update(stableStringify(value)).digest("hex")}`; +} + +function validateManifest(manifest) { + const errors = []; + if (manifest.schemaVersion !== "ai-redline-packet.v1") { + errors.push("schemaVersion must be ai-redline-packet.v1"); + } + if (!manifest.manuscript?.id) { + errors.push("manuscript.id is required"); + } + if (!Array.isArray(manifest.manuscript?.claims)) { + errors.push("manuscript.claims must be an array"); + } + if (!Array.isArray(manifest.manuscript?.evidenceSpans)) { + errors.push("manuscript.evidenceSpans must be an array"); + } + for (const claim of manifest.manuscript?.claims || []) { + if (!claim.id || !claim.text || !claim.sectionId) { + errors.push("every claim requires id, text, and sectionId"); + } + } + for (const span of manifest.manuscript?.evidenceSpans || []) { + if (!span.id || !span.quote || !span.artifact) { + errors.push("every evidence span requires id, quote, and artifact"); + } + } + return errors; +} + +function indexById(items) { + return new Map(items.map((item) => [item.id, item])); +} + +function summarize(manuscript) { + const keyClaims = manuscript.claims.filter((claim) => claim.importance === "key-finding"); + const reproducibilityClaims = manuscript.claims.filter((claim) => claim.importance === "reproducibility"); + const broadClaims = manuscript.claims.filter((claim) => claim.importance === "broad-claim"); + + return { + abstract: `${manuscript.title}: ${keyClaims.map((claim) => claim.text).join(" ")}`, + executive: [ + `${keyClaims.length} key finding(s) are ready for evidence review.`, + `${reproducibilityClaims.length} reproducibility claim(s) mention project repository evidence.`, + `${broadClaims.length} broad claim(s) need tighter support before submission.` + ].join(" "), + layperson: "The study tests whether a controlled oxygen environment changes neural organoid health, but some claims need clearer evidence and reporting before submission." + }; +} + +function detectEvidenceRedlines(manuscript) { + const evidenceIndex = indexById(manuscript.evidenceSpans); + return manuscript.claims + .filter((claim) => claim.evidenceSpanIds.length === 0) + .map((claim) => ({ + id: `evidence-${claim.id}`, + type: "evidence", + severity: claim.importance === "broad-claim" ? "blocker" : "major", + claimId: claim.id, + sectionId: claim.sectionId, + message: "Claim has no linked evidence span.", + insertionTarget: claim.sectionId, + evidenceDigest: digest({ claim: claim.text, evidence: [] }) + })) + .concat( + manuscript.claims + .filter((claim) => claim.evidenceSpanIds.some((id) => !evidenceIndex.has(id))) + .map((claim) => ({ + id: `missing-evidence-${claim.id}`, + type: "evidence", + severity: "major", + claimId: claim.id, + sectionId: claim.sectionId, + message: "Claim references an evidence span that is not present in the manuscript packet.", + insertionTarget: claim.sectionId, + evidenceDigest: digest({ claim: claim.text, evidenceSpanIds: claim.evidenceSpanIds }) + })) + ); +} + +function detectStatisticalRedlines(manuscript) { + const redlines = []; + for (const check of manuscript.statisticalChecks || []) { + if (check.claimsSignificant && check.pValue >= 0.05) { + redlines.push({ + id: `stat-significance-${check.id}`, + type: "statistics", + severity: "blocker", + claimId: check.claimId, + sectionId: manuscript.claims.find((claim) => claim.id === check.claimId)?.sectionId || "results", + message: `${check.metric} is described as significant but p=${check.pValue}.`, + insertionTarget: "results", + evidenceDigest: digest(check) + }); + } + if (!check.confidenceInterval) { + redlines.push({ + id: `stat-ci-${check.id}`, + type: "statistics", + severity: "major", + claimId: check.claimId, + sectionId: manuscript.claims.find((claim) => claim.id === check.claimId)?.sectionId || "results", + message: `${check.metric} is missing a confidence interval.`, + insertionTarget: "results", + evidenceDigest: digest(check) + }); + } + } + return redlines; +} + +function detectComplianceRedlines(manuscript) { + const items = []; + const compliance = manuscript.compliance || {}; + if (compliance.ethicsStatementPresent && !compliance.irbProtocolId) { + items.push({ + id: "compliance-irb-protocol", + type: "ethics", + severity: "major", + claimId: null, + sectionId: "ethics", + message: "Ethics statement is present but lacks an IRB/protocol identifier.", + insertionTarget: "ethics", + evidenceDigest: digest({ ethicsStatementPresent: true, irbProtocolId: null }) + }); + } + if (!compliance.conflictOfInterestPresent) { + items.push({ + id: "compliance-conflict-disclosure", + type: "compliance", + severity: "major", + claimId: null, + sectionId: "disclosures", + message: "Conflict-of-interest disclosure is missing.", + insertionTarget: "disclosures", + evidenceDigest: digest({ conflictOfInterestPresent: false }) + }); + } + return items; +} + +function rankCitationRecommendations(manuscript, redlines) { + const claimRedlineIds = new Set(redlines.map((redline) => redline.claimId).filter(Boolean)); + return manuscript.candidateCitations + .map((citation) => { + const matchingRedlineCount = citation.claimIds.filter((claimId) => claimRedlineIds.has(claimId)).length; + const score = matchingRedlineCount * 2 + (citation.year >= 2024 ? 1 : 0); + return { + id: citation.id, + title: citation.title, + year: citation.year, + score, + reason: citation.reason, + claimIds: citation.claimIds + }; + }) + .sort((a, b) => b.score - a.score || b.year - a.year); +} + +function routeReviewTemplates(manifest, redlines) { + const topicSet = new Set(redlines.map((redline) => redline.type)); + if (redlines.some((redline) => redline.type === "statistics")) { + topicSet.add("sample-size"); + topicSet.add("effect-size"); + } + if (redlines.some((redline) => redline.type === "compliance")) { + topicSet.add("data-availability"); + } + + return manifest.reviewTemplates.map((template) => { + const matchedTopics = template.requiredTopics.filter((topic) => topicSet.has(topic)); + return { + id: template.id, + name: template.name, + matchedTopics, + recommended: matchedTopics.length > 0 + }; + }); +} + +function buildRevisionTasks(redlines, citations) { + return redlines.map((redline, index) => { + const citation = citations.find((candidate) => candidate.claimIds.includes(redline.claimId)); + return { + id: `task-${String(index + 1).padStart(2, "0")}`, + severity: redline.severity, + insertionTarget: redline.insertionTarget, + redlineId: redline.id, + action: citation + ? `${redline.message} Add or discuss citation: ${citation.title}.` + : redline.message, + readyForInsert: true + }; + }); +} + +function createRedlinePacket(manifest) { + const validationErrors = validateManifest(manifest); + if (validationErrors.length > 0) { + return { + valid: false, + validationErrors, + decision: "invalid-manifest", + redlines: [], + revisionTasks: [], + citationRecommendations: [], + summary: { + redlines: 0, + revisionTasks: 0, + citationRecommendations: 0 + } + }; + } + + const manuscript = manifest.manuscript; + const redlines = [ + ...detectEvidenceRedlines(manuscript), + ...detectStatisticalRedlines(manuscript), + ...detectComplianceRedlines(manuscript) + ]; + const citationRecommendations = rankCitationRecommendations(manuscript, redlines); + const revisionTasks = buildRevisionTasks(redlines, citationRecommendations); + const templateRouting = routeReviewTemplates(manifest, redlines); + const decision = redlines.some((redline) => redline.severity === "blocker") + ? "revise-before-submit" + : redlines.length > 0 + ? "minor-revision" + : "ready"; + + const summary = { + manuscriptId: manuscript.id, + decision, + redlines: redlines.length, + blockers: redlines.filter((redline) => redline.severity === "blocker").length, + revisionTasks: revisionTasks.length, + citationRecommendations: citationRecommendations.length + }; + + return { + valid: true, + validationErrors: [], + decision, + summaries: summarize(manuscript), + redlines, + revisionTasks, + citationRecommendations, + templateRouting, + summary, + auditDigest: digest({ + summary, + redlines: redlines.map((redline) => ({ + id: redline.id, + severity: redline.severity, + evidenceDigest: redline.evidenceDigest + })) + }) + }; +} + +function formatPacketReport(packet) { + if (!packet.valid) { + return [`Manifest invalid:`, ...packet.validationErrors.map((error) => `- ${error}`)].join("\n"); + } + + const lines = [ + `Packet decision: ${packet.decision}`, + `Redlines: ${packet.summary.redlines}`, + `Blockers: ${packet.summary.blockers}`, + `Revision tasks: ${packet.summary.revisionTasks}`, + `Citation recommendations: ${packet.summary.citationRecommendations}`, + `Audit digest: ${packet.auditDigest}`, + "", + `Executive summary: ${packet.summaries.executive}`, + "" + ]; + + for (const redline of packet.redlines) { + lines.push(`${redline.severity.toUpperCase()} ${redline.id}`); + lines.push(` ${redline.message}`); + } + + return lines.join("\n"); +} + +module.exports = { + createRedlinePacket, + digest, + formatPacketReport, + stableStringify, + validateManifest +}; diff --git a/ai-research-redline-packet/test/redline-packet.test.js b/ai-research-redline-packet/test/redline-packet.test.js new file mode 100644 index 0000000..3f14fc5 --- /dev/null +++ b/ai-research-redline-packet/test/redline-packet.test.js @@ -0,0 +1,85 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const { + createRedlinePacket, + digest, + stableStringify, + validateManifest +} = require("../src/redline-packet"); + +const samplePath = path.join(__dirname, "..", "data", "sample-manuscript.json"); + +function loadSample() { + return JSON.parse(fs.readFileSync(samplePath, "utf8")); +} + +test("creates revise-before-submit packet with evidence, statistics, and compliance redlines", () => { + const packet = createRedlinePacket(loadSample()); + + assert.equal(packet.decision, "revise-before-submit"); + assert.equal(packet.summary.redlines, 5); + assert.equal(packet.summary.blockers, 2); + assert.equal(packet.summary.revisionTasks, 5); + assert.match(packet.auditDigest, /^sha256:[a-f0-9]{64}$/); +}); + +test("flags unsupported broad claims as blockers", () => { + const packet = createRedlinePacket(loadSample()); + const redline = packet.redlines.find((item) => item.id === "evidence-claim-eliminates-batch-effects"); + + assert.equal(redline.severity, "blocker"); + assert.equal(redline.type, "evidence"); + assert.match(redline.message, /no linked evidence/); +}); + +test("detects inconsistent p-value significance language and missing confidence interval", () => { + const packet = createRedlinePacket(loadSample()); + const ids = packet.redlines.map((redline) => redline.id); + + assert.equal(ids.includes("stat-significance-stat-viability-pvalue"), true); + assert.equal(ids.includes("stat-ci-stat-viability-pvalue"), true); +}); + +test("ranks citation recommendations based on claim redlines", () => { + const packet = createRedlinePacket(loadSample()); + + assert.equal(packet.citationRecommendations[0].id, "lee-2025-donor-batch-effects"); + assert.equal(packet.citationRecommendations[1].id, "nguyen-2024-viability-statistics"); +}); + +test("builds insertion-ready revision tasks", () => { + const packet = createRedlinePacket(loadSample()); + + assert.equal(packet.revisionTasks.every((task) => task.readyForInsert), true); + assert.equal( + packet.revisionTasks.some((task) => task.action.includes("Donor-line batch effects")), + true + ); +}); + +test("routes redlines to relevant reviewer templates", () => { + const packet = createRedlinePacket(loadSample()); + const stats = packet.templateRouting.find((template) => template.id === "statistical-methods"); + + assert.equal(stats.recommended, true); + assert.deepEqual(stats.matchedTopics.sort(), ["effect-size", "sample-size", "statistics"]); +}); + +test("validates manifest shape", () => { + const manifest = loadSample(); + delete manifest.manuscript.id; + + const errors = validateManifest(manifest); + + assert.equal(errors.some((error) => error.includes("manuscript.id")), true); +}); + +test("uses deterministic stable digests", () => { + const left = { b: 2, a: { d: 4, c: 3 } }; + const right = { a: { c: 3, d: 4 }, b: 2 }; + + assert.equal(stableStringify(left), stableStringify(right)); + assert.equal(digest(left), digest(right)); +});