Skip to content

Commit 52d189a

Browse files
committed
feat: define paper ingestion coverage contracts
1 parent 358f65d commit 52d189a

4 files changed

Lines changed: 548 additions & 0 deletions

File tree

docs/HANDOFF.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Development handoff
22

3+
## 2026-08-31 First paper-substrate contract increment
4+
5+
Completed the strict, versioned contracts for paper sources, UTF-8 locators, sections, chunks,
6+
revisions, extraction-versus-processing coverage, and cache identity. Extraction regions partition
7+
the source, and chunks partition extracted regions. Processing is complete only when extraction is
8+
complete and all chunks are processed. Portable path safety is covered. No parser, persistence,
9+
model analysis, PDF support, or `/paper` command exists yet.
10+
11+
Validation: contracts typecheck; 26/26 contracts tests; Biome on three files; and
12+
`git diff --check` passed. Next: deterministic local txt/md/tex research ingestion, then the
13+
Harness/CLI `/paper` vertical slice.
14+
315
## 2026-08-31 Phase 2 stabilization addendum
416

517
Independent review and re-review are clean and commit-ready after fixes. The fixes addressed exact

packages/contracts/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export * from "./errors.js";
22
export * from "./events.js";
33
export * from "./ids.js";
44
export * from "./model.js";
5+
export * from "./paper.js";
56
export * from "./run.js";
67
export * from "./runtime-protocol.js";
78
export * from "./tools.js";

packages/contracts/src/paper.ts

Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
import { z } from "zod";
2+
import {
3+
ContractSchemaVersionSchema,
4+
EntityIdSchema,
5+
SafeTextSchema,
6+
Sha256Schema,
7+
} from "./ids.js";
8+
9+
const RevisionSchema = z
10+
.string()
11+
.min(1)
12+
.max(128)
13+
.regex(/^[A-Za-z0-9][A-Za-z0-9._+/-]*$/u, "Invalid revision identifier");
14+
const RelativePathSchema = z
15+
.string()
16+
.min(1)
17+
.max(1024)
18+
.refine((value) => !/^[A-Za-z]:|^[/\\]/u.test(value), "Absolute and drive paths are not allowed")
19+
.refine((value) => value === value.trim(), "Leading and trailing whitespace are not allowed")
20+
.refine(
21+
(value) =>
22+
value
23+
.split("/")
24+
.every(
25+
(part) =>
26+
part.length > 0 &&
27+
part.length <= 255 &&
28+
part !== "." &&
29+
part !== ".." &&
30+
!/[. ]$/u.test(part) &&
31+
!/[<>:"\\|?*]/u.test(part) &&
32+
!/^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?$/iu.test(part),
33+
),
34+
"Invalid relative path",
35+
)
36+
.refine(
37+
(value) =>
38+
[...value].every((character) => {
39+
const code = character.codePointAt(0) ?? 0;
40+
return code > 0x1f && (code < 0x7f || code > 0x9f);
41+
}),
42+
"Control characters are not allowed",
43+
);
44+
45+
export const PaperFormatSchema = z.enum(["text", "markdown", "latex"]);
46+
export type PaperFormat = z.infer<typeof PaperFormatSchema>;
47+
48+
export const PaperSourceSnapshotSchema = z
49+
.object({
50+
sourceId: EntityIdSchema,
51+
relativePath: RelativePathSchema,
52+
format: PaperFormatSchema,
53+
mediaType: z
54+
.string()
55+
.min(1)
56+
.max(128)
57+
.regex(/^[\w.+-]+\/[\w.+-]+$/u),
58+
byteLength: z.number().int().positive().max(1_000_000_000),
59+
contentHash: Sha256Schema,
60+
})
61+
.strict()
62+
.readonly();
63+
export type PaperSourceSnapshot = z.infer<typeof PaperSourceSnapshotSchema>;
64+
65+
export const PaperByteRangeSchema = z
66+
.object({ startByte: z.number().int().nonnegative(), endByte: z.number().int().nonnegative() })
67+
.strict()
68+
.superRefine((range, context) => {
69+
if (range.endByte <= range.startByte)
70+
context.addIssue({ code: "custom", message: "Range must be non-empty" });
71+
})
72+
.readonly();
73+
export type PaperByteRange = z.infer<typeof PaperByteRangeSchema>;
74+
75+
export const PaperLocatorSchema = z
76+
.object({
77+
startByte: z.number().int().nonnegative(),
78+
endByte: z.number().int().nonnegative(),
79+
startLine: z.number().int().positive(),
80+
endLine: z.number().int().positive(),
81+
sectionPath: z.array(SafeTextSchema).max(32).readonly().optional(),
82+
})
83+
.strict()
84+
.superRefine((locator, context) => {
85+
if (locator.endByte <= locator.startByte)
86+
context.addIssue({ code: "custom", message: "Locator must be non-empty" });
87+
if (locator.endLine < locator.startLine)
88+
context.addIssue({ code: "custom", message: "Invalid line range" });
89+
})
90+
.readonly();
91+
export type PaperLocator = z.infer<typeof PaperLocatorSchema>;
92+
93+
export const PaperSectionSchema = z
94+
.object({
95+
sectionId: EntityIdSchema,
96+
title: SafeTextSchema,
97+
level: z.number().int().positive().max(64),
98+
locator: PaperLocatorSchema,
99+
})
100+
.strict()
101+
.readonly();
102+
export type PaperSection = z.infer<typeof PaperSectionSchema>;
103+
104+
export const PaperChunkSchema = z
105+
.object({
106+
chunkId: EntityIdSchema,
107+
index: z.number().int().nonnegative(),
108+
locator: PaperLocatorSchema,
109+
contentHash: Sha256Schema,
110+
content: z.string().min(1).max(16_000_000),
111+
estimatedTokenCount: z.number().int().nonnegative().max(16_000_000),
112+
})
113+
.strict()
114+
.superRefine((chunk, context) => {
115+
if (
116+
new TextEncoder().encode(chunk.content).byteLength !==
117+
chunk.locator.endByte - chunk.locator.startByte
118+
) {
119+
context.addIssue({
120+
code: "custom",
121+
message: "Chunk content length must match its byte locator",
122+
});
123+
}
124+
})
125+
.readonly();
126+
export type PaperChunk = z.infer<typeof PaperChunkSchema>;
127+
128+
export const PaperRevisionsSchema = z
129+
.object({ extractor: RevisionSchema, chunker: RevisionSchema, tokenEstimator: RevisionSchema })
130+
.strict()
131+
.readonly();
132+
export type PaperRevisions = z.infer<typeof PaperRevisionsSchema>;
133+
134+
export const PaperExtractionCoverageSchema = z
135+
.object({
136+
complete: z.boolean(),
137+
extractedRegions: z.array(PaperByteRangeSchema).max(1_000_000).readonly(),
138+
omittedRegions: z
139+
.array(z.object({ range: PaperByteRangeSchema, reason: SafeTextSchema }).strict().readonly())
140+
.max(1_000_000)
141+
.readonly(),
142+
})
143+
.strict()
144+
.readonly();
145+
export type PaperExtractionCoverage = z.infer<typeof PaperExtractionCoverageSchema>;
146+
147+
export const PaperProcessingStateSchema = z.enum([
148+
"not_requested",
149+
"processed",
150+
"failed",
151+
"skipped",
152+
]);
153+
export type PaperProcessingState = z.infer<typeof PaperProcessingStateSchema>;
154+
155+
const ProcessingCountsSchema = z
156+
.object({
157+
notRequested: z.number().int().nonnegative(),
158+
processed: z.number().int().nonnegative(),
159+
failed: z.number().int().nonnegative(),
160+
skipped: z.number().int().nonnegative(),
161+
})
162+
.strict()
163+
.readonly();
164+
165+
export const PaperProcessingEntrySchema = z
166+
.object({ chunkId: EntityIdSchema, state: PaperProcessingStateSchema })
167+
.strict()
168+
.readonly();
169+
170+
export const PaperProcessingCoverageSchema = z
171+
.object({
172+
complete: z.boolean(),
173+
entries: z.array(PaperProcessingEntrySchema).max(1_000_000).readonly(),
174+
counts: ProcessingCountsSchema,
175+
})
176+
.strict()
177+
.readonly();
178+
export type PaperProcessingCoverage = z.infer<typeof PaperProcessingCoverageSchema>;
179+
180+
export const PaperIngestionManifestSchema = z
181+
.object({
182+
schemaVersion: ContractSchemaVersionSchema,
183+
cacheIdentity: Sha256Schema,
184+
sourceSnapshot: PaperSourceSnapshotSchema,
185+
revisions: PaperRevisionsSchema,
186+
sections: z.array(PaperSectionSchema).max(100_000).readonly(),
187+
chunks: z.array(PaperChunkSchema).max(1_000_000).readonly(),
188+
extraction: PaperExtractionCoverageSchema,
189+
processing: PaperProcessingCoverageSchema,
190+
})
191+
.strict()
192+
.superRefine((manifest, context) => {
193+
const { chunks, sourceSnapshot, extraction, processing } = manifest;
194+
let previousEnd = 0;
195+
const ids = new Set<string>();
196+
for (const [index, chunk] of chunks.entries()) {
197+
if (chunk.index !== index)
198+
context.addIssue({
199+
code: "custom",
200+
path: ["chunks", index, "index"],
201+
message: "Chunk indexes must be contiguous from zero",
202+
});
203+
if (ids.has(chunk.chunkId))
204+
context.addIssue({
205+
code: "custom",
206+
path: ["chunks", index, "chunkId"],
207+
message: "Chunk IDs must be unique",
208+
});
209+
ids.add(chunk.chunkId);
210+
if (
211+
chunk.locator.startByte < previousEnd ||
212+
chunk.locator.endByte > sourceSnapshot.byteLength
213+
)
214+
context.addIssue({
215+
code: "custom",
216+
path: ["chunks", index, "locator"],
217+
message: "Chunk locators must be ordered and within source",
218+
});
219+
previousEnd = chunk.locator.endByte;
220+
}
221+
const extracted = extraction.extractedRegions;
222+
const omitted = extraction.omittedRegions.map(({ range }) => range);
223+
for (const ranges of [extracted, omitted]) {
224+
for (let index = 1; index < ranges.length; index++) {
225+
const current = ranges[index];
226+
const previous = ranges[index - 1];
227+
if (current !== undefined && previous !== undefined && current.startByte < previous.endByte)
228+
context.addIssue({
229+
code: "custom",
230+
path: ["extraction"],
231+
message: "Regions must be ordered and non-overlapping",
232+
});
233+
}
234+
}
235+
let ei = 0;
236+
let oi = 0;
237+
let cursor = 0;
238+
while (ei < extracted.length || oi < omitted.length) {
239+
const e = extracted[ei];
240+
const o = omitted[oi];
241+
const next = o === undefined || (e !== undefined && e.startByte < o.startByte) ? e : o;
242+
if (next === undefined || next.startByte !== cursor) break;
243+
cursor = next.endByte;
244+
if (next === e) ei++;
245+
else oi++;
246+
}
247+
if (cursor !== sourceSnapshot.byteLength || ei !== extracted.length || oi !== omitted.length)
248+
context.addIssue({
249+
code: "custom",
250+
path: ["extraction"],
251+
message: "Extraction ranges contain gaps, overlaps, or exceed source",
252+
});
253+
if (extraction.complete !== (omitted.length === 0))
254+
context.addIssue({
255+
code: "custom",
256+
path: ["extraction", "complete"],
257+
message: "Extraction completeness disagrees with omitted regions",
258+
});
259+
let ri = 0;
260+
let chunkCursor = extracted[0]?.startByte;
261+
for (const chunk of chunks) {
262+
while (ri < extracted.length && chunkCursor === extracted[ri]?.endByte) {
263+
ri++;
264+
chunkCursor = extracted[ri]?.startByte;
265+
}
266+
const region = extracted[ri];
267+
if (
268+
chunkCursor === undefined ||
269+
chunk.locator.startByte !== chunkCursor ||
270+
region === undefined ||
271+
chunk.locator.endByte > region.endByte
272+
)
273+
context.addIssue({
274+
code: "custom",
275+
path: ["chunks"],
276+
message: "Chunks must exactly partition extracted regions",
277+
});
278+
chunkCursor = chunk.locator.endByte;
279+
}
280+
while (ri < extracted.length && chunkCursor === extracted[ri]?.endByte) {
281+
ri++;
282+
chunkCursor = extracted[ri]?.startByte;
283+
}
284+
if (ri !== extracted.length)
285+
context.addIssue({
286+
code: "custom",
287+
path: ["chunks"],
288+
message: "Chunks must consume every extracted region",
289+
});
290+
const sectionIds = new Set<string>();
291+
for (const [index, section] of manifest.sections.entries()) {
292+
if (sectionIds.has(section.sectionId) || section.locator.endByte > sourceSnapshot.byteLength)
293+
context.addIssue({
294+
code: "custom",
295+
path: ["sections", index],
296+
message: "Section IDs and locators must be unique and bounded",
297+
});
298+
sectionIds.add(section.sectionId);
299+
}
300+
const entryIds = new Set<string>();
301+
const counts: Record<"notRequested" | "processed" | "failed" | "skipped", number> = {
302+
notRequested: 0,
303+
processed: 0,
304+
failed: 0,
305+
skipped: 0,
306+
};
307+
for (const entry of processing.entries) {
308+
if (
309+
entryIds.has(entry.chunkId) ||
310+
!ids.has(entry.chunkId) ||
311+
entry.chunkId !== chunks[entryIds.size]?.chunkId
312+
)
313+
context.addIssue({
314+
code: "custom",
315+
path: ["processing"],
316+
message: "Processing entries must correspond exactly once to chunks",
317+
});
318+
entryIds.add(entry.chunkId);
319+
const countKey = entry.state === "not_requested" ? "notRequested" : entry.state;
320+
counts[countKey]++;
321+
}
322+
if (
323+
entryIds.size !== chunks.length ||
324+
Object.entries(counts).some(
325+
([key, value]) => processing.counts[key as keyof typeof counts] !== value,
326+
) ||
327+
processing.complete !==
328+
(extraction.complete && chunks.length > 0 && counts.processed === chunks.length)
329+
)
330+
context.addIssue({
331+
code: "custom",
332+
path: ["processing"],
333+
message: "Processing summary disagrees with entries",
334+
});
335+
})
336+
.readonly();
337+
export type PaperIngestionManifest = z.infer<typeof PaperIngestionManifestSchema>;
338+
339+
export const PaperManifestSchema = PaperIngestionManifestSchema;
340+
export type PaperManifest = PaperIngestionManifest;

0 commit comments

Comments
 (0)