diff --git a/README.md b/README.md index d338cf6..fd47d94 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,4 @@ # deepevents.ai deepevents.ai main codebase + +- `ethics-data-availability-checker/` adds ethics, consent, data availability, and reproducibility readiness checks for research review packets. diff --git a/ethics-data-availability-checker/README.md b/ethics-data-availability-checker/README.md new file mode 100644 index 0000000..1c0f4b5 --- /dev/null +++ b/ethics-data-availability-checker/README.md @@ -0,0 +1,39 @@ +# Ethics Data Availability Checker + +This module adds a focused research assistant review slice for ethics and data availability readiness. + +It covers: + +- human-subjects approval, consent scope, vulnerable group, and cross-border data checks +- data availability statements, repository access, embargo, license, and controlled-access handling +- code availability, pinned commit, environment, and reproduction command checks +- claim-to-artifact coverage for manuscript claims that need data or approval evidence +- reviewer actions, release readiness status, signed audit events, and deterministic digests + +The implementation is dependency-free and uses synthetic sample data only. + +## Run + +```bash +npm run check +npm test +npm run demo +``` + +## Demo Assets + +- `docs/demo.svg` +- short demo video: `docs/demo.webm` +- `docs/demo.gif` + +## API + +```js +import { + evaluateEthicsDataAvailability, + renderEthicsDataAvailabilityReport +} from "./src/ethics-data-availability-checker.js"; + +const result = evaluateEthicsDataAvailability(input); +console.log(renderEthicsDataAvailabilityReport(result)); +``` diff --git a/ethics-data-availability-checker/data/sample-ethics-input.json b/ethics-data-availability-checker/data/sample-ethics-input.json new file mode 100644 index 0000000..5cfa3ca --- /dev/null +++ b/ethics-data-availability-checker/data/sample-ethics-input.json @@ -0,0 +1,84 @@ +{ + "project": { + "id": "proj-neuro-immune-042", + "title": "Neuroimmune Marker Study", + "stage": "pre_submission" + }, + "generatedAt": "2026-05-16T16:00:00.000Z", + "signingKey": "sample-review-key", + "ethics": { + "humanSubjects": true, + "approval": { + "status": "approved", + "protocolId": "IRB-2026-1442", + "expiresAt": "2026-05-30" + }, + "consent": { + "status": "complete", + "scope": "internal_only" + }, + "vulnerablePopulation": true, + "safeguards": [ + "deidentified_exports", + "guardian_consent" + ], + "crossBorderTransfer": true, + "transferBasis": "institutional_dpa" + }, + "dataAvailability": { + "statement": "Participant-level data is available through controlled access after review by the study data access committee.", + "repository": { + "url": "https://data.example.org/studies/neuroimmune-042", + "visibility": "controlled" + }, + "accession": "SCIBASE-NEURO-042", + "sensitiveData": true, + "accessCommittee": "Northbridge Data Access Board", + "license": "CC-BY-NC-4.0", + "embargoUntil": "2026-08-01", + "embargoReason": "journal review embargo" + }, + "codeAvailability": { + "repository": { + "url": "https://git.example.org/neuroimmune/analysis", + "commit": "2f1c8a9d7b11" + }, + "environment": "Dockerfile.sha256:64c983", + "reproductionCommand": "npm run reproduce" + }, + "artifacts": [ + { + "id": "artifact-cytokine-table", + "type": "dataset", + "checksum": "sha256:af31", + "linkedInManuscript": true + }, + { + "id": "artifact-figure-2", + "type": "figure", + "checksum": "sha256:ba84", + "linkedInManuscript": true + } + ], + "claims": [ + { + "id": "claim-primary-marker", + "text": "Marker CX-17 is associated with relapse risk.", + "requiresData": true, + "requiresEthics": true, + "evidenceArtifactIds": [ + "artifact-cytokine-table", + "artifact-figure-2" + ] + }, + { + "id": "claim-secondary-model", + "text": "The secondary model is ready for external validation.", + "requiresData": true, + "requiresEthics": false, + "evidenceArtifactIds": [ + "artifact-missing-model" + ] + } + ] +} diff --git a/ethics-data-availability-checker/docs/demo.gif b/ethics-data-availability-checker/docs/demo.gif new file mode 100644 index 0000000..85d9261 Binary files /dev/null and b/ethics-data-availability-checker/docs/demo.gif differ diff --git a/ethics-data-availability-checker/docs/demo.svg b/ethics-data-availability-checker/docs/demo.svg new file mode 100644 index 0000000..47499e7 --- /dev/null +++ b/ethics-data-availability-checker/docs/demo.svg @@ -0,0 +1,27 @@ + + Ethics data availability checker demo + Static dashboard preview for reviewer readiness checks. + + + Ethics + Data Availability + Neuroimmune Marker Study + + Status + blocked + + Ready + 2 checks + + Review + 1 check + + Blocked + 1 claim + + Reviewer actions + + claim_artifact_not_found: attach the missing model artifact + + approval_expiring: confirm renewal timing before publication + Manifest digest and signed audit events are produced by the demo run. + diff --git a/ethics-data-availability-checker/docs/demo.webm b/ethics-data-availability-checker/docs/demo.webm new file mode 100644 index 0000000..2e010a6 Binary files /dev/null and b/ethics-data-availability-checker/docs/demo.webm differ diff --git a/ethics-data-availability-checker/docs/requirement-map.md b/ethics-data-availability-checker/docs/requirement-map.md new file mode 100644 index 0000000..abb2316 --- /dev/null +++ b/ethics-data-availability-checker/docs/requirement-map.md @@ -0,0 +1,17 @@ +# Requirement Map + +Issue #16 asks for research assistant support around pre-release review, reproducibility checks, and useful reviewer guidance. + +This slice covers a focused readiness gate: + +- **Auto peer review reports:** emits ethics, consent, data, code, and claim-evidence findings with owner/action fields. +- **Reproducibility checker:** validates data repository, pinned code commit, runtime environment, and reproduction command coverage. +- **Research gap finder support:** prevents opportunity and review packets from using claims without linked data or approval evidence. +- **Reviewer workflow:** creates sorted reviewer actions, a deterministic manifest digest, and signed audit events for follow-up. + +Out of scope: + +- live model calls +- real participant records +- external repository access +- payment or credential handling diff --git a/ethics-data-availability-checker/package.json b/ethics-data-availability-checker/package.json new file mode 100644 index 0000000..8248a6b --- /dev/null +++ b/ethics-data-availability-checker/package.json @@ -0,0 +1,17 @@ +{ + "name": "ethics-data-availability-checker", + "version": "1.0.0", + "description": "Ethics and data availability readiness checks for research assistant review packets.", + "type": "module", + "scripts": { + "check": "node --check src/ethics-data-availability-checker.js && node --check scripts/demo.js && node --check test/ethics-data-availability-checker.test.js", + "test": "node --test test/*.test.js", + "demo": "node scripts/demo.js" + }, + "keywords": [ + "research-review", + "ethics-readiness", + "data-availability" + ], + "license": "MIT" +} diff --git a/ethics-data-availability-checker/scripts/demo.js b/ethics-data-availability-checker/scripts/demo.js new file mode 100644 index 0000000..007cabd --- /dev/null +++ b/ethics-data-availability-checker/scripts/demo.js @@ -0,0 +1,11 @@ +import { readFile } from "node:fs/promises"; +import { + evaluateEthicsDataAvailability, + renderEthicsDataAvailabilityReport +} from "../src/ethics-data-availability-checker.js"; + +const samplePath = new URL("../data/sample-ethics-input.json", import.meta.url); +const input = JSON.parse(await readFile(samplePath, "utf8")); +const result = evaluateEthicsDataAvailability(input); + +console.log(renderEthicsDataAvailabilityReport(result)); diff --git a/ethics-data-availability-checker/src/ethics-data-availability-checker.js b/ethics-data-availability-checker/src/ethics-data-availability-checker.js new file mode 100644 index 0000000..1d8f187 --- /dev/null +++ b/ethics-data-availability-checker/src/ethics-data-availability-checker.js @@ -0,0 +1,454 @@ +import { createHmac, createHash } from "node:crypto"; + +const SEVERITY_WEIGHT = { + high: 30, + medium: 15, + low: 5 +}; + +const STATUS_RANK = { + ready: 0, + review: 1, + blocked: 2 +}; + +function stableStringify(value) { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(",")}]`; + } + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +export function digest(value) { + return createHash("sha256").update(stableStringify(value)).digest("hex"); +} + +function signEvent(event, signingKey) { + return createHmac("sha256", signingKey).update(stableStringify(event)).digest("hex"); +} + +function parseDateLike(value) { + if (!value) { + return null; + } + const text = String(value); + const hasTimezone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(text); + const date = /^\d{4}-\d{2}-\d{2}$/.test(text) + ? new Date(`${text}T00:00:00Z`) + : new Date(hasTimezone ? text : `${text}Z`); + return Number.isNaN(date.getTime()) ? null : date; +} + +function daysUntil(dateString, generatedAt) { + const target = parseDateLike(dateString); + if (!target) { + return null; + } + const generated = new Date(generatedAt); + return Math.ceil((target.getTime() - generated.getTime()) / 86_400_000); +} + +function addFinding(findings, code, severity, message, owner, action) { + findings.push({ + code, + severity, + weight: SEVERITY_WEIGHT[severity], + message, + owner, + action + }); +} + +function worstStatus(items) { + return items.reduce((status, item) => { + if (STATUS_RANK[item.status] > STATUS_RANK[status]) { + return item.status; + } + return status; + }, "ready"); +} + +function buildEthicsCheck(input) { + const ethics = input.ethics || {}; + const findings = []; + const approval = ethics.approval || {}; + const consent = ethics.consent || {}; + + if (ethics.humanSubjects && approval.status !== "approved") { + addFinding( + findings, + "approval_missing", + "high", + "Human-subjects work needs approved review before release.", + "ethics_board", + "Attach the approval record or hold submission." + ); + } + + const expiryDays = daysUntil(approval.expiresAt, input.generatedAt); + if (approval.status === "approved" && approval.expiresAt && expiryDays === null) { + addFinding( + findings, + "approval_expiry_invalid", + "medium", + "Approval expiry date cannot be parsed.", + "ethics_board", + "Replace the expiry value with an ISO date or timestamp." + ); + } else if (approval.status === "approved" && expiryDays !== null && expiryDays < 0) { + addFinding( + findings, + "approval_expired", + "high", + `Approval expired ${Math.abs(expiryDays)} days ago.`, + "ethics_board", + "Renew the approval before release." + ); + } else if (approval.status === "approved" && expiryDays !== null && expiryDays <= 30) { + addFinding( + findings, + "approval_expiring", + "medium", + `Approval expires in ${expiryDays} days.`, + "ethics_board", + "Confirm renewal timing before publication." + ); + } + + if (ethics.humanSubjects && consent.status !== "complete") { + addFinding( + findings, + "consent_incomplete", + "high", + "Consent coverage is incomplete for participant data.", + "study_team", + "Resolve consent gaps or remove affected records." + ); + } + + if (consent.scope === "internal_only" && input.dataAvailability?.repository?.visibility === "public") { + addFinding( + findings, + "consent_scope_conflict", + "high", + "Consent scope does not allow public data release.", + "data_steward", + "Use controlled access or update the data package." + ); + } + + if (ethics.vulnerablePopulation && !ethics.safeguards?.length) { + addFinding( + findings, + "vulnerable_group_safeguards_missing", + "medium", + "Vulnerable group safeguards are not documented.", + "study_team", + "Add safeguard notes for reviewer signoff." + ); + } + + if (ethics.crossBorderTransfer && !ethics.transferBasis) { + addFinding( + findings, + "transfer_basis_missing", + "medium", + "Cross-border participant data transfer needs a legal basis.", + "data_steward", + "Record the transfer basis or restrict export." + ); + } + + return { + id: "ethics", + status: findings.some((finding) => finding.severity === "high") ? "blocked" : findings.length ? "review" : "ready", + findings + }; +} + +function buildDataCheck(input) { + const data = input.dataAvailability || {}; + const repository = data.repository || {}; + const findings = []; + + if (!data.statement || data.statement.trim().length < 30) { + addFinding( + findings, + "statement_too_short", + "medium", + "Data availability statement is missing or too short.", + "corresponding_author", + "Add repository, accession, restrictions, and reuse terms." + ); + } + + if (data.sensitiveData && repository.visibility === "public") { + addFinding( + findings, + "sensitive_data_public", + "high", + "Sensitive data is marked for public release.", + "data_steward", + "Move the data to controlled access or publish a redacted package." + ); + } + + if (data.sensitiveData && !data.accessCommittee) { + addFinding( + findings, + "access_committee_missing", + "medium", + "Controlled data needs an access committee or approval path.", + "data_steward", + "Name the access committee and request process." + ); + } + + if (!repository.url && !data.accession) { + addFinding( + findings, + "repository_missing", + "medium", + "No repository URL or accession is recorded.", + "data_steward", + "Add a persistent repository location." + ); + } + + if (!data.license) { + addFinding( + findings, + "license_missing", + "low", + "Reuse license is not listed.", + "data_steward", + "Add license terms for data reuse." + ); + } + + if (data.embargoUntil && !data.embargoReason) { + addFinding( + findings, + "embargo_reason_missing", + "medium", + "Embargo date is set without a reviewer-facing reason.", + "corresponding_author", + "Explain the embargo and release trigger." + ); + } + + return { + id: "data", + status: findings.some((finding) => finding.severity === "high") ? "blocked" : findings.length ? "review" : "ready", + findings + }; +} + +function buildCodeCheck(input) { + const code = input.codeAvailability || {}; + const findings = []; + + if (!code.repository?.url) { + addFinding( + findings, + "code_repository_missing", + "medium", + "Code repository is missing.", + "corresponding_author", + "Add a repository link or explain why code cannot be shared." + ); + } + + if (code.repository?.url && !code.repository.commit) { + addFinding( + findings, + "commit_missing", + "medium", + "Code repository is not pinned to a commit.", + "corresponding_author", + "Add an immutable commit or release tag." + ); + } + + if (!code.environment) { + addFinding( + findings, + "environment_missing", + "medium", + "Runtime environment is not described.", + "reproducibility_reviewer", + "Add Dockerfile, lockfile, or environment file details." + ); + } + + if (!code.reproductionCommand) { + addFinding( + findings, + "reproduction_command_missing", + "low", + "Reproduction command is not recorded.", + "reproducibility_reviewer", + "Add the command used to rebuild the reported outputs." + ); + } + + return { + id: "code", + status: findings.some((finding) => finding.severity === "high") ? "blocked" : findings.length ? "review" : "ready", + findings + }; +} + +function buildClaimChecks(input) { + const artifactIds = new Set((input.artifacts || []).map((artifact) => artifact.id)); + return (input.claims || []).map((claim) => { + const findings = []; + const linkedArtifacts = claim.evidenceArtifactIds || []; + + if (claim.requiresData && linkedArtifacts.length === 0) { + addFinding( + findings, + "claim_data_evidence_missing", + "high", + `Claim ${claim.id} needs linked data evidence.`, + "corresponding_author", + "Link a dataset, figure source, or analysis output." + ); + } + + const missingArtifacts = linkedArtifacts.filter((artifactId) => !artifactIds.has(artifactId)); + if (missingArtifacts.length) { + addFinding( + findings, + "claim_artifact_not_found", + "high", + `Claim ${claim.id} references missing artifacts: ${missingArtifacts.join(", ")}.`, + "corresponding_author", + "Attach the missing artifacts or update the evidence links." + ); + } + + if (claim.requiresEthics && !input.ethics?.approval?.protocolId) { + addFinding( + findings, + "claim_ethics_protocol_missing", + "high", + `Claim ${claim.id} needs an ethics protocol reference.`, + "ethics_board", + "Add the protocol id that covers this claim." + ); + } + + return { + id: claim.id, + status: findings.some((finding) => finding.severity === "high") ? "blocked" : findings.length ? "review" : "ready", + findings + }; + }); +} + +function buildReviewerActions(checks) { + return checks + .flatMap((check) => check.findings.map((finding) => ({ + checkId: check.id, + code: finding.code, + severity: finding.severity, + owner: finding.owner, + action: finding.action + }))) + .sort((a, b) => SEVERITY_WEIGHT[b.severity] - SEVERITY_WEIGHT[a.severity] || a.code.localeCompare(b.code)); +} + +function buildAuditEvents(result, input) { + const baseEvents = [ + { + type: "ethics_data_readiness_evaluated", + projectId: input.project?.id, + status: result.status, + riskScore: result.riskScore, + at: input.generatedAt + }, + { + type: "reviewer_actions_created", + projectId: input.project?.id, + actionCount: result.reviewerActions.length, + at: input.generatedAt + } + ]; + + return baseEvents.map((event) => ({ + ...event, + signature: signEvent(event, input.signingKey) + })); +} + +export function evaluateEthicsDataAvailability(input) { + if (!input?.generatedAt || Number.isNaN(new Date(input.generatedAt).getTime())) { + throw new Error("A valid generatedAt timestamp is required."); + } + if (!input.signingKey || input.signingKey.length < 8) { + throw new Error("A signingKey of at least 8 characters is required."); + } + + const primaryChecks = [buildEthicsCheck(input), buildDataCheck(input), buildCodeCheck(input)]; + const claimChecks = buildClaimChecks(input); + const checks = [...primaryChecks, ...claimChecks]; + const status = worstStatus(checks); + const reviewerActions = buildReviewerActions(checks); + const riskScore = Math.min( + 100, + checks.flatMap((check) => check.findings).reduce((total, finding) => total + finding.weight, 0) + ); + + const result = { + projectId: input.project?.id, + projectTitle: input.project?.title, + generatedAt: input.generatedAt, + status, + riskScore, + dashboard: { + ready: checks.filter((check) => check.status === "ready").length, + review: checks.filter((check) => check.status === "review").length, + blocked: checks.filter((check) => check.status === "blocked").length, + reviewerActions: reviewerActions.length + }, + checks, + reviewerActions + }; + + result.manifestDigest = digest({ + projectId: result.projectId, + generatedAt: result.generatedAt, + checks: result.checks, + reviewerActions: result.reviewerActions + }); + result.auditEvents = buildAuditEvents(result, input); + return result; +} + +export function renderEthicsDataAvailabilityReport(result) { + const lines = [ + "Ethics + Data Availability", + `${result.projectTitle || result.projectId}: ${result.status} (${result.riskScore}/100)`, + `Checks ready/review/blocked: ${result.dashboard.ready}/${result.dashboard.review}/${result.dashboard.blocked}`, + `Manifest: ${result.manifestDigest.slice(0, 16)}`, + "", + "Reviewer actions:" + ]; + + for (const action of result.reviewerActions.slice(0, 8)) { + lines.push(`- ${action.severity} ${action.code}: ${action.action}`); + } + + if (result.reviewerActions.length === 0) { + lines.push("- none"); + } + + return lines.join("\n"); +} diff --git a/ethics-data-availability-checker/test/ethics-data-availability-checker.test.js b/ethics-data-availability-checker/test/ethics-data-availability-checker.test.js new file mode 100644 index 0000000..453e2ac --- /dev/null +++ b/ethics-data-availability-checker/test/ethics-data-availability-checker.test.js @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { + digest, + evaluateEthicsDataAvailability, + renderEthicsDataAvailabilityReport +} from "../src/ethics-data-availability-checker.js"; + +async function loadSample() { + const samplePath = new URL("../data/sample-ethics-input.json", import.meta.url); + return JSON.parse(await readFile(samplePath, "utf8")); +} + +test("blocks missing claim artifacts while keeping controlled data in review", async () => { + const result = evaluateEthicsDataAvailability(await loadSample()); + const checks = new Map(result.checks.map((check) => [check.id, check])); + + assert.equal(result.status, "blocked"); + assert.equal(checks.get("ethics").status, "review"); + assert.equal(checks.get("data").status, "ready"); + assert.equal(checks.get("code").status, "ready"); + assert.equal(checks.get("claim-secondary-model").status, "blocked"); + assert.ok(result.reviewerActions.some((action) => action.code === "claim_artifact_not_found")); +}); + +test("flags public sensitive data and consent scope conflicts", async () => { + const input = await loadSample(); + input.dataAvailability.repository.visibility = "public"; + const result = evaluateEthicsDataAvailability(input); + const codes = result.reviewerActions.map((action) => action.code); + + assert.equal(result.status, "blocked"); + assert.ok(codes.includes("sensitive_data_public")); + assert.ok(codes.includes("consent_scope_conflict")); +}); + +test("handles full timestamp approval expiry values", async () => { + const input = await loadSample(); + input.ethics.approval.expiresAt = "2026-05-15T12:00:00.000Z"; + const result = evaluateEthicsDataAvailability(input); + const codes = result.reviewerActions.map((action) => action.code); + + assert.equal(result.status, "blocked"); + assert.ok(codes.includes("approval_expired")); +}); + +test("treats timezone-less approval timestamps as UTC", async () => { + const input = await loadSample(); + input.ethics.approval.expiresAt = "2026-05-15T12:00:00.000"; + const result = evaluateEthicsDataAvailability(input); + + assert.ok(result.reviewerActions.some((action) => action.code === "approval_expired")); +}); + +test("flags approval, consent, repository, license, and code gaps", async () => { + const input = await loadSample(); + input.ethics.approval = { status: "pending", expiresAt: "bad-date" }; + input.ethics.consent = { status: "partial", scope: "internal_only" }; + input.ethics.safeguards = []; + input.ethics.transferBasis = ""; + input.dataAvailability.statement = "TBD"; + input.dataAvailability.repository = {}; + input.dataAvailability.accession = ""; + input.dataAvailability.accessCommittee = ""; + input.dataAvailability.license = ""; + input.dataAvailability.embargoReason = ""; + input.codeAvailability = { + repository: { url: "https://git.example.org/neuroimmune/analysis" } + }; + input.claims.push({ + id: "claim-unlinked", + text: "Unlinked claim needs checks.", + requiresData: true, + requiresEthics: true, + evidenceArtifactIds: [] + }); + + const result = evaluateEthicsDataAvailability(input); + const codes = result.reviewerActions.map((action) => action.code); + + assert.ok(codes.includes("approval_missing")); + assert.ok(codes.includes("consent_incomplete")); + assert.ok(codes.includes("vulnerable_group_safeguards_missing")); + assert.ok(codes.includes("transfer_basis_missing")); + assert.ok(codes.includes("statement_too_short")); + assert.ok(codes.includes("repository_missing")); + assert.ok(codes.includes("access_committee_missing")); + assert.ok(codes.includes("license_missing")); + assert.ok(codes.includes("embargo_reason_missing")); + assert.ok(codes.includes("commit_missing")); + assert.ok(codes.includes("environment_missing")); + assert.ok(codes.includes("reproduction_command_missing")); + assert.ok(codes.includes("claim_data_evidence_missing")); + assert.ok(codes.includes("claim_ethics_protocol_missing")); +}); + +test("flags unparseable approval expiry values", async () => { + const input = await loadSample(); + input.ethics.approval.expiresAt = "not-a-date"; + input.claims[1].evidenceArtifactIds = ["artifact-cytokine-table"]; + const result = evaluateEthicsDataAvailability(input); + + assert.equal(result.status, "review"); + assert.ok(result.reviewerActions.some((action) => action.code === "approval_expiry_invalid")); +}); + +test("allows a clean ready packet", async () => { + const input = await loadSample(); + input.ethics.approval.expiresAt = "2027-05-30"; + input.claims[1].evidenceArtifactIds = ["artifact-cytokine-table"]; + const result = evaluateEthicsDataAvailability(input); + + assert.equal(result.status, "ready"); + assert.equal(result.riskScore, 0); + assert.equal(result.dashboard.blocked, 0); + assert.equal(result.reviewerActions.length, 0); +}); + +test("requires generatedAt and signing key", async () => { + const input = await loadSample(); + + assert.throws(() => evaluateEthicsDataAvailability({ ...input, generatedAt: "not-a-date" }), /generatedAt/); + assert.throws(() => evaluateEthicsDataAvailability({ ...input, signingKey: "short" }), /signingKey/); +}); + +test("produces deterministic digests and signatures", async () => { + const input = await loadSample(); + const first = evaluateEthicsDataAvailability(input); + const second = evaluateEthicsDataAvailability(input); + + assert.equal(first.manifestDigest, second.manifestDigest); + assert.ok(first.auditEvents.every((event) => /^[a-f0-9]{64}$/.test(event.signature))); + assert.equal(digest({ b: [2, 3], a: 1 }), digest({ a: 1, b: [2, 3] })); +}); + +test("renders a reviewer friendly report", async () => { + const result = evaluateEthicsDataAvailability(await loadSample()); + const report = renderEthicsDataAvailabilityReport(result); + + assert.match(report, /Ethics \+ Data Availability/); + assert.match(report, /claim_artifact_not_found/); + assert.match(report, new RegExp(result.manifestDigest.slice(0, 12))); +});