diff --git a/paper-ingestion-quality-gate/README.md b/paper-ingestion-quality-gate/README.md
new file mode 100644
index 00000000..2c2f8082
--- /dev/null
+++ b/paper-ingestion-quality-gate/README.md
@@ -0,0 +1,48 @@
+# Paper Ingestion Quality Gate
+
+This contribution adds a focused intake guard for the AI Paper Summarizer in SCIBASE's AI-Assisted Research Tools MVP. It checks whether an uploaded or repository-linked paper has been extracted cleanly enough before an AI summarizer generates key findings, implications, or next steps.
+
+The slice is intentionally narrow. It does not score the generated summary, validate citation context, check journal venue quality, assess statistics, run reporting guidelines, detect plagiarism, or review disclosure statements. It sits earlier in the workflow: before summarization, when broken PDF/OCR/table/reference extraction can quietly poison every downstream AI output.
+
+## What It Checks
+
+- Dropped pages between the source PDF and extracted text.
+- Required scientific sections: abstract, methods, results, and discussion.
+- OCR confidence and unrecognized glyph noise.
+- Table extraction completeness before numeric key findings are generated.
+- Figure caption extraction before figure-derived summary bullets are allowed.
+- Reference parsing coverage before citation-based implications are generated.
+- Equation extraction coverage for mathematical or methods summaries.
+- Required extraction provenance for text, sections, tables, figures, references, and digests.
+- Whether key-finding generation is incorrectly enabled while intake findings are unresolved.
+
+## Running Locally
+
+```bash
+npm test
+npm run demo
+npm run check
+npm run video
+```
+
+The demo uses only synthetic intake packets in `data/sample_ingestion_packets.json`.
+
+Generated files:
+
+- `reports/summary.json`
+- `reports/reviewer-packet.md`
+- `reports/summary.svg`
+- `reports/demo.mp4`
+
+The MP4 is generated from an FFmpeg color source and drawtext slate. It is not a screen recording and contains no desktop capture or private information.
+
+## Why This Matters
+
+AI summarizers can sound confident even when source extraction is broken. A missing methods section, low-quality OCR, dropped pages, unparsed tables, or failed references can lead to unsupported key findings and misleading implications.
+
+This guard creates a deterministic intake review packet so SCIBASE can:
+
+1. Block summarization when the source is incomplete.
+2. Route questionable intake packets for manual review.
+3. Suppress table, figure, reference, or equation-derived summary bullets when extraction is incomplete.
+4. Preserve extraction provenance for reviewers and collaborators.
diff --git a/paper-ingestion-quality-gate/data/sample_ingestion_packets.json b/paper-ingestion-quality-gate/data/sample_ingestion_packets.json
new file mode 100644
index 00000000..593b83ab
--- /dev/null
+++ b/paper-ingestion-quality-gate/data/sample_ingestion_packets.json
@@ -0,0 +1,150 @@
+[
+ {
+ "id": "ingest-summarize-001",
+ "title": "Clean paper intake for abstract and executive summary generation",
+ "source": {
+ "fileType": "pdf",
+ "pageCount": 12
+ },
+ "expected": {
+ "tables": 2,
+ "figures": 3,
+ "references": 42,
+ "minEquationCoverage": 0.9
+ },
+ "extraction": {
+ "pagesExtracted": 12,
+ "sectionsPresent": ["abstract", "introduction", "methods", "results", "discussion", "references"],
+ "ocrConfidence": 0.98,
+ "unrecognizedGlyphRate": 0.002,
+ "textCoverage": 0.96,
+ "tablesExtracted": 2,
+ "figureCaptionsExtracted": 3,
+ "referencesParsed": 41,
+ "equationCoverage": 0.93
+ },
+ "summarizer": {
+ "generateKeyFindings": true,
+ "requireCleanIntake": true
+ },
+ "provenance": {
+ "textLayer": true,
+ "sectionMap": true,
+ "tableMap": true,
+ "figureCaptionMap": true,
+ "referenceMap": true,
+ "extractionDigest": true
+ }
+ },
+ {
+ "id": "ingest-hold-002",
+ "title": "OCR-only scan with missing methods and dropped pages",
+ "source": {
+ "fileType": "pdf_scan",
+ "pageCount": 18
+ },
+ "expected": {
+ "tables": 4,
+ "figures": 6,
+ "references": 61,
+ "minEquationCoverage": 0.9
+ },
+ "extraction": {
+ "pagesExtracted": 15,
+ "sectionsPresent": ["abstract", "introduction", "discussion", "references"],
+ "ocrConfidence": 0.61,
+ "unrecognizedGlyphRate": 0.092,
+ "textCoverage": 0.62,
+ "tablesExtracted": 1,
+ "figureCaptionsExtracted": 3,
+ "referencesParsed": 25,
+ "equationCoverage": 0.41
+ },
+ "summarizer": {
+ "generateKeyFindings": true,
+ "requireCleanIntake": false
+ },
+ "provenance": {
+ "textLayer": true,
+ "sectionMap": false,
+ "tableMap": false,
+ "figureCaptionMap": false,
+ "referenceMap": false,
+ "extractionDigest": false
+ }
+ },
+ {
+ "id": "ingest-review-003",
+ "title": "Good text extraction with incomplete tables and captions",
+ "source": {
+ "fileType": "pdf",
+ "pageCount": 9
+ },
+ "expected": {
+ "tables": 3,
+ "figures": 4,
+ "references": 30,
+ "minEquationCoverage": 0.85
+ },
+ "extraction": {
+ "pagesExtracted": 9,
+ "sectionsPresent": ["abstract", "introduction", "methods", "results", "discussion", "references"],
+ "ocrConfidence": 0.94,
+ "unrecognizedGlyphRate": 0.006,
+ "textCoverage": 0.91,
+ "tablesExtracted": 2,
+ "figureCaptionsExtracted": 2,
+ "referencesParsed": 28,
+ "equationCoverage": 0.87
+ },
+ "summarizer": {
+ "generateKeyFindings": false,
+ "requireCleanIntake": true
+ },
+ "provenance": {
+ "textLayer": true,
+ "sectionMap": true,
+ "tableMap": true,
+ "figureCaptionMap": false,
+ "referenceMap": true,
+ "extractionDigest": true
+ }
+ },
+ {
+ "id": "ingest-hold-004",
+ "title": "Reference and equation extraction failure before implications summary",
+ "source": {
+ "fileType": "pdf",
+ "pageCount": 14
+ },
+ "expected": {
+ "tables": 1,
+ "figures": 2,
+ "references": 48,
+ "minEquationCoverage": 0.9
+ },
+ "extraction": {
+ "pagesExtracted": 14,
+ "sectionsPresent": ["abstract", "introduction", "methods", "results", "discussion", "references"],
+ "ocrConfidence": 0.9,
+ "unrecognizedGlyphRate": 0.01,
+ "textCoverage": 0.88,
+ "tablesExtracted": 1,
+ "figureCaptionsExtracted": 2,
+ "referencesParsed": 20,
+ "equationCoverage": 0.45
+ },
+ "summarizer": {
+ "generateKeyFindings": true,
+ "requireCleanIntake": false
+ },
+ "provenance": {
+ "textLayer": true,
+ "sectionMap": true,
+ "tableMap": true,
+ "figureCaptionMap": true,
+ "referenceMap": false,
+ "extractionDigest": true
+ }
+ }
+]
diff --git a/paper-ingestion-quality-gate/package.json b/paper-ingestion-quality-gate/package.json
new file mode 100644
index 00000000..a31e60e7
--- /dev/null
+++ b/paper-ingestion-quality-gate/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "paper-ingestion-quality-gate",
+ "version": "1.0.0",
+ "description": "Dependency-free AI summarizer intake guard for broken paper/OCR/table/reference extraction.",
+ "main": "src/index.js",
+ "scripts": {
+ "test": "node --test",
+ "demo": "node scripts/demo.js",
+ "video": "node scripts/render-demo-video.js",
+ "check": "node --check src/index.js && node --check scripts/demo.js && node --check scripts/render-demo-video.js"
+ },
+ "license": "MIT"
+}
diff --git a/paper-ingestion-quality-gate/reports/demo.mp4 b/paper-ingestion-quality-gate/reports/demo.mp4
new file mode 100644
index 00000000..e10ca8ac
Binary files /dev/null and b/paper-ingestion-quality-gate/reports/demo.mp4 differ
diff --git a/paper-ingestion-quality-gate/reports/reviewer-packet.md b/paper-ingestion-quality-gate/reports/reviewer-packet.md
new file mode 100644
index 00000000..8fc7cc75
--- /dev/null
+++ b/paper-ingestion-quality-gate/reports/reviewer-packet.md
@@ -0,0 +1,69 @@
+# Paper Ingestion Quality Gate Report
+
+Generated: 2026-08-15T22:05:11.925Z
+Packets analyzed: 4
+Decision counts: SUMMARIZE 1, REVIEW 1, HOLD 2
+
+## Findings
+
+### ingest-summarize-001: Clean paper intake for abstract and executive summary generation
+
+Decision: SUMMARIZE
+Completeness: 0.984
+
+- No intake quality risk detected.
+
+Recommended actions:
+- Allow AI summarization with normal extraction provenance attached.
+
+### ingest-hold-002: OCR-only scan with missing methods and dropped pages
+
+Decision: HOLD
+Completeness: 0.528
+
+- CRITICAL PDF_PAGE_DROP_DETECTED: The source paper has pages that were not extracted.
+- CRITICAL REQUIRED_SECTION_MISSING: Required scientific sections are missing from the extracted text map.
+- CRITICAL OCR_OR_ENCODING_QUALITY_LOW: OCR confidence or glyph quality is too poor for a reliable AI-generated summary.
+- CRITICAL TABLE_EXTRACTION_INCOMPLETE: One or more expected tables were not extracted for summarizer grounding.
+- MAJOR FIGURE_CAPTION_MAP_INCOMPLETE: Figure captions are missing from the summarizer intake packet.
+- MAJOR REFERENCE_PARSE_COVERAGE_LOW: Reference parsing coverage is too low for reliable related-work or implication summaries.
+- MAJOR EQUATION_EXTRACTION_COVERAGE_LOW: Equation extraction coverage is below the configured summarizer threshold.
+- MAJOR EXTRACTION_PROVENANCE_INCOMPLETE: The intake packet lacks required extraction provenance flags.
+- CRITICAL INTAKE_COMPLETENESS_BELOW_THRESHOLD: Overall source extraction completeness is below the AI summarizer safety threshold.
+- CRITICAL SUMMARIZER_NOT_GATED_ON_CLEAN_INTAKE: AI key-finding generation is enabled while source-ingestion quality findings are unresolved.
+
+Recommended actions:
+- Disable key-finding generation until intake quality is clean.
+- Attach the intake findings to the AI summarizer queue.
+- Regenerate extraction artifacts before key findings or implications are generated.
+- Show reviewers which source sections, tables, figures, or references are unsafe.
+
+### ingest-review-003: Good text extraction with incomplete tables and captions
+
+Decision: REVIEW
+Completeness: 0.877
+
+- MAJOR TABLE_EXTRACTION_INCOMPLETE: One or more expected tables were not extracted for summarizer grounding.
+- MAJOR FIGURE_CAPTION_MAP_INCOMPLETE: Figure captions are missing from the summarizer intake packet.
+- MAJOR EXTRACTION_PROVENANCE_INCOMPLETE: The intake packet lacks required extraction provenance flags.
+
+Recommended actions:
+- Attach the intake findings to the AI summarizer queue.
+- Regenerate extraction artifacts before key findings or implications are generated.
+- Show reviewers which source sections, tables, figures, or references are unsafe.
+
+### ingest-hold-004: Reference and equation extraction failure before implications summary
+
+Decision: HOLD
+Completeness: 0.824
+
+- MAJOR REFERENCE_PARSE_COVERAGE_LOW: Reference parsing coverage is too low for reliable related-work or implication summaries.
+- MAJOR EQUATION_EXTRACTION_COVERAGE_LOW: Equation extraction coverage is below the configured summarizer threshold.
+- MAJOR EXTRACTION_PROVENANCE_INCOMPLETE: The intake packet lacks required extraction provenance flags.
+- CRITICAL SUMMARIZER_NOT_GATED_ON_CLEAN_INTAKE: AI key-finding generation is enabled while source-ingestion quality findings are unresolved.
+
+Recommended actions:
+- Disable key-finding generation until intake quality is clean.
+- Attach the intake findings to the AI summarizer queue.
+- Regenerate extraction artifacts before key findings or implications are generated.
+- Show reviewers which source sections, tables, figures, or references are unsafe.
diff --git a/paper-ingestion-quality-gate/reports/summary.json b/paper-ingestion-quality-gate/reports/summary.json
new file mode 100644
index 00000000..dd0eba72
--- /dev/null
+++ b/paper-ingestion-quality-gate/reports/summary.json
@@ -0,0 +1,241 @@
+{
+ "generatedAt": "2026-08-15T22:05:11.925Z",
+ "totalPackets": 4,
+ "counts": {
+ "SUMMARIZE": 1,
+ "REVIEW": 1,
+ "HOLD": 2
+ },
+ "results": [
+ {
+ "id": "ingest-summarize-001",
+ "title": "Clean paper intake for abstract and executive summary generation",
+ "decision": "SUMMARIZE",
+ "completeness": 0.984,
+ "findings": [],
+ "recommendedActions": [
+ "Allow AI summarization with normal extraction provenance attached."
+ ]
+ },
+ {
+ "id": "ingest-hold-002",
+ "title": "OCR-only scan with missing methods and dropped pages",
+ "decision": "HOLD",
+ "completeness": 0.528,
+ "findings": [
+ {
+ "code": "PDF_PAGE_DROP_DETECTED",
+ "severity": "critical",
+ "message": "The source paper has pages that were not extracted.",
+ "evidence": {
+ "pageCount": 18,
+ "pagesExtracted": 15,
+ "droppedPages": 3
+ },
+ "remediation": "Block summarization until the paper is reprocessed and every page has extraction evidence."
+ },
+ {
+ "code": "REQUIRED_SECTION_MISSING",
+ "severity": "critical",
+ "message": "Required scientific sections are missing from the extracted text map.",
+ "evidence": {
+ "missingSections": [
+ "methods",
+ "results"
+ ]
+ },
+ "remediation": "Re-run section segmentation or require manual section confirmation before summary generation."
+ },
+ {
+ "code": "OCR_OR_ENCODING_QUALITY_LOW",
+ "severity": "critical",
+ "message": "OCR confidence or glyph quality is too poor for a reliable AI-generated summary.",
+ "evidence": {
+ "ocrConfidence": 0.61,
+ "unrecognizedGlyphRate": 0.092
+ },
+ "remediation": "Use a higher-quality text layer or route to manual intake review before summarization."
+ },
+ {
+ "code": "TABLE_EXTRACTION_INCOMPLETE",
+ "severity": "critical",
+ "message": "One or more expected tables were not extracted for summarizer grounding.",
+ "evidence": {
+ "expectedTables": 4,
+ "extractedTables": 1
+ },
+ "remediation": "Hold key-finding and numeric-summary generation until table extraction is complete."
+ },
+ {
+ "code": "FIGURE_CAPTION_MAP_INCOMPLETE",
+ "severity": "major",
+ "message": "Figure captions are missing from the summarizer intake packet.",
+ "evidence": {
+ "expectedFigures": 6,
+ "extractedCaptions": 3
+ },
+ "remediation": "Regenerate figure-caption anchors or suppress figure-derived claims from the summary."
+ },
+ {
+ "code": "REFERENCE_PARSE_COVERAGE_LOW",
+ "severity": "major",
+ "message": "Reference parsing coverage is too low for reliable related-work or implication summaries.",
+ "evidence": {
+ "expectedReferences": 61,
+ "parsedReferences": 25
+ },
+ "remediation": "Suppress citation-based implication bullets until reference parsing is repaired."
+ },
+ {
+ "code": "EQUATION_EXTRACTION_COVERAGE_LOW",
+ "severity": "major",
+ "message": "Equation extraction coverage is below the configured summarizer threshold.",
+ "evidence": {
+ "equationCoverage": 0.41,
+ "minEquationCoverage": 0.9
+ },
+ "remediation": "Warn reviewers and avoid mathematical-method summaries until equation extraction is confirmed."
+ },
+ {
+ "code": "EXTRACTION_PROVENANCE_INCOMPLETE",
+ "severity": "major",
+ "message": "The intake packet lacks required extraction provenance flags.",
+ "evidence": {
+ "missingProvenance": [
+ "sectionMap",
+ "tableMap",
+ "figureCaptionMap",
+ "referenceMap",
+ "extractionDigest"
+ ]
+ },
+ "remediation": "Attach text, section, table, figure, reference, and digest evidence before releasing AI summaries."
+ },
+ {
+ "code": "INTAKE_COMPLETENESS_BELOW_THRESHOLD",
+ "severity": "critical",
+ "message": "Overall source extraction completeness is below the AI summarizer safety threshold.",
+ "evidence": {
+ "completeness": 0.528
+ },
+ "remediation": "Hold summarization and regenerate the intake packet from the source document."
+ },
+ {
+ "code": "SUMMARIZER_NOT_GATED_ON_CLEAN_INTAKE",
+ "severity": "critical",
+ "message": "AI key-finding generation is enabled while source-ingestion quality findings are unresolved.",
+ "evidence": {
+ "generateKeyFindings": true,
+ "requireCleanIntake": false
+ },
+ "remediation": "Require clean intake status before generating key findings, implications, or next steps."
+ }
+ ],
+ "recommendedActions": [
+ "Disable key-finding generation until intake quality is clean.",
+ "Attach the intake findings to the AI summarizer queue.",
+ "Regenerate extraction artifacts before key findings or implications are generated.",
+ "Show reviewers which source sections, tables, figures, or references are unsafe."
+ ]
+ },
+ {
+ "id": "ingest-review-003",
+ "title": "Good text extraction with incomplete tables and captions",
+ "decision": "REVIEW",
+ "completeness": 0.877,
+ "findings": [
+ {
+ "code": "TABLE_EXTRACTION_INCOMPLETE",
+ "severity": "major",
+ "message": "One or more expected tables were not extracted for summarizer grounding.",
+ "evidence": {
+ "expectedTables": 3,
+ "extractedTables": 2
+ },
+ "remediation": "Hold key-finding and numeric-summary generation until table extraction is complete."
+ },
+ {
+ "code": "FIGURE_CAPTION_MAP_INCOMPLETE",
+ "severity": "major",
+ "message": "Figure captions are missing from the summarizer intake packet.",
+ "evidence": {
+ "expectedFigures": 4,
+ "extractedCaptions": 2
+ },
+ "remediation": "Regenerate figure-caption anchors or suppress figure-derived claims from the summary."
+ },
+ {
+ "code": "EXTRACTION_PROVENANCE_INCOMPLETE",
+ "severity": "major",
+ "message": "The intake packet lacks required extraction provenance flags.",
+ "evidence": {
+ "missingProvenance": [
+ "figureCaptionMap"
+ ]
+ },
+ "remediation": "Attach text, section, table, figure, reference, and digest evidence before releasing AI summaries."
+ }
+ ],
+ "recommendedActions": [
+ "Attach the intake findings to the AI summarizer queue.",
+ "Regenerate extraction artifacts before key findings or implications are generated.",
+ "Show reviewers which source sections, tables, figures, or references are unsafe."
+ ]
+ },
+ {
+ "id": "ingest-hold-004",
+ "title": "Reference and equation extraction failure before implications summary",
+ "decision": "HOLD",
+ "completeness": 0.824,
+ "findings": [
+ {
+ "code": "REFERENCE_PARSE_COVERAGE_LOW",
+ "severity": "major",
+ "message": "Reference parsing coverage is too low for reliable related-work or implication summaries.",
+ "evidence": {
+ "expectedReferences": 48,
+ "parsedReferences": 20
+ },
+ "remediation": "Suppress citation-based implication bullets until reference parsing is repaired."
+ },
+ {
+ "code": "EQUATION_EXTRACTION_COVERAGE_LOW",
+ "severity": "major",
+ "message": "Equation extraction coverage is below the configured summarizer threshold.",
+ "evidence": {
+ "equationCoverage": 0.45,
+ "minEquationCoverage": 0.9
+ },
+ "remediation": "Warn reviewers and avoid mathematical-method summaries until equation extraction is confirmed."
+ },
+ {
+ "code": "EXTRACTION_PROVENANCE_INCOMPLETE",
+ "severity": "major",
+ "message": "The intake packet lacks required extraction provenance flags.",
+ "evidence": {
+ "missingProvenance": [
+ "referenceMap"
+ ]
+ },
+ "remediation": "Attach text, section, table, figure, reference, and digest evidence before releasing AI summaries."
+ },
+ {
+ "code": "SUMMARIZER_NOT_GATED_ON_CLEAN_INTAKE",
+ "severity": "critical",
+ "message": "AI key-finding generation is enabled while source-ingestion quality findings are unresolved.",
+ "evidence": {
+ "generateKeyFindings": true,
+ "requireCleanIntake": false
+ },
+ "remediation": "Require clean intake status before generating key findings, implications, or next steps."
+ }
+ ],
+ "recommendedActions": [
+ "Disable key-finding generation until intake quality is clean.",
+ "Attach the intake findings to the AI summarizer queue.",
+ "Regenerate extraction artifacts before key findings or implications are generated.",
+ "Show reviewers which source sections, tables, figures, or references are unsafe."
+ ]
+ }
+ ]
+}
diff --git a/paper-ingestion-quality-gate/reports/summary.svg b/paper-ingestion-quality-gate/reports/summary.svg
new file mode 100644
index 00000000..1a076eab
--- /dev/null
+++ b/paper-ingestion-quality-gate/reports/summary.svg
@@ -0,0 +1,17 @@
+
diff --git a/paper-ingestion-quality-gate/scripts/demo.js b/paper-ingestion-quality-gate/scripts/demo.js
new file mode 100644
index 00000000..192ef09c
--- /dev/null
+++ b/paper-ingestion-quality-gate/scripts/demo.js
@@ -0,0 +1,77 @@
+"use strict";
+
+const fs = require("node:fs");
+const path = require("node:path");
+const { analyzeIngestionPackets } = require("../src");
+
+const root = path.resolve(__dirname, "..");
+const dataPath = path.join(root, "data", "sample_ingestion_packets.json");
+const outDir = path.join(root, "reports");
+const packets = JSON.parse(fs.readFileSync(dataPath, "utf8"));
+const report = analyzeIngestionPackets(packets);
+
+fs.mkdirSync(outDir, { recursive: true });
+fs.writeFileSync(path.join(outDir, "summary.json"), `${JSON.stringify(report, null, 2)}\n`);
+
+const markdown = [
+ "# Paper Ingestion Quality Gate Report",
+ "",
+ `Generated: ${report.generatedAt}`,
+ `Packets analyzed: ${report.totalPackets}`,
+ `Decision counts: SUMMARIZE ${report.counts.SUMMARIZE}, REVIEW ${report.counts.REVIEW}, HOLD ${report.counts.HOLD}`,
+ "",
+ "## Findings",
+ "",
+ ...report.results.flatMap((item) => [
+ `### ${item.id}: ${item.title}`,
+ "",
+ `Decision: ${item.decision}`,
+ `Completeness: ${item.completeness}`,
+ "",
+ item.findings.length === 0
+ ? "- No intake quality risk detected."
+ : item.findings.map((finding) => `- ${finding.severity.toUpperCase()} ${finding.code}: ${finding.message}`).join("\n"),
+ "",
+ "Recommended actions:",
+ ...item.recommendedActions.map((action) => `- ${action}`),
+ ""
+ ])
+].join("\n");
+
+fs.writeFileSync(path.join(outDir, "reviewer-packet.md"), markdown);
+
+const total = Math.max(1, report.totalPackets);
+const maxBar = 620;
+const holdWidth = Math.round((report.counts.HOLD / total) * maxBar);
+const reviewWidth = Math.round((report.counts.REVIEW / total) * maxBar);
+const summarizeWidth = Math.round((report.counts.SUMMARIZE / total) * maxBar);
+const svg = `
+`;
+
+fs.writeFileSync(path.join(outDir, "summary.svg"), svg);
+
+console.log(JSON.stringify({
+ packets: report.totalPackets,
+ counts: report.counts,
+ outputs: [
+ path.join(outDir, "summary.json"),
+ path.join(outDir, "reviewer-packet.md"),
+ path.join(outDir, "summary.svg")
+ ]
+}, null, 2));
diff --git a/paper-ingestion-quality-gate/scripts/render-demo-video.js b/paper-ingestion-quality-gate/scripts/render-demo-video.js
new file mode 100644
index 00000000..804fca35
--- /dev/null
+++ b/paper-ingestion-quality-gate/scripts/render-demo-video.js
@@ -0,0 +1,60 @@
+"use strict";
+
+const fs = require("node:fs");
+const path = require("node:path");
+const { spawnSync } = require("node:child_process");
+
+const root = path.resolve(__dirname, "..");
+const ffmpeg = path.resolve(
+ root,
+ "..",
+ "..",
+ "tool_downloads",
+ "video_tools",
+ "node_modules",
+ "ffmpeg-static",
+ "ffmpeg.exe"
+);
+const outDir = path.join(root, "reports");
+const out = path.join(outDir, "demo.mp4");
+const font = "C\\:/Windows/Fonts/arial.ttf";
+
+fs.mkdirSync(outDir, { recursive: true });
+
+const draw = [
+ `drawtext=fontfile=${font}:text='Paper Ingestion Quality Gate':x=64:y=56:fontsize=42:fontcolor=black`,
+ `drawtext=fontfile=${font}:text='AI summarizer intake audit before key findings are generated':x=64:y=122:fontsize=25:fontcolor=0x34413b`,
+ `drawtext=fontfile=${font}:text='Synthetic intake packets analyzed 4':x=64:y=214:fontsize=34:fontcolor=black`,
+ `drawtext=fontfile=${font}:text='HOLD 2 REVIEW 1 SUMMARIZE 1':x=64:y=274:fontsize=40:fontcolor=0x245fd6`,
+ `drawtext=fontfile=${font}:text='Detects page drops OCR noise missing sections and extraction gaps':x=64:y=366:fontsize=26:fontcolor=0x34413b`,
+ `drawtext=fontfile=${font}:text='Blocks AI key findings until source intake quality is clean':x=64:y=428:fontsize=26:fontcolor=0x34413b`,
+ `drawtext=fontfile=${font}:text='Synthetic generated slate only no desktop capture or private data':x=64:y=492:fontsize=26:fontcolor=0x34413b`
+].join(",");
+
+if (!fs.existsSync(ffmpeg)) {
+ throw new Error(`ffmpeg binary not found at ${ffmpeg}`);
+}
+
+const result = spawnSync(ffmpeg, [
+ "-y",
+ "-f",
+ "lavfi",
+ "-i",
+ "color=c=0xf9faf8:s=1280x720:d=8:r=30",
+ "-vf",
+ draw,
+ "-c:v",
+ "libx264",
+ "-pix_fmt",
+ "yuv420p",
+ "-movflags",
+ "+faststart",
+ out
+], { encoding: "utf8" });
+
+if (result.status !== 0) {
+ process.stderr.write(result.stderr || result.stdout);
+ process.exit(result.status || 1);
+}
+
+console.log(JSON.stringify({ out, bytes: fs.statSync(out).size }, null, 2));
diff --git a/paper-ingestion-quality-gate/src/index.js b/paper-ingestion-quality-gate/src/index.js
new file mode 100644
index 00000000..33d900ad
--- /dev/null
+++ b/paper-ingestion-quality-gate/src/index.js
@@ -0,0 +1,259 @@
+"use strict";
+
+const REQUIRED_SECTIONS = ["abstract", "methods", "results", "discussion"];
+const REQUIRED_EXTRACTION_KEYS = [
+ "textLayer",
+ "sectionMap",
+ "tableMap",
+ "figureCaptionMap",
+ "referenceMap",
+ "extractionDigest"
+];
+
+function list(value) {
+ return Array.isArray(value) ? value : [];
+}
+
+function normalizeText(value) {
+ return String(value || "").trim().toLowerCase();
+}
+
+function finding(code, severity, message, evidence, remediation) {
+ return { code, severity, message, evidence, remediation };
+}
+
+function normalizePacket(packet) {
+ return {
+ id: packet.id,
+ title: packet.title || "Untitled AI summarizer intake packet",
+ source: packet.source || {},
+ extraction: packet.extraction || {},
+ expected: packet.expected || {},
+ summarizer: packet.summarizer || {},
+ provenance: packet.provenance || {}
+ };
+}
+
+function percent(value) {
+ const number = Number(value);
+ return Number.isFinite(number) ? number : 0;
+}
+
+function missingRequiredSections(packet) {
+ const present = new Set(list(packet.extraction.sectionsPresent).map(normalizeText));
+ return REQUIRED_SECTIONS.filter((section) => !present.has(section));
+}
+
+function missingProvenance(packet) {
+ return REQUIRED_EXTRACTION_KEYS.filter((key) => packet.provenance[key] !== true);
+}
+
+function pageDropCount(packet) {
+ return Math.max(0, Number(packet.source.pageCount || 0) - Number(packet.extraction.pagesExtracted || 0));
+}
+
+function extractionCompleteness(packet) {
+ const sourcePages = Math.max(1, Number(packet.source.pageCount || 0));
+ const pages = Number(packet.extraction.pagesExtracted || 0) / sourcePages;
+ const text = percent(packet.extraction.textCoverage);
+ const tablesExpected = Math.max(0, Number(packet.expected.tables || 0));
+ const tablesExtracted = Number(packet.extraction.tablesExtracted || 0);
+ const tables = tablesExpected === 0 ? 1 : tablesExtracted / tablesExpected;
+ const referencesExpected = Math.max(0, Number(packet.expected.references || 0));
+ const referencesParsed = Number(packet.extraction.referencesParsed || 0);
+ const references = referencesExpected === 0 ? 1 : referencesParsed / referencesExpected;
+ return Math.max(0, Math.min(1, (pages + text + Math.min(1, tables) + Math.min(1, references)) / 4));
+}
+
+function hasSevereEncodingNoise(packet) {
+ return percent(packet.extraction.unrecognizedGlyphRate) > 0.04 || percent(packet.extraction.ocrConfidence) < 0.72;
+}
+
+function analyzePacket(input) {
+ const packet = normalizePacket(input);
+ const findings = [];
+ const missingSections = missingRequiredSections(packet);
+ const droppedPages = pageDropCount(packet);
+ const completeness = extractionCompleteness(packet);
+ const missingProv = missingProvenance(packet);
+
+ if (droppedPages > 0) {
+ findings.push(finding(
+ "PDF_PAGE_DROP_DETECTED",
+ droppedPages >= 2 ? "critical" : "major",
+ "The source paper has pages that were not extracted.",
+ {
+ pageCount: Number(packet.source.pageCount || 0),
+ pagesExtracted: Number(packet.extraction.pagesExtracted || 0),
+ droppedPages
+ },
+ "Block summarization until the paper is reprocessed and every page has extraction evidence."
+ ));
+ }
+
+ if (missingSections.length > 0) {
+ findings.push(finding(
+ "REQUIRED_SECTION_MISSING",
+ missingSections.includes("methods") || missingSections.includes("results") ? "critical" : "major",
+ "Required scientific sections are missing from the extracted text map.",
+ { missingSections },
+ "Re-run section segmentation or require manual section confirmation before summary generation."
+ ));
+ }
+
+ if (hasSevereEncodingNoise(packet)) {
+ findings.push(finding(
+ "OCR_OR_ENCODING_QUALITY_LOW",
+ "critical",
+ "OCR confidence or glyph quality is too poor for a reliable AI-generated summary.",
+ {
+ ocrConfidence: percent(packet.extraction.ocrConfidence),
+ unrecognizedGlyphRate: percent(packet.extraction.unrecognizedGlyphRate)
+ },
+ "Use a higher-quality text layer or route to manual intake review before summarization."
+ ));
+ }
+
+ const expectedTables = Number(packet.expected.tables || 0);
+ const extractedTables = Number(packet.extraction.tablesExtracted || 0);
+ if (expectedTables > extractedTables) {
+ findings.push(finding(
+ "TABLE_EXTRACTION_INCOMPLETE",
+ expectedTables - extractedTables >= 2 ? "critical" : "major",
+ "One or more expected tables were not extracted for summarizer grounding.",
+ { expectedTables, extractedTables },
+ "Hold key-finding and numeric-summary generation until table extraction is complete."
+ ));
+ }
+
+ const expectedFigures = Number(packet.expected.figures || 0);
+ const extractedCaptions = Number(packet.extraction.figureCaptionsExtracted || 0);
+ if (expectedFigures > extractedCaptions) {
+ findings.push(finding(
+ "FIGURE_CAPTION_MAP_INCOMPLETE",
+ "major",
+ "Figure captions are missing from the summarizer intake packet.",
+ { expectedFigures, extractedCaptions },
+ "Regenerate figure-caption anchors or suppress figure-derived claims from the summary."
+ ));
+ }
+
+ const expectedReferences = Number(packet.expected.references || 0);
+ const parsedReferences = Number(packet.extraction.referencesParsed || 0);
+ if (expectedReferences > 0 && parsedReferences / expectedReferences < 0.8) {
+ findings.push(finding(
+ "REFERENCE_PARSE_COVERAGE_LOW",
+ "major",
+ "Reference parsing coverage is too low for reliable related-work or implication summaries.",
+ { expectedReferences, parsedReferences },
+ "Suppress citation-based implication bullets until reference parsing is repaired."
+ ));
+ }
+
+ if (percent(packet.extraction.equationCoverage) < percent(packet.expected.minEquationCoverage || 0.9)) {
+ findings.push(finding(
+ "EQUATION_EXTRACTION_COVERAGE_LOW",
+ "major",
+ "Equation extraction coverage is below the configured summarizer threshold.",
+ {
+ equationCoverage: percent(packet.extraction.equationCoverage),
+ minEquationCoverage: percent(packet.expected.minEquationCoverage || 0.9)
+ },
+ "Warn reviewers and avoid mathematical-method summaries until equation extraction is confirmed."
+ ));
+ }
+
+ if (missingProv.length > 0) {
+ findings.push(finding(
+ "EXTRACTION_PROVENANCE_INCOMPLETE",
+ "major",
+ "The intake packet lacks required extraction provenance flags.",
+ { missingProvenance: missingProv },
+ "Attach text, section, table, figure, reference, and digest evidence before releasing AI summaries."
+ ));
+ }
+
+ if (completeness < 0.78) {
+ findings.push(finding(
+ "INTAKE_COMPLETENESS_BELOW_THRESHOLD",
+ "critical",
+ "Overall source extraction completeness is below the AI summarizer safety threshold.",
+ { completeness: Number(completeness.toFixed(3)) },
+ "Hold summarization and regenerate the intake packet from the source document."
+ ));
+ }
+
+ if (
+ findings.length > 0 &&
+ packet.summarizer.generateKeyFindings === true &&
+ packet.summarizer.requireCleanIntake !== true
+ ) {
+ findings.push(finding(
+ "SUMMARIZER_NOT_GATED_ON_CLEAN_INTAKE",
+ "critical",
+ "AI key-finding generation is enabled while source-ingestion quality findings are unresolved.",
+ {
+ generateKeyFindings: packet.summarizer.generateKeyFindings,
+ requireCleanIntake: packet.summarizer.requireCleanIntake || false
+ },
+ "Require clean intake status before generating key findings, implications, or next steps."
+ ));
+ }
+
+ const decision = decisionFor(findings);
+ return {
+ id: packet.id,
+ title: packet.title,
+ decision,
+ completeness: Number(completeness.toFixed(3)),
+ findings,
+ recommendedActions: buildRecommendedActions(decision, findings)
+ };
+}
+
+function decisionFor(findings) {
+ if (findings.some((item) => item.severity === "critical")) return "HOLD";
+ if (findings.some((item) => item.severity === "major")) return "REVIEW";
+ return "SUMMARIZE";
+}
+
+function buildRecommendedActions(decision, findings) {
+ if (decision === "SUMMARIZE") {
+ return ["Allow AI summarization with normal extraction provenance attached."];
+ }
+
+ const actions = [
+ "Attach the intake findings to the AI summarizer queue.",
+ "Regenerate extraction artifacts before key findings or implications are generated.",
+ "Show reviewers which source sections, tables, figures, or references are unsafe."
+ ];
+
+ if (findings.some((item) => item.code === "SUMMARIZER_NOT_GATED_ON_CLEAN_INTAKE")) {
+ actions.unshift("Disable key-finding generation until intake quality is clean.");
+ }
+
+ return actions;
+}
+
+function analyzeIngestionPackets(packets) {
+ const results = list(packets).map(analyzePacket);
+ const counts = results.reduce((acc, item) => {
+ acc[item.decision] = (acc[item.decision] || 0) + 1;
+ return acc;
+ }, { SUMMARIZE: 0, REVIEW: 0, HOLD: 0 });
+
+ return {
+ generatedAt: new Date().toISOString(),
+ totalPackets: results.length,
+ counts,
+ results
+ };
+}
+
+module.exports = {
+ analyzePacket,
+ analyzeIngestionPackets,
+ extractionCompleteness,
+ missingRequiredSections,
+ pageDropCount
+};
diff --git a/paper-ingestion-quality-gate/test/paper-ingestion-quality-gate.test.js b/paper-ingestion-quality-gate/test/paper-ingestion-quality-gate.test.js
new file mode 100644
index 00000000..45f05db2
--- /dev/null
+++ b/paper-ingestion-quality-gate/test/paper-ingestion-quality-gate.test.js
@@ -0,0 +1,57 @@
+"use strict";
+
+const assert = require("node:assert/strict");
+const test = require("node:test");
+const { analyzePacket, analyzeIngestionPackets, extractionCompleteness, missingRequiredSections, pageDropCount } = require("../src");
+const samples = require("../data/sample_ingestion_packets.json");
+
+test("detects dropped pages", () => {
+ assert.equal(pageDropCount({ source: { pageCount: 10 }, extraction: { pagesExtracted: 9 } }), 1);
+ assert.equal(pageDropCount({ source: { pageCount: 10 }, extraction: { pagesExtracted: 11 } }), 0);
+});
+
+test("detects missing required scientific sections", () => {
+ const missing = missingRequiredSections({ extraction: { sectionsPresent: ["abstract", "methods"] } });
+ assert.deepEqual(missing, ["results", "discussion"]);
+});
+
+test("computes extraction completeness", () => {
+ const score = extractionCompleteness(samples.find((item) => item.id === "ingest-summarize-001"));
+ assert.ok(score > 0.95);
+});
+
+test("allows clean paper intake to summarize", () => {
+ const result = analyzePacket(samples.find((item) => item.id === "ingest-summarize-001"));
+ assert.equal(result.decision, "SUMMARIZE");
+ assert.equal(result.findings.length, 0);
+});
+
+test("holds low-quality OCR with dropped pages and missing methods", () => {
+ const result = analyzePacket(samples.find((item) => item.id === "ingest-hold-002"));
+ assert.equal(result.decision, "HOLD");
+ assert.ok(result.findings.some((item) => item.code === "PDF_PAGE_DROP_DETECTED"));
+ assert.ok(result.findings.some((item) => item.code === "REQUIRED_SECTION_MISSING"));
+ assert.ok(result.findings.some((item) => item.code === "OCR_OR_ENCODING_QUALITY_LOW"));
+ assert.ok(result.findings.some((item) => item.code === "SUMMARIZER_NOT_GATED_ON_CLEAN_INTAKE"));
+});
+
+test("routes incomplete tables and captions for review", () => {
+ const result = analyzePacket(samples.find((item) => item.id === "ingest-review-003"));
+ assert.equal(result.decision, "REVIEW");
+ assert.ok(result.findings.some((item) => item.code === "TABLE_EXTRACTION_INCOMPLETE"));
+ assert.ok(result.findings.some((item) => item.code === "FIGURE_CAPTION_MAP_INCOMPLETE"));
+});
+
+test("holds unsafe key-finding generation when reference and equation extraction fail", () => {
+ const result = analyzePacket(samples.find((item) => item.id === "ingest-hold-004"));
+ assert.equal(result.decision, "HOLD");
+ assert.ok(result.findings.some((item) => item.code === "REFERENCE_PARSE_COVERAGE_LOW"));
+ assert.ok(result.findings.some((item) => item.code === "EQUATION_EXTRACTION_COVERAGE_LOW"));
+ assert.ok(result.findings.some((item) => item.code === "SUMMARIZER_NOT_GATED_ON_CLEAN_INTAKE"));
+});
+
+test("aggregates paper ingestion decisions", () => {
+ const report = analyzeIngestionPackets(samples);
+ assert.equal(report.totalPackets, 4);
+ assert.deepEqual(report.counts, { SUMMARIZE: 1, REVIEW: 1, HOLD: 2 });
+});