|
| 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