From e0d66c81bdb15667305920e9c7f26ad49fbc44e4 Mon Sep 17 00:00:00 2001 From: AlonePenguin <187998801+AlonePenguin@users.noreply.github.com> Date: Mon, 1 Jun 2026 06:27:33 -0400 Subject: [PATCH] Add geospatial spatial-autocorrelation assistant --- .../.gitignore | 1 + .../README.md | 47 ++ .../demo.js | 47 ++ .../index.js | 690 ++++++++++++++++++ .../make-demo-video.js | 128 ++++ .../package.json | 21 + .../reports/clean-audit.json | 18 + .../reports/demo.mp4 | Bin 0 -> 21558 bytes .../reports/manifest.json | 14 + .../reports/risky-audit.json | 285 ++++++++ .../reports/risky-review.md | 64 ++ .../reports/summary.svg | 13 + .../sample-data.js | 122 ++++ .../test.js | 60 ++ 14 files changed, 1510 insertions(+) create mode 100644 geospatial-spatial-autocorrelation-assistant/.gitignore create mode 100644 geospatial-spatial-autocorrelation-assistant/README.md create mode 100644 geospatial-spatial-autocorrelation-assistant/demo.js create mode 100644 geospatial-spatial-autocorrelation-assistant/index.js create mode 100644 geospatial-spatial-autocorrelation-assistant/make-demo-video.js create mode 100644 geospatial-spatial-autocorrelation-assistant/package.json create mode 100644 geospatial-spatial-autocorrelation-assistant/reports/clean-audit.json create mode 100644 geospatial-spatial-autocorrelation-assistant/reports/demo.mp4 create mode 100644 geospatial-spatial-autocorrelation-assistant/reports/manifest.json create mode 100644 geospatial-spatial-autocorrelation-assistant/reports/risky-audit.json create mode 100644 geospatial-spatial-autocorrelation-assistant/reports/risky-review.md create mode 100644 geospatial-spatial-autocorrelation-assistant/reports/summary.svg create mode 100644 geospatial-spatial-autocorrelation-assistant/sample-data.js create mode 100644 geospatial-spatial-autocorrelation-assistant/test.js diff --git a/geospatial-spatial-autocorrelation-assistant/.gitignore b/geospatial-spatial-autocorrelation-assistant/.gitignore new file mode 100644 index 00000000..2bf074d6 --- /dev/null +++ b/geospatial-spatial-autocorrelation-assistant/.gitignore @@ -0,0 +1 @@ +reports/frames/ diff --git a/geospatial-spatial-autocorrelation-assistant/README.md b/geospatial-spatial-autocorrelation-assistant/README.md new file mode 100644 index 00000000..d04aa6b9 --- /dev/null +++ b/geospatial-spatial-autocorrelation-assistant/README.md @@ -0,0 +1,47 @@ +# Geospatial Spatial-Autocorrelation Review Assistant + +Self-contained reviewer utility for SCIBASE issue #16, AI-Powered Research Assistant Suite. It reviews synthetic geospatial manuscript packets before AI peer-review, reproducibility, or research-gap recommendations are released to researchers. + +## What It Checks + +- Coordinate reference system and analysis projection evidence. +- Invalid or over-precise coordinates for sensitive human-subject or protected-location studies. +- Spatial train/test leakage from nearby train and holdout samples. +- High Moran's I paired with random validation splits. +- Preprocessing fitted on the full dataset for spatial covariates. +- Test-set tuning, missing external spatial validation, stale covariate windows, and missing raster/vector source metadata. +- Reproducibility artifacts: data manifest, code commit, environment spec, and spatial block map. +- Research-gap prompts for under-sampled regions and missing spatial validation benchmarks. + +## Why This Is Distinct + +Existing #16 work covers broad assistant orchestration, evidence binding, structured abstracts, randomization/blinding, survival analysis, missing-data sensitivity, causal adjustment, genomic/proteomics/single-cell review, and related peer-review checks. Existing #17 geospatial work validates sample-provenance graph edges. This module is a separate #16 peer-review layer for manuscript-method validity: spatial autocorrelation, blocked validation, coordinate/projection evidence, and geography-aware reproducibility. + +## Usage + +```bash +npm run check +npm test +npm run demo +npm run verify-video +``` + +Generated reviewer artifacts are written to `reports/`: + +- `risky-audit.json` +- `clean-audit.json` +- `risky-review.md` +- `summary.svg` +- `demo.mp4` + +## API + +```js +const { + evaluateGeospatialReviewPacket, + renderMarkdownReport, + renderSvgSummary +} = require("./index"); +``` + +The evaluator returns a deterministic status (`READY`, `REVIEW`, or `HOLD`), finding counts, manuscript decisions, reproducibility scores, remediation actions, research-gap opportunities, and a stable fingerprint. diff --git a/geospatial-spatial-autocorrelation-assistant/demo.js b/geospatial-spatial-autocorrelation-assistant/demo.js new file mode 100644 index 00000000..4521dbe4 --- /dev/null +++ b/geospatial-spatial-autocorrelation-assistant/demo.js @@ -0,0 +1,47 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { + evaluateGeospatialReviewPacket, + renderMarkdownReport, + renderSvgSummary +} = require("./index"); +const { riskyPacket, cleanPacket } = require("./sample-data"); + +const reportsDir = path.join(__dirname, "reports"); +fs.mkdirSync(reportsDir, { recursive: true }); + +const now = "2026-06-01T10:30:00.000Z"; +const risky = evaluateGeospatialReviewPacket(riskyPacket, { now }); +const clean = evaluateGeospatialReviewPacket(cleanPacket, { now }); + +fs.writeFileSync(path.join(reportsDir, "risky-audit.json"), `${JSON.stringify(risky, null, 2)}\n`); +fs.writeFileSync(path.join(reportsDir, "clean-audit.json"), `${JSON.stringify(clean, null, 2)}\n`); +fs.writeFileSync(path.join(reportsDir, "risky-review.md"), renderMarkdownReport(risky, riskyPacket)); +fs.writeFileSync(path.join(reportsDir, "summary.svg"), renderSvgSummary(risky)); +fs.writeFileSync( + path.join(reportsDir, "manifest.json"), + `${JSON.stringify( + { + generatedAt: now, + artifacts: [ + "risky-audit.json", + "clean-audit.json", + "risky-review.md", + "summary.svg", + "demo.mp4" + ], + riskyStatus: risky.status, + cleanStatus: clean.status, + riskyFingerprint: risky.fingerprint, + cleanFingerprint: clean.fingerprint + }, + null, + 2 + )}\n` +); + +console.log(`Risky packet: ${risky.status} (${risky.findings.length} findings)`); +console.log(`Clean packet: ${clean.status} (${clean.findings.length} findings)`); +console.log(`Wrote reports to ${reportsDir}`); diff --git a/geospatial-spatial-autocorrelation-assistant/index.js b/geospatial-spatial-autocorrelation-assistant/index.js new file mode 100644 index 00000000..6712ff47 --- /dev/null +++ b/geospatial-spatial-autocorrelation-assistant/index.js @@ -0,0 +1,690 @@ +"use strict"; + +const crypto = require("node:crypto"); + +const SEVERITY_ORDER = ["critical", "high", "warning", "info"]; + +const DEFAULT_POLICY = { + minSpatialHoldoutKm: 25, + highMoransI: 0.35, + maxSensitivePrecisionDecimals: 4, + minRegionsForBroadClaims: 3, + maxCovariateWindowDays: 365 +}; + +function evaluateGeospatialReviewPacket(packet, options = {}) { + if (!isPlainObject(packet)) { + throw new TypeError("evaluateGeospatialReviewPacket expects a packet object"); + } + + const now = options.now ?? new Date().toISOString(); + const policy = { ...DEFAULT_POLICY, ...(isPlainObject(packet.policy) ? packet.policy : {}) }; + const manuscripts = asArray(packet.manuscripts); + const findings = []; + + if (manuscripts.length === 0) { + findings.push( + finding( + "PACKET_SCHEMA_MISSING_MANUSCRIPTS", + "high", + "The geospatial review packet has no manuscripts to inspect.", + "AI peer review output needs at least one manuscript or study packet.", + "manuscripts", + "Attach manuscript metadata, spatial design, samples, model split evidence, and reproducibility artifacts.", + "research assistant owner" + ) + ); + } + + manuscripts.forEach((manuscript, index) => inspectManuscript(manuscript, index, policy, findings)); + + const sortedFindings = sortFindings(findings); + const status = determineStatus(sortedFindings); + const reviewDecisions = manuscripts.map((manuscript, index) => + buildReviewDecision(manuscript, index, sortedFindings) + ); + const researchGapOpportunities = buildResearchGapOpportunities(manuscripts, sortedFindings, policy); + const remediationActions = sortedFindings.map((item) => ({ + code: item.code, + manuscriptId: item.manuscriptId ?? null, + modelId: item.modelId ?? null, + owner: item.owner, + action: item.remediation + })); + + const fingerprint = crypto + .createHash("sha256") + .update( + JSON.stringify({ + policy, + manuscripts: manuscripts.map((manuscript) => ({ + id: manuscript.id, + spatialDesign: manuscript.spatialDesign, + claims: manuscript.claims, + models: manuscript.models, + artifactKeys: Object.keys(isPlainObject(manuscript.reproducibilityArtifacts) ? manuscript.reproducibilityArtifacts : {}) + })), + codes: sortedFindings.map((item) => item.code) + }) + ) + .digest("hex") + .slice(0, 16); + + return { + generatedAt: now, + status, + summary: summarize(status, sortedFindings, manuscripts.length, researchGapOpportunities.length), + findingCounts: countBySeverity(sortedFindings), + findings: sortedFindings, + reviewDecisions, + researchGapOpportunities, + remediationActions, + fingerprint + }; +} + +function renderMarkdownReport(result, packet) { + const lines = [ + "# Geospatial Spatial-Autocorrelation Review Assistant", + "", + `Packet: ${packet.id ?? "unknown"}`, + `Status: ${result.status}`, + `Fingerprint: ${result.fingerprint}`, + "", + "## Summary", + "", + result.summary, + "", + "## Manuscript Decisions", + "" + ]; + + result.reviewDecisions.forEach((decision) => { + lines.push( + `- ${decision.manuscriptId}: ${decision.decision}; reproducibility score ${decision.reproducibilityScore}/100; ${decision.reasonCodes.length} finding(s)` + ); + }); + + lines.push("", "## Findings", ""); + if (result.findings.length === 0) { + lines.push("- No geospatial peer-review blockers found."); + } else { + result.findings.forEach((item) => { + lines.push(`- ${item.severity.toUpperCase()} ${item.code}: ${item.message}`); + lines.push(` - Evidence: ${item.evidence}`); + lines.push(` - Remediation: ${item.remediation}`); + }); + } + + lines.push("", "## Research Gap Opportunities", ""); + if (result.researchGapOpportunities.length === 0) { + lines.push("- No under-sampled geography or replication opportunities were generated."); + } else { + result.researchGapOpportunities.forEach((gap) => { + lines.push(`- ${gap.id}: ${gap.title}`); + lines.push(` - Rationale: ${gap.rationale}`); + lines.push(` - First action: ${gap.firstAction}`); + }); + } + + return `${lines.join("\n")}\n`; +} + +function renderSvgSummary(result) { + const counts = result.findingCounts; + const critical = counts.critical ?? 0; + const high = counts.high ?? 0; + const warning = counts.warning ?? 0; + const ready = result.status === "READY"; + const statusColor = ready ? "#16794c" : result.status === "REVIEW" ? "#a15c00" : "#a11b32"; + const holdWidth = Math.min(330, (critical + high) * 54); + const warningWidth = Math.min(220, warning * 42); + const readyWidth = ready ? 300 : Math.max(80, 300 - holdWidth); + + return [ + ``, + ``, + ``, + `Geospatial review assistant`, + `Status ${escapeXml(result.status)} - fingerprint ${escapeXml(result.fingerprint)}`, + ``, + ``, + ``, + `SPATIAL QA`, + `Critical/high blockers: ${critical + high}`, + `Research gaps: ${result.researchGapOpportunities.length}`, + `Manuscripts checked: ${result.reviewDecisions.length}`, + `` + ].join("\n"); +} + +function inspectManuscript(manuscript, index, policy, findings) { + const manuscriptId = manuscript.id ?? `manuscript-${index}`; + const path = `manuscripts[${index}]`; + const spatialDesign = isPlainObject(manuscript.spatialDesign) ? manuscript.spatialDesign : {}; + const samples = asArray(manuscript.samples); + const models = asArray(manuscript.models); + const claims = asArray(manuscript.claims); + const artifacts = isPlainObject(manuscript.reproducibilityArtifacts) ? manuscript.reproducibilityArtifacts : {}; + + if (!manuscript.id) { + findings.push( + finding( + "MANUSCRIPT_MISSING_ID", + "high", + `Manuscript at index ${index} has no stable id.`, + "Reviewer packets need stable manuscript ids for traceability.", + `${path}.id`, + "Assign a stable manuscript id before releasing assistant output.", + "research assistant owner", + manuscriptId + ) + ); + } + + if (!spatialDesign.crs && !spatialDesign.epsg) { + findings.push( + finding( + "MISSING_CRS_EVIDENCE", + "high", + `${manuscriptId} does not declare a coordinate reference system.`, + "Spatial distances, joins, and raster overlays cannot be reviewed without CRS evidence.", + `${path}.spatialDesign.crs`, + "Declare the source CRS/EPSG code and any analysis projection used for distance or area operations.", + "geospatial methods reviewer", + manuscriptId + ) + ); + } + + if (samples.length === 0) { + findings.push( + finding( + "MISSING_SPATIAL_SAMPLE_TABLE", + "high", + `${manuscriptId} has no sample table with coordinates and split labels.`, + "Spatial leakage and regional coverage checks need sample-level geography.", + `${path}.samples`, + "Attach synthetic-safe sample coordinates, split labels, site ids, and region labels.", + "data steward", + manuscriptId + ) + ); + } + + inspectCoordinates(samples, path, manuscriptId, findings); + inspectSensitivePrecision(manuscript, spatialDesign, path, manuscriptId, policy, findings); + inspectBroadClaims(manuscript, claims, samples, path, manuscriptId, policy, findings); + + models.forEach((model, modelIndex) => + inspectModel(model, modelIndex, manuscript, samples, path, manuscriptId, policy, findings) + ); + + inspectArtifacts(artifacts, models, path, manuscriptId, findings); +} + +function inspectCoordinates(samples, path, manuscriptId, findings) { + samples.forEach((sample, sampleIndex) => { + const lat = Number(sample.lat); + const lon = Number(sample.lon); + if (!Number.isFinite(lat) || !Number.isFinite(lon) || lat < -90 || lat > 90 || lon < -180 || lon > 180) { + findings.push( + finding( + "INVALID_COORDINATE", + "critical", + `${manuscriptId} has an invalid coordinate at sample ${sample.id ?? sampleIndex}.`, + `Observed lat=${sample.lat}, lon=${sample.lon}.`, + `${path}.samples[${sampleIndex}]`, + "Correct or exclude invalid coordinates before AI peer review summarizes spatial findings.", + "data steward", + manuscriptId + ) + ); + } + }); +} + +function inspectSensitivePrecision(manuscript, spatialDesign, path, manuscriptId, policy, findings) { + const sensitivity = String(manuscript.sensitivity ?? "").toLowerCase(); + const sensitive = sensitivity.includes("human") || sensitivity.includes("protected") || sensitivity.includes("restricted"); + const decimals = Number(spatialDesign.coordinatePrecisionDecimals); + if (sensitive && Number.isFinite(decimals) && decimals > policy.maxSensitivePrecisionDecimals) { + findings.push( + finding( + "SENSITIVE_COORDINATE_OVERPRECISION", + "high", + `${manuscriptId} exposes sensitive locations at ${decimals} decimal places.`, + "Human-subject or protected-species locations should be generalized before reviewer packets or public summaries.", + `${path}.spatialDesign.coordinatePrecisionDecimals`, + `Round or jitter coordinates to ${policy.maxSensitivePrecisionDecimals} decimals or provide an approved restricted-location access path.`, + "privacy reviewer", + manuscriptId + ) + ); + } +} + +function inspectBroadClaims(manuscript, claims, samples, path, manuscriptId, policy, findings) { + const broadClaims = claims.filter(isBroadClaim); + if (broadClaims.length === 0) { + return; + } + + const regions = new Set(samples.map((sample) => sample.region).filter(Boolean)); + broadClaims.forEach((claim, claimIndex) => { + const claimedRegions = asArray(claim.claimedRegions).filter(Boolean); + const expectedRegions = Math.max(policy.minRegionsForBroadClaims, claimedRegions.length || 0); + if (regions.size < expectedRegions) { + findings.push( + finding( + "OVERBROAD_GEOGRAPHIC_CLAIM", + "high", + `${manuscriptId} makes a broad geographic claim with only ${regions.size} observed region(s).`, + claim.text ?? "Broad geographic claim without matching sampled-region coverage.", + `${path}.claims[${claimIndex}]`, + "Limit the claim to sampled regions or add external validation sites covering the claimed geography.", + "methods reviewer", + manuscriptId + ) + ); + } + }); +} + +function inspectModel(model, modelIndex, manuscript, samples, path, manuscriptId, policy, findings) { + const modelId = model.id ?? `model-${modelIndex}`; + const modelPath = `${path}.models[${modelIndex}]`; + const splitStrategy = String(model.splitStrategy ?? "").toLowerCase(); + const moransI = Number(model.moransI); + const spatialSplit = isSpatialSplit(model); + const minDistance = minimumTrainTestDistanceKm(samples); + + if (Number.isFinite(minDistance) && minDistance < policy.minSpatialHoldoutKm && !spatialSplit) { + findings.push( + finding( + "SPATIAL_SPLIT_LEAKAGE", + "critical", + `${manuscriptId}/${modelId} has train/test samples only ${minDistance.toFixed(1)} km apart without spatial blocking.`, + `Policy requires at least ${policy.minSpatialHoldoutKm} km or explicit spatial block validation.`, + `${modelPath}.splitStrategy`, + "Use spatial block, leave-site-out, or regional holdout validation and regenerate performance claims.", + "model reviewer", + manuscriptId, + modelId + ) + ); + } + + if (Number.isFinite(moransI) && moransI >= policy.highMoransI && !spatialSplit) { + findings.push( + finding( + "HIGH_SPATIAL_AUTOCORRELATION_RANDOM_SPLIT", + "high", + `${manuscriptId}/${modelId} reports Moran's I ${moransI.toFixed(2)} with a ${model.splitStrategy ?? "missing"} split.`, + "High spatial autocorrelation inflates random train/test validation.", + `${modelPath}.moransI`, + "Run spatial block cross-validation or leave-region-out validation before presenting performance as reviewer-ready.", + "spatial statistics reviewer", + manuscriptId, + modelId + ) + ); + } + + const preprocessingFitScope = String(model.preprocessingFitScope ?? "").toLowerCase(); + if (preprocessingFitScope.includes("full") && hasSpatialCovariates(model)) { + findings.push( + finding( + "FULL_DATASET_PREPROCESSING_LEAKAGE", + "high", + `${manuscriptId}/${modelId} fits spatial preprocessing on the full dataset.`, + "Raster normalization, imputation, or feature selection must be learned inside each training fold.", + `${modelPath}.preprocessingFitScope`, + "Refit preprocessing inside training folds and attach fold-specific transformation hashes.", + "reproducibility reviewer", + manuscriptId, + modelId + ) + ); + } + + const tunedOn = String(model.hyperparameterTunedOn ?? "").toLowerCase(); + if (tunedOn.includes("test") || tunedOn.includes("holdout")) { + findings.push( + finding( + "TEST_SET_TUNING", + "critical", + `${manuscriptId}/${modelId} tunes model choices on the test/holdout set.`, + "Reviewer-facing performance claims require a locked final test set.", + `${modelPath}.hyperparameterTunedOn`, + "Move tuning to inner validation folds and rerun the locked test set once.", + "model reviewer", + manuscriptId, + modelId + ) + ); + } + + inspectCovariates(model, modelPath, manuscriptId, modelId, policy, findings); + + const needsExternalValidation = asArray(manuscript.claims).some(isBroadClaim) || String(model.deploymentContext ?? "").length > 0; + if (needsExternalValidation && asArray(model.externalValidationSites).length === 0) { + findings.push( + finding( + "MISSING_EXTERNAL_SPATIAL_VALIDATION", + "high", + `${manuscriptId}/${modelId} lacks external spatial validation for broader deployment claims.`, + "Broad geographic or deployment claims should be checked outside the training geography.", + `${modelPath}.externalValidationSites`, + "Add an out-of-region validation site or limit the manuscript claim to the sampled geography.", + "methods reviewer", + manuscriptId, + modelId + ) + ); + } + + if (!splitStrategy) { + findings.push( + finding( + "MISSING_SPLIT_STRATEGY", + "warning", + `${manuscriptId}/${modelId} does not describe its spatial validation split strategy.`, + "Peer review needs the split design to reason about leakage and autocorrelation.", + `${modelPath}.splitStrategy`, + "Document random, blocked, leave-site-out, or external validation split evidence.", + "methods reviewer", + manuscriptId, + modelId + ) + ); + } +} + +function inspectCovariates(model, modelPath, manuscriptId, modelId, policy, findings) { + asArray(model.covariates).forEach((covariate, covariateIndex) => { + if (!covariate.source) { + findings.push( + finding( + "COVARIATE_SOURCE_MISSING", + "warning", + `${manuscriptId}/${modelId} covariate ${covariate.name ?? covariateIndex} has no source citation or artifact id.`, + "Raster/vector covariates should be traceable for reproducibility and recency review.", + `${modelPath}.covariates[${covariateIndex}].source`, + "Attach a source DOI, artifact id, or repository path for each spatial covariate.", + "data steward", + manuscriptId, + modelId + ) + ); + } + + const windowDays = Number(covariate.acquisitionWindowDays); + if (Number.isFinite(windowDays) && windowDays > policy.maxCovariateWindowDays) { + findings.push( + finding( + "STALE_COVARIATE_WINDOW", + "warning", + `${manuscriptId}/${modelId} covariate ${covariate.name ?? covariateIndex} spans ${windowDays} acquisition days.`, + "Long covariate windows can hide temporal drift in geospatial models.", + `${modelPath}.covariates[${covariateIndex}].acquisitionWindowDays`, + "Use period-matched covariates or report temporal-drift sensitivity checks.", + "methods reviewer", + manuscriptId, + modelId + ) + ); + } + + const resolutionMeters = Number(covariate.resolutionMeters); + if (!Number.isFinite(resolutionMeters) || resolutionMeters <= 0) { + findings.push( + finding( + "COVARIATE_RESOLUTION_MISSING", + "warning", + `${manuscriptId}/${modelId} covariate ${covariate.name ?? covariateIndex} lacks raster/vector resolution evidence.`, + "Spatial scale mismatch cannot be reviewed without resolution metadata.", + `${modelPath}.covariates[${covariateIndex}].resolutionMeters`, + "Attach spatial resolution, aggregation rules, and resampling method for each covariate.", + "geospatial methods reviewer", + manuscriptId, + modelId + ) + ); + } + }); +} + +function inspectArtifacts(artifacts, models, path, manuscriptId, findings) { + const required = [ + ["dataManifest", "DATA_MANIFEST_MISSING", "Attach a data manifest with sample ids, coordinates, split labels, and hashes."], + ["codeCommit", "CODE_COMMIT_MISSING", "Attach the analysis code commit or immutable archive hash."], + ["environmentSpec", "ENVIRONMENT_SPEC_MISSING", "Attach a pinned environment or container digest for spatial libraries."] + ]; + + required.forEach(([key, code, remediation]) => { + if (!artifacts[key]) { + findings.push( + finding( + code, + "high", + `${manuscriptId} is missing reproducibility artifact ${key}.`, + "Geospatial results depend on data, code, and environment parity.", + `${path}.reproducibilityArtifacts.${key}`, + remediation, + "reproducibility reviewer", + manuscriptId + ) + ); + } + }); + + const hasSpatialModel = models.some((model) => isSpatialSplit(model) || Number.isFinite(Number(model.moransI))); + if (hasSpatialModel && !artifacts.spatialBlockMap) { + findings.push( + finding( + "SPATIAL_BLOCK_MAP_MISSING", + "warning", + `${manuscriptId} has spatial validation claims without a block map artifact.`, + "Reviewers need the held-out geometry or block map to audit leakage.", + `${path}.reproducibilityArtifacts.spatialBlockMap`, + "Attach a block-map artifact id, geometry hash, or leave-site-out manifest.", + "geospatial methods reviewer", + manuscriptId + ) + ); + } +} + +function buildReviewDecision(manuscript, index, findings) { + const manuscriptId = manuscript.id ?? `manuscript-${index}`; + const manuscriptFindings = findings.filter((item) => item.manuscriptId === manuscriptId || !item.manuscriptId); + const decision = manuscriptFindings.some((item) => item.severity === "critical" || item.severity === "high") + ? "HOLD" + : manuscriptFindings.some((item) => item.severity === "warning") + ? "REVIEW" + : "READY"; + + return { + manuscriptId, + decision, + reasonCodes: manuscriptFindings.map((item) => item.code), + reproducibilityScore: scoreFindings(manuscriptFindings) + }; +} + +function buildResearchGapOpportunities(manuscripts, findings, policy) { + const gaps = []; + manuscripts.forEach((manuscript, index) => { + const manuscriptId = manuscript.id ?? `manuscript-${index}`; + const samples = asArray(manuscript.samples); + const regions = new Set(samples.map((sample) => sample.region).filter(Boolean)); + const broad = asArray(manuscript.claims).some(isBroadClaim); + const manuscriptFindings = findings.filter((item) => item.manuscriptId === manuscriptId); + + if (broad && regions.size < policy.minRegionsForBroadClaims) { + gaps.push({ + id: `${manuscriptId}-regional-replication`, + title: "Prioritize out-of-region replication before broad geographic claims", + rationale: `${manuscriptId} samples ${regions.size} region(s), below the ${policy.minRegionsForBroadClaims}-region policy for broad claims.`, + firstAction: "Recruit or simulate a holdout site in the least represented claimed region." + }); + } + + if (manuscriptFindings.some((item) => item.code === "HIGH_SPATIAL_AUTOCORRELATION_RANDOM_SPLIT")) { + gaps.push({ + id: `${manuscriptId}-spatial-validation-gap`, + title: "Add spatial block validation benchmark", + rationale: "High autocorrelation with random validation means reported accuracy may be optimistic.", + firstAction: "Create a leave-region-out benchmark and compare it to the random split baseline." + }); + } + }); + return gaps; +} + +function summarize(status, findings, manuscriptCount, gapCount) { + if (findings.length === 0) { + return `${manuscriptCount} manuscript(s) are ready for geospatial peer-review release with no spatial leakage or reproducibility findings.`; + } + + const counts = countBySeverity(findings); + return `${status}: ${manuscriptCount} manuscript(s) produced ${findings.length} finding(s): ${counts.critical ?? 0} critical, ${counts.high ?? 0} high, ${counts.warning ?? 0} warning, and ${gapCount} research gap prompt(s).`; +} + +function finding(code, severity, message, evidence, path, remediation, owner, manuscriptId = null, modelId = null) { + return { + code, + severity, + message, + evidence, + path, + remediation, + owner, + manuscriptId, + modelId + }; +} + +function determineStatus(findings) { + if (findings.some((item) => item.severity === "critical" || item.severity === "high")) { + return "HOLD"; + } + if (findings.some((item) => item.severity === "warning")) { + return "REVIEW"; + } + return "READY"; +} + +function scoreFindings(findings) { + const counts = countBySeverity(findings); + return Math.max( + 0, + 100 - (counts.critical ?? 0) * 30 - (counts.high ?? 0) * 17 - (counts.warning ?? 0) * 7 + ); +} + +function sortFindings(findings) { + return [...findings].sort((a, b) => { + const severityDiff = SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity); + if (severityDiff !== 0) { + return severityDiff; + } + return a.code.localeCompare(b.code); + }); +} + +function countBySeverity(findings) { + return findings.reduce((counts, item) => { + counts[item.severity] = (counts[item.severity] ?? 0) + 1; + return counts; + }, {}); +} + +function minimumTrainTestDistanceKm(samples) { + const train = samples.filter((sample) => String(sample.split ?? "").toLowerCase() === "train"); + const test = samples.filter((sample) => String(sample.split ?? "").toLowerCase() === "test"); + if (train.length === 0 || test.length === 0) { + return Infinity; + } + + let minimum = Infinity; + train.forEach((trainSample) => { + test.forEach((testSample) => { + const distance = haversineKm(trainSample.lat, trainSample.lon, testSample.lat, testSample.lon); + if (distance < minimum) { + minimum = distance; + } + }); + }); + return minimum; +} + +function haversineKm(latA, lonA, latB, lonB) { + const aLat = Number(latA); + const aLon = Number(lonA); + const bLat = Number(latB); + const bLon = Number(lonB); + if (![aLat, aLon, bLat, bLon].every(Number.isFinite)) { + return Infinity; + } + + const earthRadiusKm = 6371; + const dLat = radians(bLat - aLat); + const dLon = radians(bLon - aLon); + const startLat = radians(aLat); + const endLat = radians(bLat); + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(startLat) * Math.cos(endLat) * Math.sin(dLon / 2) ** 2; + return 2 * earthRadiusKm * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + +function radians(value) { + return (value * Math.PI) / 180; +} + +function isSpatialSplit(model) { + const split = String(model.splitStrategy ?? "").toLowerCase(); + return split.includes("spatial") || split.includes("block") || split.includes("leave-site") || split.includes("leave_region"); +} + +function hasSpatialCovariates(model) { + return Boolean(model.spatialCovariates) || asArray(model.covariates).length > 0; +} + +function isBroadClaim(claim) { + const scope = String(claim.scope ?? "").toLowerCase(); + const text = String(claim.text ?? "").toLowerCase(); + return ( + ["global", "continental", "multi-region", "national", "deployment"].includes(scope) || + text.includes("generalize") || + text.includes("across regions") || + text.includes("continent") || + text.includes("nationwide") || + text.includes("global") + ); +} + +function isPlainObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function escapeXml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +module.exports = { + evaluateGeospatialReviewPacket, + renderMarkdownReport, + renderSvgSummary, + haversineKm +}; diff --git a/geospatial-spatial-autocorrelation-assistant/make-demo-video.js b/geospatial-spatial-autocorrelation-assistant/make-demo-video.js new file mode 100644 index 00000000..f690b872 --- /dev/null +++ b/geospatial-spatial-autocorrelation-assistant/make-demo-video.js @@ -0,0 +1,128 @@ +"use strict"; + +const { execFileSync } = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); + +const WIDTH = 960; +const HEIGHT = 540; +const FONT = { + A: ["01110", "10001", "10001", "11111", "10001", "10001", "10001"], + B: ["11110", "10001", "10001", "11110", "10001", "10001", "11110"], + C: ["01111", "10000", "10000", "10000", "10000", "10000", "01111"], + D: ["11110", "10001", "10001", "10001", "10001", "10001", "11110"], + E: ["11111", "10000", "10000", "11110", "10000", "10000", "11111"], + G: ["01111", "10000", "10000", "10111", "10001", "10001", "01111"], + I: ["11111", "00100", "00100", "00100", "00100", "00100", "11111"], + K: ["10001", "10010", "10100", "11000", "10100", "10010", "10001"], + L: ["10000", "10000", "10000", "10000", "10000", "10000", "11111"], + O: ["01110", "10001", "10001", "10001", "10001", "10001", "01110"], + P: ["11110", "10001", "10001", "11110", "10000", "10000", "10000"], + R: ["11110", "10001", "10001", "11110", "10100", "10010", "10001"], + S: ["01111", "10000", "10000", "01110", "00001", "00001", "11110"], + T: ["11111", "00100", "00100", "00100", "00100", "00100", "00100"], + V: ["10001", "10001", "10001", "10001", "01010", "01010", "00100"], + W: ["10001", "10001", "10001", "10101", "10101", "10101", "01010"], + Y: ["10001", "01010", "00100", "00100", "00100", "00100", "00100"] +}; + +const reportsDir = path.join(__dirname, "reports"); +const framesDir = path.join(reportsDir, "frames"); +fs.mkdirSync(framesDir, { recursive: true }); + +for (const file of fs.readdirSync(framesDir)) { + fs.unlinkSync(path.join(framesDir, file)); +} + +const slides = [ + { label: "CRS READY", color: [22, 121, 76], fill: 0.72 }, + { label: "LEAKAGE", color: [161, 27, 50], fill: 0.88 }, + { label: "BLOCK SPLIT", color: [22, 121, 76], fill: 0.78 }, + { label: "GAP MAP", color: [161, 92, 0], fill: 0.64 } +]; + +let frameIndex = 0; +for (const slide of slides) { + for (let i = 0; i < 8; i += 1) { + const progress = (i + 1) / 8; + const buffer = createFrame(slide, progress); + fs.writeFileSync(path.join(framesDir, `frame-${String(frameIndex).padStart(3, "0")}.ppm`), buffer); + frameIndex += 1; + } +} + +const output = path.join(reportsDir, "demo.mp4"); +execFileSync( + "ffmpeg", + [ + "-y", + "-framerate", + "8", + "-i", + path.join(framesDir, "frame-%03d.ppm"), + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + output + ], + { stdio: "ignore" } +); + +const stats = fs.statSync(output); +console.log(`Wrote ${output} (${stats.size} bytes)`); + +function createFrame(slide, progress) { + const pixels = Buffer.alloc(WIDTH * HEIGHT * 3); + fillRect(pixels, 0, 0, WIDTH, HEIGHT, [16, 24, 32]); + fillRect(pixels, 48, 48, 864, 444, [248, 250, 252]); + fillRect(pixels, 80, 190, 800, 88, [226, 232, 240]); + fillRect(pixels, 80, 190, Math.round(800 * slide.fill * progress), 88, slide.color); + fillRect(pixels, 80, 322, 220, 42, [226, 232, 240]); + fillRect(pixels, 332, 322, 220, 42, [226, 232, 240]); + fillRect(pixels, 584, 322, 220, 42, [226, 232, 240]); + fillRect(pixels, 80, 322, 130, 42, [161, 27, 50]); + fillRect(pixels, 332, 322, 150, 42, [161, 92, 0]); + fillRect(pixels, 584, 322, 200, 42, [22, 121, 76]); + drawText(pixels, "SPATIAL REVIEW", 82, 104, 5, [17, 24, 39]); + drawText(pixels, slide.label, 108, 214, 7, [255, 255, 255]); + drawText(pixels, "PEER READY", 82, 414, 4, [51, 65, 85]); + return Buffer.concat([Buffer.from(`P6\n${WIDTH} ${HEIGHT}\n255\n`, "ascii"), pixels]); +} + +function fillRect(pixels, x, y, width, height, color) { + const x2 = Math.min(WIDTH, x + width); + const y2 = Math.min(HEIGHT, y + height); + for (let row = Math.max(0, y); row < y2; row += 1) { + for (let col = Math.max(0, x); col < x2; col += 1) { + const offset = (row * WIDTH + col) * 3; + pixels[offset] = color[0]; + pixels[offset + 1] = color[1]; + pixels[offset + 2] = color[2]; + } + } +} + +function drawText(pixels, text, x, y, scale, color) { + let cursor = x; + for (const rawChar of text) { + const char = rawChar.toUpperCase(); + if (char === " ") { + cursor += 4 * scale; + continue; + } + const glyph = FONT[char]; + if (!glyph) { + cursor += 6 * scale; + continue; + } + glyph.forEach((row, rowIndex) => { + for (let colIndex = 0; colIndex < row.length; colIndex += 1) { + if (row[colIndex] === "1") { + fillRect(pixels, cursor + colIndex * scale, y + rowIndex * scale, scale, scale, color); + } + } + }); + cursor += 6 * scale; + } +} diff --git a/geospatial-spatial-autocorrelation-assistant/package.json b/geospatial-spatial-autocorrelation-assistant/package.json new file mode 100644 index 00000000..a583ce99 --- /dev/null +++ b/geospatial-spatial-autocorrelation-assistant/package.json @@ -0,0 +1,21 @@ +{ + "name": "geospatial-spatial-autocorrelation-assistant", + "version": "1.0.0", + "private": true, + "description": "Synthetic geospatial peer-review assistant for spatial autocorrelation, split leakage, CRS, and reproducibility risk.", + "main": "index.js", + "scripts": { + "check": "node --check index.js && node --check sample-data.js && node --check test.js && node --check demo.js && node --check make-demo-video.js", + "test": "node test.js", + "demo": "node demo.js && node make-demo-video.js", + "verify-video": "ffprobe -v error -show_entries stream=codec_name,width,height,duration -of default=nokey=1:noprint_wrappers=1 reports/demo.mp4" + }, + "keywords": [ + "geospatial", + "peer-review", + "spatial-autocorrelation", + "reproducibility", + "synthetic" + ], + "license": "MIT" +} diff --git a/geospatial-spatial-autocorrelation-assistant/reports/clean-audit.json b/geospatial-spatial-autocorrelation-assistant/reports/clean-audit.json new file mode 100644 index 00000000..0faadf2c --- /dev/null +++ b/geospatial-spatial-autocorrelation-assistant/reports/clean-audit.json @@ -0,0 +1,18 @@ +{ + "generatedAt": "2026-06-01T10:30:00.000Z", + "status": "READY", + "summary": "1 manuscript(s) are ready for geospatial peer-review release with no spatial leakage or reproducibility findings.", + "findingCounts": {}, + "findings": [], + "reviewDecisions": [ + { + "manuscriptId": "rangeland-blocked-validation", + "decision": "READY", + "reasonCodes": [], + "reproducibilityScore": 100 + } + ], + "researchGapOpportunities": [], + "remediationActions": [], + "fingerprint": "aa2c187bd4b36628" +} diff --git a/geospatial-spatial-autocorrelation-assistant/reports/demo.mp4 b/geospatial-spatial-autocorrelation-assistant/reports/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..0e79ef6b7890028750f0ba2114e8711d3ffa1610 GIT binary patch literal 21558 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 literal 0 HcmV?d00001 diff --git a/geospatial-spatial-autocorrelation-assistant/reports/manifest.json b/geospatial-spatial-autocorrelation-assistant/reports/manifest.json new file mode 100644 index 00000000..c0dd10c0 --- /dev/null +++ b/geospatial-spatial-autocorrelation-assistant/reports/manifest.json @@ -0,0 +1,14 @@ +{ + "generatedAt": "2026-06-01T10:30:00.000Z", + "artifacts": [ + "risky-audit.json", + "clean-audit.json", + "risky-review.md", + "summary.svg", + "demo.mp4" + ], + "riskyStatus": "HOLD", + "cleanStatus": "READY", + "riskyFingerprint": "e036107e72f70a7e", + "cleanFingerprint": "aa2c187bd4b36628" +} diff --git a/geospatial-spatial-autocorrelation-assistant/reports/risky-audit.json b/geospatial-spatial-autocorrelation-assistant/reports/risky-audit.json new file mode 100644 index 00000000..0ea41478 --- /dev/null +++ b/geospatial-spatial-autocorrelation-assistant/reports/risky-audit.json @@ -0,0 +1,285 @@ +{ + "generatedAt": "2026-06-01T10:30:00.000Z", + "status": "HOLD", + "summary": "HOLD: 1 manuscript(s) produced 13 finding(s): 2 critical, 8 high, 3 warning, and 2 research gap prompt(s).", + "findingCounts": { + "critical": 2, + "high": 8, + "warning": 3 + }, + "findings": [ + { + "code": "SPATIAL_SPLIT_LEAKAGE", + "severity": "critical", + "message": "urban-heat-random-split/rf-heat-risk has train/test samples only 0.6 km apart without spatial blocking.", + "evidence": "Policy requires at least 35 km or explicit spatial block validation.", + "path": "manuscripts[0].models[0].splitStrategy", + "remediation": "Use spatial block, leave-site-out, or regional holdout validation and regenerate performance claims.", + "owner": "model reviewer", + "manuscriptId": "urban-heat-random-split", + "modelId": "rf-heat-risk" + }, + { + "code": "TEST_SET_TUNING", + "severity": "critical", + "message": "urban-heat-random-split/rf-heat-risk tunes model choices on the test/holdout set.", + "evidence": "Reviewer-facing performance claims require a locked final test set.", + "path": "manuscripts[0].models[0].hyperparameterTunedOn", + "remediation": "Move tuning to inner validation folds and rerun the locked test set once.", + "owner": "model reviewer", + "manuscriptId": "urban-heat-random-split", + "modelId": "rf-heat-risk" + }, + { + "code": "DATA_MANIFEST_MISSING", + "severity": "high", + "message": "urban-heat-random-split is missing reproducibility artifact dataManifest.", + "evidence": "Geospatial results depend on data, code, and environment parity.", + "path": "manuscripts[0].reproducibilityArtifacts.dataManifest", + "remediation": "Attach a data manifest with sample ids, coordinates, split labels, and hashes.", + "owner": "reproducibility reviewer", + "manuscriptId": "urban-heat-random-split", + "modelId": null + }, + { + "code": "ENVIRONMENT_SPEC_MISSING", + "severity": "high", + "message": "urban-heat-random-split is missing reproducibility artifact environmentSpec.", + "evidence": "Geospatial results depend on data, code, and environment parity.", + "path": "manuscripts[0].reproducibilityArtifacts.environmentSpec", + "remediation": "Attach a pinned environment or container digest for spatial libraries.", + "owner": "reproducibility reviewer", + "manuscriptId": "urban-heat-random-split", + "modelId": null + }, + { + "code": "FULL_DATASET_PREPROCESSING_LEAKAGE", + "severity": "high", + "message": "urban-heat-random-split/rf-heat-risk fits spatial preprocessing on the full dataset.", + "evidence": "Raster normalization, imputation, or feature selection must be learned inside each training fold.", + "path": "manuscripts[0].models[0].preprocessingFitScope", + "remediation": "Refit preprocessing inside training folds and attach fold-specific transformation hashes.", + "owner": "reproducibility reviewer", + "manuscriptId": "urban-heat-random-split", + "modelId": "rf-heat-risk" + }, + { + "code": "HIGH_SPATIAL_AUTOCORRELATION_RANDOM_SPLIT", + "severity": "high", + "message": "urban-heat-random-split/rf-heat-risk reports Moran's I 0.62 with a random split.", + "evidence": "High spatial autocorrelation inflates random train/test validation.", + "path": "manuscripts[0].models[0].moransI", + "remediation": "Run spatial block cross-validation or leave-region-out validation before presenting performance as reviewer-ready.", + "owner": "spatial statistics reviewer", + "manuscriptId": "urban-heat-random-split", + "modelId": "rf-heat-risk" + }, + { + "code": "MISSING_CRS_EVIDENCE", + "severity": "high", + "message": "urban-heat-random-split does not declare a coordinate reference system.", + "evidence": "Spatial distances, joins, and raster overlays cannot be reviewed without CRS evidence.", + "path": "manuscripts[0].spatialDesign.crs", + "remediation": "Declare the source CRS/EPSG code and any analysis projection used for distance or area operations.", + "owner": "geospatial methods reviewer", + "manuscriptId": "urban-heat-random-split", + "modelId": null + }, + { + "code": "MISSING_EXTERNAL_SPATIAL_VALIDATION", + "severity": "high", + "message": "urban-heat-random-split/rf-heat-risk lacks external spatial validation for broader deployment claims.", + "evidence": "Broad geographic or deployment claims should be checked outside the training geography.", + "path": "manuscripts[0].models[0].externalValidationSites", + "remediation": "Add an out-of-region validation site or limit the manuscript claim to the sampled geography.", + "owner": "methods reviewer", + "manuscriptId": "urban-heat-random-split", + "modelId": "rf-heat-risk" + }, + { + "code": "OVERBROAD_GEOGRAPHIC_CLAIM", + "severity": "high", + "message": "urban-heat-random-split makes a broad geographic claim with only 1 observed region(s).", + "evidence": "The model generalizes across continental urban heat islands.", + "path": "manuscripts[0].claims[0]", + "remediation": "Limit the claim to sampled regions or add external validation sites covering the claimed geography.", + "owner": "methods reviewer", + "manuscriptId": "urban-heat-random-split", + "modelId": null + }, + { + "code": "SENSITIVE_COORDINATE_OVERPRECISION", + "severity": "high", + "message": "urban-heat-random-split exposes sensitive locations at 6 decimal places.", + "evidence": "Human-subject or protected-species locations should be generalized before reviewer packets or public summaries.", + "path": "manuscripts[0].spatialDesign.coordinatePrecisionDecimals", + "remediation": "Round or jitter coordinates to 4 decimals or provide an approved restricted-location access path.", + "owner": "privacy reviewer", + "manuscriptId": "urban-heat-random-split", + "modelId": null + }, + { + "code": "COVARIATE_SOURCE_MISSING", + "severity": "warning", + "message": "urban-heat-random-split/rf-heat-risk covariate NDVI has no source citation or artifact id.", + "evidence": "Raster/vector covariates should be traceable for reproducibility and recency review.", + "path": "manuscripts[0].models[0].covariates[0].source", + "remediation": "Attach a source DOI, artifact id, or repository path for each spatial covariate.", + "owner": "data steward", + "manuscriptId": "urban-heat-random-split", + "modelId": "rf-heat-risk" + }, + { + "code": "SPATIAL_BLOCK_MAP_MISSING", + "severity": "warning", + "message": "urban-heat-random-split has spatial validation claims without a block map artifact.", + "evidence": "Reviewers need the held-out geometry or block map to audit leakage.", + "path": "manuscripts[0].reproducibilityArtifacts.spatialBlockMap", + "remediation": "Attach a block-map artifact id, geometry hash, or leave-site-out manifest.", + "owner": "geospatial methods reviewer", + "manuscriptId": "urban-heat-random-split", + "modelId": null + }, + { + "code": "STALE_COVARIATE_WINDOW", + "severity": "warning", + "message": "urban-heat-random-split/rf-heat-risk covariate NDVI spans 540 acquisition days.", + "evidence": "Long covariate windows can hide temporal drift in geospatial models.", + "path": "manuscripts[0].models[0].covariates[0].acquisitionWindowDays", + "remediation": "Use period-matched covariates or report temporal-drift sensitivity checks.", + "owner": "methods reviewer", + "manuscriptId": "urban-heat-random-split", + "modelId": "rf-heat-risk" + } + ], + "reviewDecisions": [ + { + "manuscriptId": "urban-heat-random-split", + "decision": "HOLD", + "reasonCodes": [ + "SPATIAL_SPLIT_LEAKAGE", + "TEST_SET_TUNING", + "DATA_MANIFEST_MISSING", + "ENVIRONMENT_SPEC_MISSING", + "FULL_DATASET_PREPROCESSING_LEAKAGE", + "HIGH_SPATIAL_AUTOCORRELATION_RANDOM_SPLIT", + "MISSING_CRS_EVIDENCE", + "MISSING_EXTERNAL_SPATIAL_VALIDATION", + "OVERBROAD_GEOGRAPHIC_CLAIM", + "SENSITIVE_COORDINATE_OVERPRECISION", + "COVARIATE_SOURCE_MISSING", + "SPATIAL_BLOCK_MAP_MISSING", + "STALE_COVARIATE_WINDOW" + ], + "reproducibilityScore": 0 + } + ], + "researchGapOpportunities": [ + { + "id": "urban-heat-random-split-regional-replication", + "title": "Prioritize out-of-region replication before broad geographic claims", + "rationale": "urban-heat-random-split samples 1 region(s), below the 3-region policy for broad claims.", + "firstAction": "Recruit or simulate a holdout site in the least represented claimed region." + }, + { + "id": "urban-heat-random-split-spatial-validation-gap", + "title": "Add spatial block validation benchmark", + "rationale": "High autocorrelation with random validation means reported accuracy may be optimistic.", + "firstAction": "Create a leave-region-out benchmark and compare it to the random split baseline." + } + ], + "remediationActions": [ + { + "code": "SPATIAL_SPLIT_LEAKAGE", + "manuscriptId": "urban-heat-random-split", + "modelId": "rf-heat-risk", + "owner": "model reviewer", + "action": "Use spatial block, leave-site-out, or regional holdout validation and regenerate performance claims." + }, + { + "code": "TEST_SET_TUNING", + "manuscriptId": "urban-heat-random-split", + "modelId": "rf-heat-risk", + "owner": "model reviewer", + "action": "Move tuning to inner validation folds and rerun the locked test set once." + }, + { + "code": "DATA_MANIFEST_MISSING", + "manuscriptId": "urban-heat-random-split", + "modelId": null, + "owner": "reproducibility reviewer", + "action": "Attach a data manifest with sample ids, coordinates, split labels, and hashes." + }, + { + "code": "ENVIRONMENT_SPEC_MISSING", + "manuscriptId": "urban-heat-random-split", + "modelId": null, + "owner": "reproducibility reviewer", + "action": "Attach a pinned environment or container digest for spatial libraries." + }, + { + "code": "FULL_DATASET_PREPROCESSING_LEAKAGE", + "manuscriptId": "urban-heat-random-split", + "modelId": "rf-heat-risk", + "owner": "reproducibility reviewer", + "action": "Refit preprocessing inside training folds and attach fold-specific transformation hashes." + }, + { + "code": "HIGH_SPATIAL_AUTOCORRELATION_RANDOM_SPLIT", + "manuscriptId": "urban-heat-random-split", + "modelId": "rf-heat-risk", + "owner": "spatial statistics reviewer", + "action": "Run spatial block cross-validation or leave-region-out validation before presenting performance as reviewer-ready." + }, + { + "code": "MISSING_CRS_EVIDENCE", + "manuscriptId": "urban-heat-random-split", + "modelId": null, + "owner": "geospatial methods reviewer", + "action": "Declare the source CRS/EPSG code and any analysis projection used for distance or area operations." + }, + { + "code": "MISSING_EXTERNAL_SPATIAL_VALIDATION", + "manuscriptId": "urban-heat-random-split", + "modelId": "rf-heat-risk", + "owner": "methods reviewer", + "action": "Add an out-of-region validation site or limit the manuscript claim to the sampled geography." + }, + { + "code": "OVERBROAD_GEOGRAPHIC_CLAIM", + "manuscriptId": "urban-heat-random-split", + "modelId": null, + "owner": "methods reviewer", + "action": "Limit the claim to sampled regions or add external validation sites covering the claimed geography." + }, + { + "code": "SENSITIVE_COORDINATE_OVERPRECISION", + "manuscriptId": "urban-heat-random-split", + "modelId": null, + "owner": "privacy reviewer", + "action": "Round or jitter coordinates to 4 decimals or provide an approved restricted-location access path." + }, + { + "code": "COVARIATE_SOURCE_MISSING", + "manuscriptId": "urban-heat-random-split", + "modelId": "rf-heat-risk", + "owner": "data steward", + "action": "Attach a source DOI, artifact id, or repository path for each spatial covariate." + }, + { + "code": "SPATIAL_BLOCK_MAP_MISSING", + "manuscriptId": "urban-heat-random-split", + "modelId": null, + "owner": "geospatial methods reviewer", + "action": "Attach a block-map artifact id, geometry hash, or leave-site-out manifest." + }, + { + "code": "STALE_COVARIATE_WINDOW", + "manuscriptId": "urban-heat-random-split", + "modelId": "rf-heat-risk", + "owner": "methods reviewer", + "action": "Use period-matched covariates or report temporal-drift sensitivity checks." + } + ], + "fingerprint": "e036107e72f70a7e" +} diff --git a/geospatial-spatial-autocorrelation-assistant/reports/risky-review.md b/geospatial-spatial-autocorrelation-assistant/reports/risky-review.md new file mode 100644 index 00000000..e4579b20 --- /dev/null +++ b/geospatial-spatial-autocorrelation-assistant/reports/risky-review.md @@ -0,0 +1,64 @@ +# Geospatial Spatial-Autocorrelation Review Assistant + +Packet: geo-review-risky-2026-06 +Status: HOLD +Fingerprint: e036107e72f70a7e + +## Summary + +HOLD: 1 manuscript(s) produced 13 finding(s): 2 critical, 8 high, 3 warning, and 2 research gap prompt(s). + +## Manuscript Decisions + +- urban-heat-random-split: HOLD; reproducibility score 0/100; 13 finding(s) + +## Findings + +- CRITICAL SPATIAL_SPLIT_LEAKAGE: urban-heat-random-split/rf-heat-risk has train/test samples only 0.6 km apart without spatial blocking. + - Evidence: Policy requires at least 35 km or explicit spatial block validation. + - Remediation: Use spatial block, leave-site-out, or regional holdout validation and regenerate performance claims. +- CRITICAL TEST_SET_TUNING: urban-heat-random-split/rf-heat-risk tunes model choices on the test/holdout set. + - Evidence: Reviewer-facing performance claims require a locked final test set. + - Remediation: Move tuning to inner validation folds and rerun the locked test set once. +- HIGH DATA_MANIFEST_MISSING: urban-heat-random-split is missing reproducibility artifact dataManifest. + - Evidence: Geospatial results depend on data, code, and environment parity. + - Remediation: Attach a data manifest with sample ids, coordinates, split labels, and hashes. +- HIGH ENVIRONMENT_SPEC_MISSING: urban-heat-random-split is missing reproducibility artifact environmentSpec. + - Evidence: Geospatial results depend on data, code, and environment parity. + - Remediation: Attach a pinned environment or container digest for spatial libraries. +- HIGH FULL_DATASET_PREPROCESSING_LEAKAGE: urban-heat-random-split/rf-heat-risk fits spatial preprocessing on the full dataset. + - Evidence: Raster normalization, imputation, or feature selection must be learned inside each training fold. + - Remediation: Refit preprocessing inside training folds and attach fold-specific transformation hashes. +- HIGH HIGH_SPATIAL_AUTOCORRELATION_RANDOM_SPLIT: urban-heat-random-split/rf-heat-risk reports Moran's I 0.62 with a random split. + - Evidence: High spatial autocorrelation inflates random train/test validation. + - Remediation: Run spatial block cross-validation or leave-region-out validation before presenting performance as reviewer-ready. +- HIGH MISSING_CRS_EVIDENCE: urban-heat-random-split does not declare a coordinate reference system. + - Evidence: Spatial distances, joins, and raster overlays cannot be reviewed without CRS evidence. + - Remediation: Declare the source CRS/EPSG code and any analysis projection used for distance or area operations. +- HIGH MISSING_EXTERNAL_SPATIAL_VALIDATION: urban-heat-random-split/rf-heat-risk lacks external spatial validation for broader deployment claims. + - Evidence: Broad geographic or deployment claims should be checked outside the training geography. + - Remediation: Add an out-of-region validation site or limit the manuscript claim to the sampled geography. +- HIGH OVERBROAD_GEOGRAPHIC_CLAIM: urban-heat-random-split makes a broad geographic claim with only 1 observed region(s). + - Evidence: The model generalizes across continental urban heat islands. + - Remediation: Limit the claim to sampled regions or add external validation sites covering the claimed geography. +- HIGH SENSITIVE_COORDINATE_OVERPRECISION: urban-heat-random-split exposes sensitive locations at 6 decimal places. + - Evidence: Human-subject or protected-species locations should be generalized before reviewer packets or public summaries. + - Remediation: Round or jitter coordinates to 4 decimals or provide an approved restricted-location access path. +- WARNING COVARIATE_SOURCE_MISSING: urban-heat-random-split/rf-heat-risk covariate NDVI has no source citation or artifact id. + - Evidence: Raster/vector covariates should be traceable for reproducibility and recency review. + - Remediation: Attach a source DOI, artifact id, or repository path for each spatial covariate. +- WARNING SPATIAL_BLOCK_MAP_MISSING: urban-heat-random-split has spatial validation claims without a block map artifact. + - Evidence: Reviewers need the held-out geometry or block map to audit leakage. + - Remediation: Attach a block-map artifact id, geometry hash, or leave-site-out manifest. +- WARNING STALE_COVARIATE_WINDOW: urban-heat-random-split/rf-heat-risk covariate NDVI spans 540 acquisition days. + - Evidence: Long covariate windows can hide temporal drift in geospatial models. + - Remediation: Use period-matched covariates or report temporal-drift sensitivity checks. + +## Research Gap Opportunities + +- urban-heat-random-split-regional-replication: Prioritize out-of-region replication before broad geographic claims + - Rationale: urban-heat-random-split samples 1 region(s), below the 3-region policy for broad claims. + - First action: Recruit or simulate a holdout site in the least represented claimed region. +- urban-heat-random-split-spatial-validation-gap: Add spatial block validation benchmark + - Rationale: High autocorrelation with random validation means reported accuracy may be optimistic. + - First action: Create a leave-region-out benchmark and compare it to the random split baseline. diff --git a/geospatial-spatial-autocorrelation-assistant/reports/summary.svg b/geospatial-spatial-autocorrelation-assistant/reports/summary.svg new file mode 100644 index 00000000..1e389e92 --- /dev/null +++ b/geospatial-spatial-autocorrelation-assistant/reports/summary.svg @@ -0,0 +1,13 @@ + + + +Geospatial review assistant +Status HOLD - fingerprint e036107e72f70a7e + + + +SPATIAL QA +Critical/high blockers: 10 +Research gaps: 2 +Manuscripts checked: 1 + \ No newline at end of file diff --git a/geospatial-spatial-autocorrelation-assistant/sample-data.js b/geospatial-spatial-autocorrelation-assistant/sample-data.js new file mode 100644 index 00000000..60fd69d9 --- /dev/null +++ b/geospatial-spatial-autocorrelation-assistant/sample-data.js @@ -0,0 +1,122 @@ +"use strict"; + +const riskyPacket = { + id: "geo-review-risky-2026-06", + policy: { + minSpatialHoldoutKm: 35, + highMoransI: 0.35, + maxSensitivePrecisionDecimals: 4, + minRegionsForBroadClaims: 3, + maxCovariateWindowDays: 365 + }, + manuscripts: [ + { + id: "urban-heat-random-split", + title: "Continental urban heat risk from neighborhood satellite features", + field: "environmental epidemiology", + sensitivity: "human-subjects", + spatialDesign: { + crs: "", + projection: "web map tiles", + coordinatePrecisionDecimals: 6, + samplingFrame: "three volunteer neighborhoods" + }, + claims: [ + { + id: "claim-generalization", + scope: "continental", + claimedRegions: ["Northeast", "Midwest", "South", "West"], + text: "The model generalizes across continental urban heat islands." + } + ], + samples: [ + { id: "s-001", lat: 40.712776, lon: -74.005974, split: "train", region: "Northeast", site: "NYC-A" }, + { id: "s-002", lat: 40.734112, lon: -73.98742, split: "test", region: "Northeast", site: "NYC-B" }, + { id: "s-003", lat: 40.75891, lon: -73.98513, split: "train", region: "Northeast", site: "NYC-C" }, + { id: "s-004", lat: 40.76172, lon: -73.97864, split: "test", region: "Northeast", site: "NYC-D" } + ], + models: [ + { + id: "rf-heat-risk", + splitStrategy: "random", + moransI: 0.62, + preprocessingFitScope: "full_dataset", + hyperparameterTunedOn: "test", + deploymentContext: "national heat-risk triage", + spatialCovariates: true, + externalValidationSites: [], + covariates: [ + { name: "NDVI", source: "", resolutionMeters: 1000, acquisitionWindowDays: 540 }, + { name: "impervious_surface", source: "city-open-data:impervious-v1", resolutionMeters: 30, acquisitionWindowDays: 90 } + ] + } + ], + reproducibilityArtifacts: { + dataManifest: "", + codeCommit: "2f7c91e", + environmentSpec: "", + spatialBlockMap: "" + } + } + ] +}; + +const cleanPacket = { + id: "geo-review-clean-2026-06", + policy: riskyPacket.policy, + manuscripts: [ + { + id: "rangeland-blocked-validation", + title: "Regional rangeland recovery forecasts with blocked spatial validation", + field: "ecology", + sensitivity: "public-environmental", + spatialDesign: { + crs: "EPSG:4326 WGS84 source coordinates; EPSG:5070 equal-area analysis projection", + projection: "EPSG:5070", + coordinatePrecisionDecimals: 3, + samplingFrame: "blocked stratified ecological sites" + }, + claims: [ + { + id: "claim-regional", + scope: "regional", + claimedRegions: ["Colorado Front Range", "New Mexico Plateau", "Utah Basin"], + text: "The blocked model supports regional recovery forecasts for sampled western rangeland systems." + } + ], + samples: [ + { id: "co-001", lat: 39.739, lon: -104.99, split: "train", region: "Colorado Front Range", site: "CO-A" }, + { id: "co-002", lat: 39.231, lon: -105.02, split: "train", region: "Colorado Front Range", site: "CO-B" }, + { id: "nm-001", lat: 35.084, lon: -106.65, split: "test", region: "New Mexico Plateau", site: "NM-A" }, + { id: "ut-001", lat: 40.760, lon: -111.89, split: "test", region: "Utah Basin", site: "UT-A" } + ], + models: [ + { + id: "blocked-gbm-recovery", + splitStrategy: "spatial_block_leave_region_out", + moransI: 0.18, + preprocessingFitScope: "training_fold", + hyperparameterTunedOn: "inner_validation", + deploymentContext: "", + spatialCovariates: true, + externalValidationSites: ["New Mexico Plateau", "Utah Basin"], + covariates: [ + { name: "soil_moisture", source: "doi:10.1234/soil-moisture-v3", resolutionMeters: 250, acquisitionWindowDays: 30 }, + { name: "burn_severity", source: "artifact:burn-severity-2026-05", resolutionMeters: 30, acquisitionWindowDays: 12 } + ] + } + ], + reproducibilityArtifacts: { + dataManifest: "artifact:geo-sample-manifest-v2", + codeCommit: "d6b0e3c", + environmentSpec: "container:ghcr.io/scibase/geo-review@sha256:abc123", + spatialBlockMap: "artifact:block-map-v2" + } + } + ] +}; + +module.exports = { + riskyPacket, + cleanPacket +}; diff --git a/geospatial-spatial-autocorrelation-assistant/test.js b/geospatial-spatial-autocorrelation-assistant/test.js new file mode 100644 index 00000000..9628888f --- /dev/null +++ b/geospatial-spatial-autocorrelation-assistant/test.js @@ -0,0 +1,60 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { + evaluateGeospatialReviewPacket, + renderMarkdownReport, + renderSvgSummary, + haversineKm +} = require("./index"); +const { riskyPacket, cleanPacket } = require("./sample-data"); + +assert.throws(() => evaluateGeospatialReviewPacket(null), /expects a packet object/); + +const risky = evaluateGeospatialReviewPacket(riskyPacket, { now: "2026-06-01T10:30:00.000Z" }); +assert.equal(risky.status, "HOLD"); +assert.equal(risky.reviewDecisions[0].decision, "HOLD"); +assert.ok(risky.findings.some((item) => item.code === "SPATIAL_SPLIT_LEAKAGE")); +assert.ok(risky.findings.some((item) => item.code === "MISSING_CRS_EVIDENCE")); +assert.ok(risky.findings.some((item) => item.code === "HIGH_SPATIAL_AUTOCORRELATION_RANDOM_SPLIT")); +assert.ok(risky.findings.some((item) => item.code === "TEST_SET_TUNING")); +assert.ok(risky.researchGapOpportunities.length >= 2); +assert.ok(risky.reviewDecisions[0].reproducibilityScore < 50); + +const riskyRepeat = evaluateGeospatialReviewPacket(riskyPacket, { now: "2026-06-01T10:31:00.000Z" }); +assert.equal(risky.fingerprint, riskyRepeat.fingerprint); + +const clean = evaluateGeospatialReviewPacket(cleanPacket, { now: "2026-06-01T10:30:00.000Z" }); +assert.equal(clean.status, "READY"); +assert.equal(clean.findings.length, 0); +assert.equal(clean.reviewDecisions[0].reproducibilityScore, 100); + +const invalidCoordinatePacket = { + id: "invalid-coordinate", + manuscripts: [ + { + id: "bad-coordinate", + spatialDesign: { crs: "EPSG:4326", coordinatePrecisionDecimals: 2 }, + claims: [], + samples: [{ id: "bad", lat: 110, lon: -74, split: "train", region: "X" }], + models: [], + reproducibilityArtifacts: { dataManifest: "m", codeCommit: "c", environmentSpec: "e" } + } + ] +}; +const invalid = evaluateGeospatialReviewPacket(invalidCoordinatePacket, { now: "2026-06-01T10:30:00.000Z" }); +assert.ok(invalid.findings.some((item) => item.code === "INVALID_COORDINATE")); + +const markdown = renderMarkdownReport(risky, riskyPacket); +assert.ok(markdown.includes("Spatial-Autocorrelation")); +assert.ok(markdown.includes("SPATIAL_SPLIT_LEAKAGE")); +assert.ok(markdown.includes("Research Gap Opportunities")); + +const svg = renderSvgSummary(risky); +assert.ok(svg.includes(" 2 && distance < 4); + +console.log("All geospatial spatial-autocorrelation assistant tests passed.");