From ed313159c35644cc200db5e909360e40fe7fa633 Mon Sep 17 00:00:00 2001 From: aaron Date: Mon, 17 Aug 2026 14:02:15 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(schema):=20the=20library-paper=20kind?= =?UTF-8?q?=20=E2=80=94=20the=20paper=20record=20contract=20(#405=20slice?= =?UTF-8?q?=201a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Formalized from the two production notes (TEMPO, mitten-qLDPC): identity (title+authors, arxiv xor doi — dotted form, version suffixes normalize at the fold), lifecycle (staged→distilled; absent = distilled, the historical notes), provenance (source pipeline enum), scoping (relevance/systems/tags). Strict shape; registered in SCHEMAS only — notes carry no top-level schema_version (the ledger-record pattern). --- .../schema/schemas/library-paper.schema.json | 41 ++++++++++++ packages/schema/src/index.ts | 15 ++++- .../test/fixtures/invalid/library-paper.toml | 4 ++ .../test/fixtures/valid/library-paper.toml | 10 +++ packages/schema/test/library-paper.test.ts | 63 +++++++++++++++++++ packages/schema/test/validate.test.ts | 3 + 6 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 packages/schema/schemas/library-paper.schema.json create mode 100644 packages/schema/test/fixtures/invalid/library-paper.toml create mode 100644 packages/schema/test/fixtures/valid/library-paper.toml create mode 100644 packages/schema/test/library-paper.test.ts diff --git a/packages/schema/schemas/library-paper.schema.json b/packages/schema/schemas/library-paper.schema.json new file mode 100644 index 0000000..5b7ae6b --- /dev/null +++ b/packages/schema/schemas/library-paper.schema.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://amico.harmoniqs.co/schema/library-paper/v1", + "title": "amico library-paper record", + "description": "The paper record (#405, literature-plane slice 1): the frontmatter contract for a vault reading note, formalized from the two production notes (TEMPO, mitten-qLDPC). The record IS the note's frontmatter — PDF (evidence) and note (projection) stay distinct artifacts; this contract binds identity (title+authors, arxiv xor doi at minimum), lifecycle (staged→distilled; absent = distilled, the historical notes), provenance (which pipeline ingested it), and scoping (relevance/systems/tags — study-relative fields). Bots stage; humans (or distill sessions) promote. Deliberately NOT filename-kinded — notes are markdown frontmatter, validated as parsed objects.", + "type": "object", + "additionalProperties": false, + "required": ["type", "title", "authors"], + "properties": { + "type": { "const": "paper" }, + "title": { "type": "string", "minLength": 1 }, + "authors": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } }, + "arxiv": { + "type": "string", + "pattern": "^\\d{4}\\.\\d{4,5}$", + "description": "canonical dotted id, NO version suffix — the fold normalizes vN before compare" + }, + "doi": { "type": "string", "pattern": "^10\\.\\d{4,9}/\\S+$" }, + "status": { + "enum": ["staged", "distilled"], + "description": "lifecycle: bots stage, humans promote. Absent = distilled (the historical notes predate the gate)" + }, + "date_read": { "type": "string", "minLength": 1 }, + "date": { "type": "string", "minLength": 1 }, + "relevance": { "enum": ["high", "medium", "low"] }, + "systems": { "type": "array", "items": { "type": "string", "minLength": 1 } }, + "tags": { "type": "array", "items": { "type": "string", "minLength": 1 } }, + "visibility": { "type": "string", "minLength": 1 }, + "route_intent": { "type": "string", "minLength": 1 }, + "session_id": { "type": ["string", "null"] }, + "source": { + "enum": ["dream-distill", "papers-channel", "arxiv-subscription", "upload", "manual"], + "description": "the ingestion pipeline that produced the note" + }, + "source_session": { "type": ["string", "null"] } + }, + "anyOf": [ + { "required": ["arxiv"] }, + { "required": ["doi"] } + ] +} diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index c2a76c8..04e384c 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -43,6 +43,10 @@ import packSchema from "../schemas/pack.schema.json" with { type: "json" }; // root, the ordered vault mount stack, derived-root overrides. NOT filename- // kinded (config.toml is too generic a name to claim). import amicodeConfigSchema from "../schemas/amicode-config.schema.json" with { type: "json" }; +// The paper record (#405, literature plane): the vault reading-note frontmatter +// contract — identity, lifecycle, provenance, scoping. NOT filename-kinded +// (notes are markdown frontmatter, validated as parsed objects). +import libraryPaperSchema from "../schemas/library-paper.schema.json" with { type: "json" }; // Cross-language ProblemSpec hashing (Plan 2 Task 5) — re-exported at the package // root so cross-package consumers (e.g. the extension's ledger_client.ts, Plan 3 @@ -98,6 +102,11 @@ const SCHEMAS = { plan: planSchema, pack: packSchema, "amicode-config": amicodeConfigSchema, +// Registered in SCHEMAS ONLY (not SUPPORTED_VERSIONS_BY_KIND): library-paper +// is the vault NOTE frontmatter contract (#405) — notes carry no top-level +// schema_version (real-data parity with the two production notes), same +// pattern as problemspec/ledger-record. + "library-paper": libraryPaperSchema, } as const; export type SchemaKind = keyof typeof SCHEMAS; @@ -110,8 +119,10 @@ export const SCHEMA_KINDS = Object.keys(SCHEMAS) as SchemaKind[]; * solvespec v4 also adds problem_spec, the typed ProblemSpec runner target); * the rest remain v1 and bump independently. `finished` (no schema_version), * `problemspec`, and `ledger-record` (both top-level `oneOf` shapes with no - * top-level properties.schema_version) are excluded from this string-version map. */ -export const SUPPORTED_VERSIONS_BY_KIND: Record, string[]> = + * top-level properties.schema_version), and `library-paper` (vault note + * frontmatter — real notes carry no schema_version) are excluded from this + * string-version map. */ +export const SUPPORTED_VERSIONS_BY_KIND: Record, string[]> = Object.fromEntries( (["run", "result", "lab", "solvespec", "catalog-entry", "spec", "plan", "pack", "amicode-config"] as const).map((kind) => [ kind, diff --git a/packages/schema/test/fixtures/invalid/library-paper.toml b/packages/schema/test/fixtures/invalid/library-paper.toml new file mode 100644 index 0000000..0781241 --- /dev/null +++ b/packages/schema/test/fixtures/invalid/library-paper.toml @@ -0,0 +1,4 @@ +# INVALID: no identity key (neither arxiv nor doi). +type = "paper" +title = "A title" +authors = ["Someone"] diff --git a/packages/schema/test/fixtures/valid/library-paper.toml b/packages/schema/test/fixtures/valid/library-paper.toml new file mode 100644 index 0000000..30f55cc --- /dev/null +++ b/packages/schema/test/fixtures/valid/library-paper.toml @@ -0,0 +1,10 @@ +# The TEMPO frontmatter as a TOML fixture (real-data parity). +type = "paper" +title = "Efficient non-Markovian quantum dynamics using TEMPO" +authors = ["Strathearn", "Kirton", "Kilda", "Keeling", "Lovett"] +arxiv = "1711.09641" +date_read = "2026-07-03" +relevance = "high" +systems = ["transmon", "bosonic"] +tags = ["paper", "tempo"] +source = "dream-distill" diff --git a/packages/schema/test/library-paper.test.ts b/packages/schema/test/library-paper.test.ts new file mode 100644 index 0000000..6cdb921 --- /dev/null +++ b/packages/schema/test/library-paper.test.ts @@ -0,0 +1,63 @@ +// The `library-paper` kind (#405): the paper record — the note frontmatter +// contract, formalized from the two real vault notes (TEMPO, mitten-qLDPC — +// the source of truth, proven in production). Identity: title+authors and at +// least one of arxiv/doi; lifecycle: staged→distilled (absent = distilled, +// the historical notes); provenance and scoping strictly typed when present. +import { describe, it, expect } from "vitest"; +import { validate, SUPPORTED_VERSIONS_BY_KIND } from "../src/index.js"; + +const rec = (over: Record = {}) => ({ + type: "paper", + title: "Efficient non-Markovian quantum dynamics using TEMPO", + authors: ["Strathearn", "Kirton", "Kilda", "Keeling", "Lovett"], + arxiv: "1711.09641", + date_read: "2026-07-03", + relevance: "high", + systems: ["transmon", "bosonic"], + tags: ["paper", "tempo", "process-tensor"], + ...over, +}); +const drop = (o: Record, k: string) => { + const c = { ...o }; + delete c[k]; + return c; +}; + +describe("the library-paper kind", () => { + it("accepts the TEMPO note's frontmatter, unchanged (real-data parity)", () => { + expect(validate(rec(), "library-paper")).toMatchObject({ ok: true }); + }); + it("accepts the mitten-qLDPC shape — doi instead of arxiv, route_intent tolerated", () => { + expect(validate(rec({ arxiv: undefined, doi: "10.48550/arXiv.2607.28795", route_intent: "team" }), "library-paper")).toMatchObject({ ok: true }); + }); + it("requires BOTH title and authors", () => { + expect(validate(drop(rec(), "title"), "library-paper").ok).toBe(false); + expect(validate(drop(rec(), "authors"), "library-paper").ok).toBe(false); + }); + it("requires an identity key — arxiv or doi; bare records refuse", () => { + expect(validate(drop(rec(), "arxiv"), "library-paper").ok).toBe(false); + expect(validate(drop({ ...rec(), doi: "10.1234/x" }, "arxiv"), "library-paper").ok).toBe(true); + }); + it("status is the lifecycle enum; ABSENT means distilled (the historical notes)", () => { + expect(validate(rec({ status: "staged" }), "library-paper")).toMatchObject({ ok: true }); + expect(validate(rec({ status: "published" }), "library-paper").ok).toBe(false); + }); + it("relevance is an enum when present", () => { + expect(validate(rec({ relevance: "meh" }), "library-paper").ok).toBe(false); + }); + it("arxiv ids are the canonical dotted form (version suffixes normalize at the fold, not the schema)", () => { + expect(validate(rec({ arxiv: "1711.09641v2" }), "library-paper").ok).toBe(false); + expect(validate(rec({ arxiv: "not-an-id" }), "library-paper").ok).toBe(false); + }); + it("provenance: source is an enum of the known pipelines; session_id a string", () => { + expect(validate(rec({ source: "dream-distill", source_session: "71907fc9" }), "library-paper")).toMatchObject({ ok: true }); + expect(validate(rec({ source: "vibes" }), "library-paper").ok).toBe(false); + }); + it("type must be paper; unknown keys refuse (strict — we own both sides)", () => { + expect(validate(rec({ type: "spec" }), "library-paper").ok).toBe(false); + expect(validate(rec({ banana: 1 }), "library-paper").ok).toBe(false); + }); + it("registered WITHOUT a version map entry — notes carry no schema_version (the ledger-record pattern)", () => { + expect((SUPPORTED_VERSIONS_BY_KIND as Record)["library-paper"]).toBeUndefined(); + }); +}); diff --git a/packages/schema/test/validate.test.ts b/packages/schema/test/validate.test.ts index d84b263..91ca683 100644 --- a/packages/schema/test/validate.test.ts +++ b/packages/schema/test/validate.test.ts @@ -39,6 +39,9 @@ describe("schema set + exports", () => { "pack", // the studio manifest — one file binding the installation (#402) "amicode-config", + // the paper record — vault note frontmatter (#405); joins the + // SCHEMAS-only set (no top-level schema_version) + "library-paper", ]), ); }); From 7d62b046825712a1fc24e966780a472b8cbcf17c Mon Sep 17 00:00:00 2001 From: aaron Date: Mon, 17 Aug 2026 14:24:14 -0400 Subject: [PATCH 2/3] feat(amico-run): the unified literature corpus fold + amico papers (#405 slices 1b-1c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit foldCorpus: one read-only view over every mount's papers/ notes + the library PDF store — validated per the library-paper contract, deduped by normalized identity (REPORTED, never merged — merging is a human promote act), PDFs content-addressed and identity-joined by filename, orphans surfaced both ways. MERGED-INTO- tombstones skipped. amico papers list: filters (--status/--tag/--platform/--q — S31 vocabulary) + human table + JSON with counts and drift. The contract absorbed PRODUCTION vocabulary discovered by the first live fold: prose relevance (team-vault pipeline), legacy arXiv archive ids (cond-mat/…), null-identity tolerance (arxiv XOR doi still required — branch-level type constraints, presence-based required is defeatable), journal/published/species/read_depth/hardware/promoted_from/merged_into/ date_full_text_read/triaged_from, source: doc-ingest. Live corpus (this machine): 91 valid notes across both vaults, 16 identity-unresolved (genuine backfill), 2 duplicates reported. --- packages/amico-run/src/papers.ts | 205 ++++++++++++++++++ packages/amico-run/src/papers_list.ts | 74 +++++++ packages/amico-run/src/papers_render.ts | 44 ++++ packages/amico-run/src/papers_verb.ts | 22 ++ packages/amico-run/src/verbs.ts | 14 +- packages/amico-run/test/papers.test.ts | 105 +++++++++ packages/amico-run/test/papers_verb.test.ts | 83 +++++++ .../schema/schemas/library-paper.schema.json | 33 ++- .../test/fixtures/valid/library-paper.toml | 3 + packages/schema/test/library-paper.test.ts | 21 +- 10 files changed, 589 insertions(+), 15 deletions(-) create mode 100644 packages/amico-run/src/papers.ts create mode 100644 packages/amico-run/src/papers_list.ts create mode 100644 packages/amico-run/src/papers_render.ts create mode 100644 packages/amico-run/src/papers_verb.ts create mode 100644 packages/amico-run/test/papers.test.ts create mode 100644 packages/amico-run/test/papers_verb.test.ts diff --git a/packages/amico-run/src/papers.ts b/packages/amico-run/src/papers.ts new file mode 100644 index 0000000..153fb39 --- /dev/null +++ b/packages/amico-run/src/papers.ts @@ -0,0 +1,205 @@ +// papers.ts — the unified literature corpus fold (#405): one read-only view +// over vault papers/ notes + the library PDF store. Collected, unified, +// deduped by identity (REPORTED, never merged — merging is a human promote +// act), content-addressed join (sha256 on read; filenames stay human), +// orphans surfaced both directions. The fold never writes. +// +// Zero-dep note: vault note frontmatter is a YAML subset (flat scalars, +// quoted strings, inline lists, null) — a ~50-line reader beats a dependency +// (the runstatus.ts TOML-subset precedent, invariant 7). If a note needs +// richer YAML, widen the subset with a test, not a library. +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { validate, studioPathsOrLegacy } from "@amicode/schema"; +import type { StudioPaths } from "@amicode/schema"; + +/** Parse a note's --- frontmatter fence (flat YAML subset). Throws on a + * missing fence; unknown value shapes land as strings. */ +export function parseFrontmatter(text: string): Record { + const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); + if (!m) throw new Error("missing --- frontmatter block"); + const out: Record = {}; + for (const line of m[1]!.split(/\r?\n/)) { + if (!line.trim() || line.trim().startsWith("#")) continue; + const kv = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); + if (!kv) throw new Error(`unparseable frontmatter line: ${line}`); + const [, key, raw] = kv; + out[key] = parseValue(raw!.trim()); + } + return out; +} + +function parseValue(raw: string): unknown { + if (raw === "" || raw === "null" || raw === "~") return null; + if (raw.startsWith("[") && raw.endsWith("]")) { + return raw + .slice(1, -1) + .split(",") + .map((s) => s.trim()) + .filter((s) => s !== "") + .map((s) => (s.startsWith('"') && s.endsWith('"') ? s.slice(1, -1) : s.startsWith("'") && s.endsWith("'") ? s.slice(1, -1) : s)); + } + if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) return raw.slice(1, -1); + return raw; +} + +export interface PaperRecord { + file: string; + title: string; + authors: string[]; + arxiv?: string; + doi?: string; + status: "staged" | "distilled"; // absent frontmatter = distilled (historical) + tags: string[]; + systems: string[]; + relevance?: string; + frontmatter: Record; + pdf?: { file: string; sha256: string }; +} + +export interface CorpusReport { + papers: PaperRecord[]; + /** same identity seen in multiple notes — reported, never merged */ + duplicates: { key: string; files: string[] }[]; + /** notes whose frontmatter fails the library-paper contract */ + invalid: { file: string; errors: string[] }[]; + /** library PDFs no record claims */ + orphanPdfs: { file: string; sha256: string }[]; + /** records with no PDF in the library (the acquisition to-do list) */ + recordsWithoutPdf: { file: string; title: string; arxiv?: string; doi?: string }[]; +} + +const sha256 = (buf: Buffer) => createHash("sha256").update(buf).digest("hex"); + +/** arxiv "1711.09641v2" → "1711.09641" — version suffixes normalize at the + * fold (the schema stays strict on the canonical form). */ +function normalizeArxiv(id: string): string { + return id.replace(/v\d+$/, ""); +} + +/** Fold the corpus: every //papers/*.md note, validated, + * unified, joined against the library's PDFs. Read-only; absence degrades + * to empty everywhere. */ +export function foldCorpus(vaultRoots: string[], libraryRoot: string): CorpusReport { + const report: CorpusReport = { papers: [], duplicates: [], invalid: [], orphanPdfs: [], recordsWithoutPdf: [] }; + + // 1. collect + validate notes across every mount of every root + const byIdentity = new Map(); + for (const root of vaultRoots) { + if (!existsSync(root)) continue; + let mounts: string[] = []; + try { + mounts = readdirSync(root, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => join(root, d.name)); + } catch { + continue; + } + for (const mount of mounts) { + const papersDir = join(mount, "papers"); + if (!existsSync(papersDir)) continue; + let files: string[] = []; + try { + files = readdirSync(papersDir).filter((f) => f.endsWith(".md")); + } catch { + continue; + } + for (const f of files) { + if (f.startsWith("MERGED-INTO-")) continue; // merge tombstones are retired records + const file = join(papersDir, f); + let fm: Record; + try { + fm = parseFrontmatter(readFileSync(file, "utf8")); + } catch (e) { + report.invalid.push({ file, errors: [String(e)] }); + continue; + } + const v = validate(fm, "library-paper"); + if (!v.ok) { + report.invalid.push({ file, errors: v.errors }); + continue; + } + const rec: PaperRecord = { + file, + title: fm.title as string, + authors: fm.authors as string[], + arxiv: fm.arxiv ? normalizeArxiv(fm.arxiv as string) : undefined, + doi: fm.doi as string | undefined, + status: (fm.status as "staged" | "distilled") ?? "distilled", + tags: (fm.tags as string[]) ?? [], + systems: (fm.systems as string[]) ?? [], + relevance: fm.relevance as string | undefined, + frontmatter: fm, + }; + report.papers.push(rec); + const key = rec.arxiv ? `arxiv:${rec.arxiv}` : rec.doi ? `doi:${rec.doi}` : null; + if (key) { + const bucket = byIdentity.get(key) ?? []; + bucket.push(rec); + byIdentity.set(key, bucket); + } + } + } + } + for (const [key, bucket] of byIdentity) + if (bucket.length > 1) report.duplicates.push({ key, files: bucket.map((b) => b.file) }); + + // 2. library PDFs: content-addressed, identity-joined by filename + const pdfs: { file: string; sha256: string }[] = []; + if (existsSync(libraryRoot)) { + try { + for (const f of readdirSync(libraryRoot)) { + const file = join(libraryRoot, f); + try { + if (!statSync(file).isFile()) continue; + pdfs.push({ file, sha256: sha256(readFileSync(file)) }); + } catch { + /* unreadable file — skip */ + } + } + } catch { + /* unreadable root — no pdfs */ + } + } + const claimed = new Set(); + for (const p of report.papers) { + const match = p.arxiv + ? pdfs.find((x) => basenameContainsIdentity(x.file, p.arxiv!)) + : p.doi + ? pdfs.find((x) => x.file.includes(sanitizeDoi(p.doi!))) + : undefined; + if (match) { + p.pdf = { file: match.file, sha256: match.sha256 }; + claimed.add(match.file); + } else { + report.recordsWithoutPdf.push({ file: p.file, title: p.title, arxiv: p.arxiv, doi: p.doi }); + } + } + report.orphanPdfs = pdfs.filter((x) => !claimed.has(x.file)); + return report; +} + +function basenameContainsIdentity(file: string, arxivId: string): boolean { + const base = file.replace(/^.*[\\/]/, ""); + // boundary-safe: "1711.09641.pdf", "1711.09641v2.pdf", "arXiv-1711.09641(1).pdf" + const re = new RegExp(`(^|[^0-9])${escapeRe(arxivId)}(v\\d+)?([^0-9]|$)`); + return re.test(base); +} + +function sanitizeDoi(doi: string): string { + return escapeRe(doi.replace(/^https?:\/\/(dx\.)?doi\.org\//, "")); +} + +function escapeRe(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** The corpus over THIS machine's studio ladder (manifest → legacy). The + * library root stays the legacy ~/.amico/library until the manifest grows a + * library field (v2 — the installation spec keeps PDFs as library state). */ +export function foldStudioCorpus(paths?: StudioPaths): CorpusReport { + const p = paths ?? studioPathsOrLegacy(); + return foldCorpus([p.vaultsRoot], join(homedir(), ".amico", "library")); +} diff --git a/packages/amico-run/src/papers_list.ts b/packages/amico-run/src/papers_list.ts new file mode 100644 index 0000000..6bd9bb6 --- /dev/null +++ b/packages/amico-run/src/papers_list.ts @@ -0,0 +1,74 @@ +// papers_list.ts — the `amico papers list` body: fold + filter + render. +// Pure rendering decisions live in papers_render.ts; the fold is papers.ts. +import { papersFilters, renderCorpusTable } from "./papers_render.js"; +import { foldCorpus } from "./papers.js"; +import { studioPathsOrLegacy } from "@amicode/schema"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { VerbResult } from "./verbs.js"; +import type { CorpusReport, PaperRecord } from "./papers.js"; + +function flagValue(argv: string[], name: string): string | undefined { + const i = argv.indexOf(name); + return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined; +} + +function corpusCounts(corpus: CorpusReport, shown: number): Record { + return { + papers: shown, + total: corpus.papers.length, + duplicates: corpus.duplicates.length, + invalid: corpus.invalid.length, + orphan_pdfs: corpus.orphanPdfs.length, + records_without_pdf: corpus.recordsWithoutPdf.length, + }; +} + +export function papersList(argv: string[]): VerbResult { + const asJson = argv.includes("--json"); + const filters = papersFilters({ + status: flagValue(argv, "--status"), + tag: flagValue(argv, "--tag"), + platform: flagValue(argv, "--platform"), + q: flagValue(argv, "--q"), + }); + + // Hermetic escapes win; production roots ride the studio ladder. + const vaults = process.env.AMICO_PAPERS_VAULTS ?? studioPathsOrLegacy().vaultsRoot; + const library = process.env.AMICO_PAPERS_LIBRARY ?? join(homedir(), ".amico", "library"); + const corpus = foldCorpus([vaults], library); + const papers: PaperRecord[] = corpus.papers.filter(filters); + + const payload = papers.map((p) => ({ + title: p.title, + authors: p.authors, + arxiv: p.arxiv ?? null, + doi: p.doi ?? null, + status: p.status, + relevance: p.relevance ?? null, + systems: p.systems, + tags: p.tags, + file: p.file, + pdf: p.pdf ? { file: p.pdf.file, sha256: p.pdf.sha256 } : null, + })); + + if (asJson) { + return { + json: { + ok: true, + papers: payload, + counts: corpusCounts(corpus, papers.length), + duplicates: corpus.duplicates, + invalid: corpus.invalid, + orphan_pdfs: corpus.orphanPdfs, + records_without_pdf: corpus.recordsWithoutPdf, + }, + code: 0, + }; + } + + return { + json: { ok: true, table: renderCorpusTable(papers, corpus), counts: corpusCounts(corpus, papers.length) }, + code: 0, + }; +} diff --git a/packages/amico-run/src/papers_render.ts b/packages/amico-run/src/papers_render.ts new file mode 100644 index 0000000..dafe904 --- /dev/null +++ b/packages/amico-run/src/papers_render.ts @@ -0,0 +1,44 @@ +// papers_render.ts — pure filter + table rendering for `amico papers list`. +// No I/O; trivially testable. +import type { CorpusReport, PaperRecord } from "./papers.js"; + +export interface FilterSpec { + status?: string; + tag?: string; + platform?: string; + q?: string; +} + +/** Build the predicate chain — each present flag ANDs. */ +export function papersFilters(spec: FilterSpec): (p: PaperRecord) => boolean { + const tests: ((p: PaperRecord) => boolean)[] = []; + if (spec.status) { + const want = spec.status as PaperRecord["status"]; + tests.push((p) => p.status === want); + } + if (spec.tag) tests.push((p) => p.tags.includes(spec.tag!)); + if (spec.platform) tests.push((p) => p.systems.includes(spec.platform!)); + if (spec.q) { + const needle = spec.q.toLowerCase(); + tests.push((p) => + [p.title, ...p.authors, p.arxiv ?? "", p.doi ?? "", ...p.tags].some((s) => s.toLowerCase().includes(needle)), + ); + } + return (p) => tests.every((t) => t(p)); +} + +/** The human table: title · identity · status · systems · pdf?. */ +export function renderCorpusTable(papers: PaperRecord[], corpus: CorpusReport): string { + const ident = (p: PaperRecord) => p.arxiv ? `arXiv:${p.arxiv}` : p.doi ? `doi:${p.doi}` : "?"; + const rows = papers.map((p) => [p.title.slice(0, 52), ident(p), p.status, p.systems.join(","), p.pdf ? "pdf" : "—"]); + const width = [56, 22, 8, 16, 3].map((w, i) => Math.max(w, ...rows.map((r) => r[i]!.length))); + const lines = [ + `${"title".padEnd(width[0]!)} ${"identity".padEnd(width[1]!)} ${"status".padEnd(width[2]!)} ${"systems".padEnd(width[3]!)} pdf`, + ...rows.map((r) => r.map((c, i) => c.padEnd(width[i]!)).join(" ")), + ]; + if (corpus.duplicates.length) lines.push(``, `duplicates: ${corpus.duplicates.map((d) => `${d.key} ×${d.files.length}`).join(", ")}`); + if (corpus.invalid.length) lines.push(`invalid notes: ${corpus.invalid.length} (amico papers list --json for files)`); + if (corpus.orphanPdfs.length) lines.push(`orphan pdfs: ${corpus.orphanPdfs.length}`); + if (corpus.recordsWithoutPdf.length) lines.push(`records without pdfs: ${corpus.recordsWithoutPdf.length} (the acquisition to-do list)`); + return lines.join("\n"); +} diff --git a/packages/amico-run/src/papers_verb.ts b/packages/amico-run/src/papers_verb.ts new file mode 100644 index 0000000..6060c18 --- /dev/null +++ b/packages/amico-run/src/papers_verb.ts @@ -0,0 +1,22 @@ +// `amico papers` — the unified literature corpus surface (#405): +// +// amico papers list [--status staged|distilled] [--tag ] [--platform ] [--q ] [--json] +// → the corpus fold rendered: a human table by default (title · identity +// · status · pdf?), JSON on --json. Counts + drift (duplicates, +// orphans both ways) ride along — collect, unify, usable, searchable. +// +// Read-only (the fold never writes). $AMICO_PAPERS_VAULTS / $AMICO_PAPERS_LIBRARY +// are the hermetic test escapes; production roots come from the studio ladder. +import { papersList } from "./papers_list.js"; +import type { VerbResult } from "./verbs.js"; + +export function papersVerb(argv: string[]): VerbResult { + const [sub, ...rest] = argv; + if (sub !== "list") { + return { + json: { ok: false, error: `papers: unknown subcommand '${sub ?? ""}' — usage: amico papers list [--status|--tag|--platform|--q] [--json]` }, + code: 64, + }; + } + return papersList(rest); +} diff --git a/packages/amico-run/src/verbs.ts b/packages/amico-run/src/verbs.ts index ab7431a..5f773c9 100644 --- a/packages/amico-run/src/verbs.ts +++ b/packages/amico-run/src/verbs.ts @@ -15,6 +15,7 @@ import { catalogVerb } from "./catalog_verb.js"; import { vaultVerb } from "./vault_verb.js"; +import { papersVerb } from "./papers_verb.js"; import { deviceVerb } from "./device_verb.js"; import { noteVerb } from "./note_verb.js"; import { ledgerVerb } from "./ledger_verb.js"; @@ -186,4 +187,15 @@ const handoff: Verb = { run: (args) => handoffVerb(args), }; -export const SPINE_VERBS: Verb[] = [catalog, vault, device, note, ledger, profile, fleet, spec, plan, handoff]; +// papers — the unified literature corpus (#405): one read-only view over +// vault paper notes + the library PDF store — collected, unified, deduped +// (reported, never merged), orphans surfaced both ways. Read-only. +const papers: Verb = { + name: "papers", + summary: "list the unified literature corpus (vault notes + library PDFs; filters + drift)", + generalizes: "the literature plane's collect/unify/search surface (spec-20260817-140000)", + slice: "literature plane (1)", + run: papersVerb, +}; + +export const SPINE_VERBS: Verb[] = [catalog, vault, device, note, ledger, profile, fleet, spec, plan, handoff, papers]; diff --git a/packages/amico-run/test/papers.test.ts b/packages/amico-run/test/papers.test.ts new file mode 100644 index 0000000..7c7ed12 --- /dev/null +++ b/packages/amico-run/test/papers.test.ts @@ -0,0 +1,105 @@ +// The unified literature corpus fold (#405): one read-only view over vault +// papers/ notes + the library PDF store — collected, unified, deduped by +// identity, content-addressed join, orphans surfaced. The fold NEVER writes +// (dedup reports; merging is a human promote act). +import { describe, it, expect, beforeEach } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { foldCorpus } from "../src/papers.js"; +let root: string; +let vaultA: string; +let vaultB: string; +let library: string; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "papers-fold-")); + vaultA = join(root, "vaults", "personal"); + vaultB = join(root, "vaults", "team"); + library = join(root, "library"); + mkdirSync(join(vaultA, "papers"), { recursive: true }); + mkdirSync(join(vaultB, "papers"), { recursive: true }); + mkdirSync(library, { recursive: true }); +}); +const cleanup = () => rmSync(root, { recursive: true, force: true }); + +const TEMPO = `--- +type: paper +title: "Efficient non-Markovian quantum dynamics using TEMPO" +authors: [Strathearn, Kirton, Kilda, Keeling, Lovett] +arxiv: "1711.09641" +date_read: 2026-07-03 +relevance: high +systems: [transmon, bosonic] +tags: [paper, tempo] +--- + +# TEMPO +Body text. +`; + +function note(dir: string, file: string, fm: string) { + writeFileSync(join(dir, "papers", file), `---\n${fm}\n---\n\n# t\n`); +} + +describe("foldCorpus", () => { + it("unifies notes across mounts, validates each, and reports invalid notes without dying", () => { + note(vaultA, "tempo.md", `type: paper\ntitle: "TEMPO"\nauthors: [S]\narxiv: "1711.09641"`); + note(vaultB, "bad.md", `type: paper\ntitle: "No identity"\nauthors: [X]`); + const r = foldCorpus([join(root, "vaults")], library); + expect(r.papers.map((p) => p.arxiv)).toEqual(["1711.09641"]); + expect(r.invalid).toHaveLength(1); + expect(r.invalid[0]!.file).toContain("bad.md"); + expect(r.invalid[0]!.errors.join()).toMatch(/arxiv|doi/); + cleanup(); + }); + + it("dedups by normalized identity — version suffixes fold to the same arxiv id, REPORTED not merged", () => { + note(vaultA, "a.md", `type: paper\ntitle: "T1"\nauthors: [A]\narxiv: "1711.09641"`); + note(vaultB, "b.md", `type: paper\ntitle: "T1 v2 reading"\nauthors: [A]\narxiv: "1711.09641v2"`); + const r = foldCorpus([join(root, "vaults")], library); + expect(r.papers).toHaveLength(2); // both notes exist + expect(r.duplicates).toHaveLength(1); + expect(r.duplicates[0]!.key).toBe("arxiv:1711.09641"); + expect(r.duplicates[0]!.files).toHaveLength(2); + cleanup(); + }); + + it("joins library PDFs by identity in the filename, content-addressed; orphans both ways", () => { + note(vaultA, "tempo.md", `type: paper\ntitle: "TEMPO"\nauthors: [S]\narxiv: "1711.09641"`); + const pdf1 = join(library, "1711.09641.pdf"); + writeFileSync(pdf1, "%PDF-fake-tempo"); + const pdf2 = join(library, "someone-shared-this.pdf"); + writeFileSync(pdf2, "%PDF-no-identity"); + const r = foldCorpus([join(root, "vaults")], library); + const tempo = r.papers.find((p) => p.arxiv === "1711.09641")!; + expect(tempo.pdf).toMatchObject({ file: pdf1 }); + expect(tempo.pdf!.sha256).toMatch(/^[a-f0-9]{64}$/); + expect(r.orphanPdfs.map((o) => o.file)).toEqual([pdf2]); + expect(r.recordsWithoutPdf.map((x) => x.title)).toEqual([]); // tempo HAS a pdf + cleanup(); + }); + + it("records without a matching PDF are surfaced (the acquisition to-do list)", () => { + note(vaultA, "tempo.md", `type: paper\ntitle: "TEMPO"\nauthors: [S]\narxiv: "1711.09641"`); + const r = foldCorpus([join(root, "vaults")], library); + expect(r.recordsWithoutPdf).toHaveLength(1); + cleanup(); + }); + + it("missing roots degrade to empty — the fold never throws for absence", () => { + const r = foldCorpus([join(root, "no-vaults")], join(root, "no-library")); + expect(r.papers).toEqual([]); + expect(r.duplicates).toEqual([]); + expect(r.invalid).toEqual([]); + cleanup(); + }); + + it("THE REAL CORPUS: the two production notes validate against the contract unchanged", () => { + // fixtures-by-copy — they are the contract's source of truth + const real = "/Users/aaron/.amico/vaults/vault-aaron/papers"; + const r = foldCorpus(["/Users/aaron/.amico/vaults"], "/Users/aaron/.amico/library"); + expect(r.invalid.filter((x) => x.file.startsWith(real))).toEqual([]); + expect(r.papers.filter((p) => p.file.startsWith(real)).length).toBeGreaterThanOrEqual(2); + cleanup(); + }); +}); diff --git a/packages/amico-run/test/papers_verb.test.ts b/packages/amico-run/test/papers_verb.test.ts new file mode 100644 index 0000000..630ce03 --- /dev/null +++ b/packages/amico-run/test/papers_verb.test.ts @@ -0,0 +1,83 @@ +// `amico papers` — the unified literature corpus surface (#405): +// list (filters + table/JSON), feeding the collect→unify→usable→searchable +// ladder. Read-only; the fold never writes. +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { papersVerb } from "../src/papers_verb.js"; + +let root: string; +let vaults: string; +let library: string; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "papers-verb-")); + vaults = join(root, "vaults"); + library = join(root, "library"); + const mine = join(vaults, "mine"); + mkdirSync(join(mine, "papers"), { recursive: true }); + mkdirSync(library, { recursive: true }); + process.env.AMICO_PAPERS_VAULTS = vaults; + process.env.AMICO_PAPERS_LIBRARY = library; +}); +afterEach(() => { + rmSync(root, { recursive: true, force: true }); + delete process.env.AMICO_PAPERS_VAULTS; + delete process.env.AMICO_PAPERS_LIBRARY; +}); + +const N1 = `type: paper\ntitle: "TEMPO"\nauthors: [Strathearn]\narxiv: "1711.09641"\nstatus: staged\nsystems: [transmon]\ntags: [tempo, open-systems]`; +const N2 = `type: paper\ntitle: "Mitten qLDPC"\nauthors: [Bhardwaj]\ndoi: "10.48550/arXiv.2607.28795"\nrelevance: high\nsystems: [rydberg]\ntags: [qldpc]`; + +function note(file: string, fm: string) { + writeFileSync(join(vaults, "mine", "papers", file), `---\n${fm}\n---\n\n# t\n`); +} + +describe("papersVerb", () => { + it("usage error with no subcommand (exit 64, no crash)", () => { + const r = papersVerb([]); + expect(r.code).toBe(64); + }); + + it("list: JSON with the unified corpus + counts + drift", () => { + note("a.md", N1); + note("b.md", N2); + const r = papersVerb(["list", "--json"]); + expect(r.code).toBe(0); + const j = r.json as { ok: boolean; papers: { title: string }[]; counts: Record }; + expect(j.ok).toBe(true); + expect(j.papers.map((p) => p.title).sort()).toEqual(["Mitten qLDPC", "TEMPO"]); + expect(j.counts.papers).toBe(2); + expect(j.counts.records_without_pdf).toBe(2); + }); + + it("filters: --status, --tag, --platform, --q substring", () => { + note("a.md", N1); + note("b.md", N2); + const run = (args: string[]) => + ((papersVerb(["list", "--json", ...args]).json as { papers: { title: string }[] }).papers.map((p) => p.title)); + expect(run(["--status", "staged"])).toEqual(["TEMPO"]); + expect(run(["--status", "distilled"])).toEqual(["Mitten qLDPC"]); // absent = distilled + expect(run(["--tag", "qldpc"])).toEqual(["Mitten qLDPC"]); + expect(run(["--platform", "transmon"])).toEqual(["TEMPO"]); + expect(run(["--q", "mitten"])).toEqual(["Mitten qLDPC"]); + expect(run(["--q", "qLDPC"])).toEqual(["Mitten qLDPC"]); // case-insensitive + }); + + it("default output is a human table (rendered string), not raw JSON", () => { + note("a.md", N1); + const r = papersVerb(["list"]); + expect(r.code).toBe(0); + expect(JSON.stringify(r.json)).toContain("TEMPO"); + expect(JSON.stringify(r.json)).toMatch(/table|TEMPO/); + }); + + it("invalid notes are reported, never fatal", () => { + note("bad.md", `type: paper\ntitle: "No identity"\nauthors: [X]`); + const r = papersVerb(["list", "--json"]); + expect(r.code).toBe(0); + const j = r.json as { counts: Record; invalid: { file: string }[] }; + expect(j.counts.invalid).toBe(1); + expect(j.invalid[0]!.file).toContain("bad.md"); + }); +}); diff --git a/packages/schema/schemas/library-paper.schema.json b/packages/schema/schemas/library-paper.schema.json index 5b7ae6b..392483f 100644 --- a/packages/schema/schemas/library-paper.schema.json +++ b/packages/schema/schemas/library-paper.schema.json @@ -11,31 +11,44 @@ "title": { "type": "string", "minLength": 1 }, "authors": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } }, "arxiv": { - "type": "string", - "pattern": "^\\d{4}\\.\\d{4,5}$", - "description": "canonical dotted id, NO version suffix — the fold normalizes vN before compare" + "type": ["string", "null"], + "pattern": "^(\\d{4}\\.\\d{4,5}(v\\d+)?|(cond-mat|quant-ph|hep-th|math|cs|astro-ph|gr-qc|hep-lat|hep-ex|hep-ph|nucl-ex|nucl-th|physics|q-bio|q-fin|stat|nlin|acc-phys|ao-sci|atom-ph|bayes-an|chao-dyn|chem-ph|comp-gas|cond-mat|dg-ga|funct-an|mtrl-th|patt-sol|physics|plasm-ph|solv-int|supr-con)/\\d{7})$", + "description": "new dotted form (optional v-suffix tolerated) OR the legacy archive form (cond-mat/0703002, quant-ph/9906066) — pre-2007 ids are production data; the fold normalizes before compare. null = identity unresolved (arxiv OR doi still required; the anyOf flags the gap honestly)" }, - "doi": { "type": "string", "pattern": "^10\\.\\d{4,9}/\\S+$" }, + "doi": { "type": ["string", "null"], "pattern": "^10\\.\\d{4,9}/\\S+$" }, "status": { "enum": ["staged", "distilled"], "description": "lifecycle: bots stage, humans promote. Absent = distilled (the historical notes predate the gate)" }, "date_read": { "type": "string", "minLength": 1 }, "date": { "type": "string", "minLength": 1 }, - "relevance": { "enum": ["high", "medium", "low"] }, + "relevance": { + "anyOf": [{ "enum": ["high", "medium", "low"] }, { "type": "string", "minLength": 1 }], + "description": "enum for queryability; free-text justification tolerated (the team vault's earlier pipeline wrote prose relevance — production data)" + }, "systems": { "type": "array", "items": { "type": "string", "minLength": 1 } }, "tags": { "type": "array", "items": { "type": "string", "minLength": 1 } }, "visibility": { "type": "string", "minLength": 1 }, "route_intent": { "type": "string", "minLength": 1 }, "session_id": { "type": ["string", "null"] }, "source": { - "enum": ["dream-distill", "papers-channel", "arxiv-subscription", "upload", "manual"], + "enum": ["dream-distill", "papers-channel", "arxiv-subscription", "upload", "manual", "doc-ingest"], "description": "the ingestion pipeline that produced the note" }, - "source_session": { "type": ["string", "null"] } + "source_session": { "type": ["string", "null"] }, + "journal": { "type": "string", "minLength": 1 }, + "published": { "type": "string", "minLength": 1 }, + "species": { "type": "string", "minLength": 1 }, + "read_depth": { "type": "string", "minLength": 1 }, + "hardware": { "type": "string", "minLength": 1 }, + "promoted_from": { "type": ["string", "null"] }, + "merged_into": { "type": "string", "minLength": 1 }, + "date_full_text_read": { "type": "string", "minLength": 1 }, + "triaged_from": { "type": "string", "minLength": 1 } }, "anyOf": [ - { "required": ["arxiv"] }, - { "required": ["doi"] } - ] + { "required": ["arxiv"], "properties": { "arxiv": { "type": "string" } } }, + { "required": ["doi"], "properties": { "doi": { "type": "string" } } } + ], + "$comment": "identity = at least one NON-NULL id. The branch-level type constraint is what enforces it — `required` alone is presence-based and would let arxiv: null through." } diff --git a/packages/schema/test/fixtures/valid/library-paper.toml b/packages/schema/test/fixtures/valid/library-paper.toml index 30f55cc..09566c8 100644 --- a/packages/schema/test/fixtures/valid/library-paper.toml +++ b/packages/schema/test/fixtures/valid/library-paper.toml @@ -8,3 +8,6 @@ relevance = "high" systems = ["transmon", "bosonic"] tags = ["paper", "tempo"] source = "dream-distill" + +journal = "PRX Quantum 3, 040336 (2022)" +hardware = "ibm-heron-boston" diff --git a/packages/schema/test/library-paper.test.ts b/packages/schema/test/library-paper.test.ts index 6cdb921..856f1d6 100644 --- a/packages/schema/test/library-paper.test.ts +++ b/packages/schema/test/library-paper.test.ts @@ -42,11 +42,24 @@ describe("the library-paper kind", () => { expect(validate(rec({ status: "staged" }), "library-paper")).toMatchObject({ ok: true }); expect(validate(rec({ status: "published" }), "library-paper").ok).toBe(false); }); - it("relevance is an enum when present", () => { - expect(validate(rec({ relevance: "meh" }), "library-paper").ok).toBe(false); + it("relevance: enum for queryability; PROSE relevance tolerated (production data)", () => { + expect(validate(rec({ relevance: "Original transmon paper; foundational reference for all transmon work" }), "library-paper")).toMatchObject({ ok: true }); + expect(validate(rec({ relevance: 3 }), "library-paper").ok).toBe(false); }); - it("arxiv ids are the canonical dotted form (version suffixes normalize at the fold, not the schema)", () => { - expect(validate(rec({ arxiv: "1711.09641v2" }), "library-paper").ok).toBe(false); + it("null arxiv is type-tolerated but identity still required (the anyOf flags the gap)", () => { + expect(validate(rec({ arxiv: null, doi: "10.1234/x" }), "library-paper")).toMatchObject({ ok: true }); + expect(validate(rec({ arxiv: null }), "library-paper").ok).toBe(false); + }); + it("the team vault's vocabulary is absorbed: journal/published/species/read_depth/hardware/promoted_from", () => { + expect( + validate( + rec({ journal: "PRX Quantum 3, 040336 (2022)", published: "2025-09-24", species: "cesium", read_depth: "skim", hardware: "ibm-heron-boston", promoted_from: null }), + "library-paper", + ), + ).toMatchObject({ ok: true }); + }); + it("arxiv ids: dotted form, version suffix TOLERATED (the fold normalizes); garbage refuses", () => { + expect(validate(rec({ arxiv: "1711.09641v2" }), "library-paper").ok).toBe(true); expect(validate(rec({ arxiv: "not-an-id" }), "library-paper").ok).toBe(false); }); it("provenance: source is an enum of the known pipelines; session_id a string", () => { From 45740d4fc6d8ca94807c4f0a7e422b1b754c6434 Mon Sep 17 00:00:00 2001 From: aaron Date: Tue, 18 Aug 2026 04:27:59 -0400 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20the=20real-corpus=20parity=20test=20?= =?UTF-8?q?is=20machine-local=20=E2=80=94=20skip=20when=20the=20vaults=20a?= =?UTF-8?q?re=20absent=20(CI=20hermeticity)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test read /Users/aaron/... — my machine's vaults. On CI it folded an empty tree and failed. skipIf(absent): it runs where the real corpus lives, skips honestly elsewhere. The contract itself stays pinned by the schema suite's copied fixtures. --- packages/amico-run/test/papers.test.ts | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/amico-run/test/papers.test.ts b/packages/amico-run/test/papers.test.ts index 7c7ed12..00d0e2a 100644 --- a/packages/amico-run/test/papers.test.ts +++ b/packages/amico-run/test/papers.test.ts @@ -3,7 +3,7 @@ // identity, content-addressed join, orphans surfaced. The fold NEVER writes // (dedup reports; merging is a human promote act). import { describe, it, expect, beforeEach } from "vitest"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { foldCorpus } from "../src/papers.js"; @@ -94,12 +94,18 @@ describe("foldCorpus", () => { cleanup(); }); - it("THE REAL CORPUS: the two production notes validate against the contract unchanged", () => { - // fixtures-by-copy — they are the contract's source of truth - const real = "/Users/aaron/.amico/vaults/vault-aaron/papers"; - const r = foldCorpus(["/Users/aaron/.amico/vaults"], "/Users/aaron/.amico/library"); - expect(r.invalid.filter((x) => x.file.startsWith(real))).toEqual([]); - expect(r.papers.filter((p) => p.file.startsWith(real)).length).toBeGreaterThanOrEqual(2); - cleanup(); - }); + // Machine-local parity: the production notes are the contract's source of + // truth — but they exist only on a machine carrying the real vaults. CI + // runners have none; the fold returns 0 there and the check is meaningless. + // (The contract itself is pinned by the schema suite's fixtures-by-copy.) + it.skipIf(!existsSync("/Users/aaron/.amico/vaults/vault-aaron/papers"))( + "THE REAL CORPUS: the production notes validate against the contract unchanged", + () => { + const real = "/Users/aaron/.amico/vaults/vault-aaron/papers"; + const r = foldCorpus(["/Users/aaron/.amico/vaults"], "/Users/aaron/.amico/library"); + expect(r.invalid.filter((x) => x.file.startsWith(real))).toEqual([]); + expect(r.papers.filter((p) => p.file.startsWith(real)).length).toBeGreaterThanOrEqual(2); + cleanup(); + }, + ); });