light-ocr Core Decisions
July 27, 2026 · View on GitHub
Status: Active decision record
Authority: resolved architectural and product choices for the native Core milestone
Requirements: requirements.md
1. How to use this record
This file records choices that affect more than one companion document. It prevents an implementation detail from silently changing product behavior.
Statuses are:
- Accepted: implementation and tests must follow the decision.
- Deferred: explicitly outside the Core milestone.
- Bootstrap required: the decision rule is fixed, but a generated lock or report must be committed before acceptance.
Changing an accepted decision requires updating affected documents and rerunning their gates.
2. Accepted decisions
D001 — Complete the C++ core before adapters
Status: Accepted
Decision: The current milestone ends at a complete C++17 detection-and-recognition core plus validation evidence. N-API and language packaging are not deliverables.
Reason: The OCR algorithms, model contract, safety limits, and parity behavior need one authoritative implementation before adapter lifecycle and scheduling are introduced.
Consequence: Adapter-friendly boundaries are retained, but no adapter code may become a completion dependency.
D002 — Use the official PP-OCRv6 small ONNX pair
Status: Accepted
Decision: The default bundle uses PP-OCRv6_small_det_onnx and PP-OCRv6_small_rec_onnx with the immutable identities in model-bundle.md.
Reason: Official ONNX artifacts exist for both mandatory stages and provide the smallest initial deployment target.
Consequence: Tiny and medium variants require separate bundle IDs and complete quality, parity, and performance evaluation.
D003 — Detection and recognition only
Status: Accepted
Decision: Text-line orientation is represented as a capability but is unavailable in the first bundle. Enabling it returns unsupported_capability. Document orientation, unwarping, layout, table, and formula stages are outside scope.
Reason: This makes missing behavior explicit while keeping the result and capability contracts extensible.
Consequence: The core must never silently ignore an orientation request.
D004 — Pin PaddleOCR v3.7.0 as the behavioral oracle
Status: Accepted
Decision: Differential behavior is locked to PaddleOCR v3.7.0 revision b03f46425e8ff4442b268ce449e3eef758146cd4, the same ONNX bytes, and explicit configuration.
Reason: A named model family is insufficient to reproduce preprocessing and postprocessing.
Consequence: Upstream main, mutable defaults, and locally installed PaddleOCR versions are not valid release evidence.
D005 — Make normalized JSON the runtime configuration authority
Status: Accepted
Decision: Official inference.yml files remain provenance evidence, while the core consumes only a validated normalized-config.json.
Reason: Official entry points contain different effective defaults, and YAML parser behavior should not affect runtime results.
Consequence: Every effective parameter is explicit, hashed, and covered by the oracle snapshot. Unknown or inconsistent values fail bundle validation.
D006 — Use synchronous, bounded engine execution
Status: Accepted
Decision: recognize is synchronous. One engine admits one call and rejects additional concurrent admission; separate engines may run concurrently.
Reason: The core should not own an invisible queue or impose a scheduling policy on future hosts.
Consequence: Future asynchronous adapters own input retention, worker pools, backpressure, cancellation policy, and engine lifetime.
D007 — Expose a C++17 source contract, not a stable ABI
Status: Accepted
Decision: Public headers use only the C++ standard library and hide backend types, but the milestone does not promise C ABI or cross-toolchain C++ ABI compatibility.
Reason: This is sufficient for in-repository integration while the semantic contract stabilizes.
Consequence: Validation packages must be built for each Tier 1 target; consumers cannot mix arbitrary compilers or standard libraries.
D008 — Accept decoded pixels only
Status: Accepted
Decision: The C++ Core boundary accepts validated GRAY8, RGB8, BGR8, or RGBA8 memory views. Host adapters may offer bounded decoding before crossing that boundary; the Node adapter supports in-memory JPEG/PNG. Documents and other formats are decoded by the caller.
Reason: Decoding greatly expands format, security, dependency, and platform scope without improving the OCR algorithms.
Consequence: Fixtures cross the C++ boundary as raw pixels, parity compares identical decoded bytes, and adapter decoders require their own dependency, security-limit, and format tests.
D009 — Support four Tier 1 targets
Status: Accepted
Decision: macOS arm64, macOS x64, Windows x64, and Linux x64 are completion blockers. Linux arm64 and Windows arm64 are deferred.
Reason: These targets cover the first intended native integration environments while keeping the acceptance matrix finite.
Consequence: Each target needs native build and test evidence; cross-compilation alone is insufficient.
D010 — Require fully offline runtime behavior
Status: Accepted
Decision: Bundle bytes are supplied to the core; engine creation and recognition perform no download, shell execution, process launch, or implicit filesystem lookup.
Reason: Reproducibility, privacy, and embedding safety require all runtime inputs to be explicit.
Consequence: Model acquisition is a controlled bootstrap/release operation, not a runtime fallback.
D011 — Enforce bundle ceilings and conservative initial limits
Status: Accepted
Decision: The first bundle sets the resource ceilings in section 11 of requirements.md. Engine options may reduce but not increase them.
Reason: Raw dimensions alone do not bound detection candidates, tensor width, or temporary allocation.
Consequence: Checked arithmetic and pre-allocation limit checks are required at every expanding stage.
D012 — Keep results geometric and stage-observable
Status: Accepted
Decision: Results preserve ordered quadrilateral boxes, confidence, UTF-8 text, model identity, and fixed stage timings. Optional diagnostics exclude pixels, tensors, and recognized-text logging.
Reason: Geometry and stage evidence are required for adapter parity, debugging, and performance attribution.
Consequence: Later adapters map the contract without flattening boxes or redefining confidence.
D013 — Bound default detection and stream recognition memory
Status: Accepted; first phase implemented locally, Tier 1 evidence pending
Decision: Follow memory-optimization.md. The product default uses bounded detection with longest side 960, keeps 4,000 only as the bundle ceiling and explicit upstream_exact behavior, changes the effective recognition default to batch 1, and constructs/crops/infers/decodes/releases one recognition batch at a time. High-resolution accuracy uses an overlap-tiled strategy after its quality gates pass.
Reason: A single 2048×2048 image currently reaches about 0.9 GiB RSS before dense recognition and about 2.1 GiB for a 127-line form. The model weights are only about 30 MiB; full-resolution activations, ORT workspace, output copies, and all-batch materialization dominate memory.
Consequence: Normalized config schema 1.1 distinguishes upstream resize provenance, product runtime defaults, and hard ceilings. Exact and bounded goldens are separate profiles; macOS arm64 absolute RSS gates and API changes are complete locally, while the other Tier 1 baselines and tiled second phase remain before release.
D101 — Use an asynchronous, bounded Node-API v8 adapter
Status: Accepted; source implementation complete, release matrix pending
Decision: The first Node.js adapter follows napi-design.md: raw Node-API with NAPI_VERSION=8, Promise APIs, one dedicated FIFO worker per engine, bounded request and snapshot-byte admission, copied raw-pixel inputs, AbortSignal cooperative cancellation, explicit async close, and environment-scoped cleanup. It supports Node.js 22/24 first. The native boundary still receives one local bundle directory; the published JS facade supplies the built-in model package path by default and accepts an explicit bundlePath override.
Reason: The synchronous one-call Core needs an adapter-owned scheduling and lifetime boundary that does not block JavaScript, consume libuv's shared worker pool, or permit caller mutation of in-flight image bytes.
Consequence: Core OCR semantics and errors remain authoritative. Queued abort removes work; running abort discards delivery but does not interrupt inference. Encoded images, zero-copy, hard interruption, runtime model download/update, Electron/Bun, and additional platforms remain separate extensions.
D105 — Publish a lockstep @arcships package set with a required default model
Status: Accepted; implementation pending
Decision: The public entry is @arcships/light-ocr. It has one exact-version normal dependency on @arcships/light-ocr-model-ppocrv6-small and exact-version optional dependencies on four platform native packages, as specified in npm-packaging.md. The model package carries the unpacked, hash-locked PP-OCRv6 small bundle; createEngine() uses it without a required bundlePath. No package runs install/postinstall downloads or source-build fallbacks.
Reason: Users should perform one npm installation and then create an engine without a second model acquisition step, while avoiding four duplicated copies of the same model across native packages.
Consequence: Six packages release in lockstep. The facade is published last, after the model and native packages pass sterile tarball installation. A separate model-free flavor, multiple model selection, runtime updating and non-npm model mirrors are not v1 completion requirements.
D111 — Freeze a provider-neutral execution contract before enabling accelerators
Status: Accepted for Perf-1A;Apple provider implementation complete locally with open macOS compatibility
Decision: The following records the D111 implementation-era contract; D112 supersedes its default and whole-session cross-backend fallback semantics. EngineOptions.execution owns the stable provider policy. The default remains cpu with sessionFallback=error, cpuPartition=allow, performanceHint=latency, and precision=auto. The 0.3.0 source union adds apple: Direct Core ML FP16 routes Apple Silicon detector and recognition widths through the ANE/MLCPU envelope, sends recognition widths above 1600 to FP16 GPU, and uses all-GPU execution when CPU partitions are forbidden. Production schema 1.1 payloads require macOS 15, batch 1, bounded/960 detection and devicePolicy=open-macos; arm64 and x86_64 are accepted. Intel Mac has no ANE and therefore uses Core ML CPU+GPU with cpuPartition=allow; strict GPU policy remains Apple-Silicon-only. validatedDeviceFamilies and public deviceValidated distinguish reviewed M4 evidence from experimental compatibility without blocking M1–M3, later Apple Silicon, or Intel users. sessionFallback=cpu is a whole-session creation fallback with a stable reason; runtime inference never retries. Unsupported provider, precision, partition, fallback, or performance combinations return invalid_argument rather than being ignored. EngineInfo.execution.sessions reports detection and recognition independently, including requested provider, configured chain, device family/OS, device validation status, effective precision, shape policy, model identity/hash, runtime/provider version, cache status, qualification ID, and fallback. Per-call recognition diagnostics add function bucket and compute unit. The legacy aggregate executionProvider remains as a compatibility field while callers migrate. Qualification applies the cache-aware 3/30-second provider cold-start ceiling to the locked generated-hello-123 canary; larger workloads retain their full first-page time as a separate observation because it also includes content-dependent detection and function loading.
Reason: Apple ANE/GPU routing and other accelerators require per-stage selection and truthful fallback evidence. Freezing the neutral contract first lets backends vary without duplicating the OCR pipeline or describing provider registration as device placement.
Consequence: The Core owns a backend-neutral InferenceSession boundary with ONNX Runtime CPU and Objective-C++ Direct Core ML implementations. The Apple model package is a self-contained superset of the CPU bundle; compiled models are cached offline by package hash + OS build + hardware identity under a cross-process lock. All 91 recognition functions have reviewed M4 placement evidence, while runtime inputs round up to 20 locked weighted width buckets under an LRU ceiling of 20. Other Macs can run early and report failures; their performance is not advertised until community or maintainer evidence is reviewed. WebGPU, DirectML, OpenVINO, CUDA, QNN, provider auto, and throughput profiles remain unavailable in the implementation covered by D111. D112 supersedes D111 only for future cross-backend creation selection and whole-session fallback semantics.
D112 — Use platform-aware Auto with creation-time ordered fallback
Status: Implemented in the current source candidate; platform release evidence pending
Decision: provider=auto is the only mode that may try more than one backend. During Engine creation it resolves a versioned, platform-specific candidate list, attempts each candidate atomically in order, selects the first candidate whose detector and recognizer sessions both initialize, and then freezes that backend for the engine lifetime. Every valid released policy is non-empty and ends in cpu; the target policy is macOS apple → cpu, Windows x64 webgpu → cpu, and Linux x64 glibc webgpu → cpu. A candidate may enter a released policy only when its runtime and artifacts are declared by the platform runtime descriptor, physically bundled in that release set, and accepted for the descriptor's compatibility range by the platform Gate. Until all three conditions hold, that released policy omits the accelerator; a CPU-only release therefore resolves Auto to [cpu] rather than reporting a synthetic accelerator failure.
The platform runtime descriptor is the package-private immutable authority for candidate construction. It records schema version, Auto policy ID/version and ordered provider IDs; each provider entry records bundled artifacts and hashes, runtime/provider ABI, platform/architecture, compatibility-manifest identity and qualification ID. Release staging generates it from the files actually staged and fails if declarations and payload disagree. The facade/native loader reads this descriptor from the selected platform package; the model manifest does not own runtime capability, and runtime code never discovers candidates by scanning the host.
D112 is implemented in the current source candidate and changes the C++ and Node default from cpu to auto together; published 0.2.0 binaries retain their documented D111 behavior. Explicit provider=cpu|apple|webgpu|… bypasses the Auto list and attempts only the named backend. A known provider that is not delivered for the current platform fails pre-attempt validation as unsupported_capability; it never falls through to CPU and does not fabricate a D112 creation reason. sessionFallback is retained only as a migration field until removal: error is the sole valid value for both Auto and explicit modes, while sessionFallback=cpu always returns invalid_argument. This prevents the legacy field from acquiring a second meaning under Auto.
The first Auto surface accepts only the provider-neutral values sessionFallback=error, cpuPartition=allow, performanceHint=latency, precision=auto, and no deviceId. Precision is resolved inside each candidate. Strict partitioning, throughput tuning, an explicit precision, or a device ID requires an explicit provider; combining any of them with Auto returns invalid_argument before a factory call. cpuPartition remains a graph-placement constraint on accelerator sessions, while the final CPU candidate is the requested CPU backend rather than a graph partition.
The creation state machine is:
validate request and resolve requested provider
├─ explicit provider → atomically create detector + recognizer
│ ├─ success → freeze selected backend
│ └─ any failure → destroy partial state; return structured creation error
└─ auto → resolve policy ID/version and ordered bundled candidates
→ for each candidate
├─ both sessions created → record selected; freeze backend; stop
├─ skippable creation failure → destroy partial state; record skipped; continue
└─ fatal creation failure → destroy partial state; record fatal; return error
→ final CPU candidate failure → record fatal; return error with full trace
These four stable creation reasons permit Auto to continue only when another candidate remains:
| Reason code | Exact meaning |
|---|---|
adapter_unavailable | No compatible device/adapter exists for the candidate on the current host. A provider omitted from the release descriptor is not attempted and does not produce this reason. |
model_compute_unsupported | The candidate device or compute unit cannot create both required sessions for the locked model, shape, precision, and partition policy. |
device_memory_insufficient | During candidate session creation, the provider explicitly reports that required device memory cannot be satisfied. Host allocation failure, unknown OOM, std::bad_alloc, and resource leakage are not included. |
driver_version_unsupported | The detected driver is outside the version range locked by the bundled compatibility descriptor. |
These reasons are fatal and stop Auto immediately:
| Reason code | Exact meaning |
|---|---|
package_corrupt | A descriptor-declared runtime, model, plugin, or required dependency is absent, truncated, or structurally invalid. |
artifact_hash_mismatch | A bundled artifact does not match its locked digest. |
provider_abi_mismatch | Runtime, plugin, addon, or provider ABI versions are incompatible. |
internal_assertion_failed | A product/runtime invariant or internal assertion fails. |
unrecoverable_load_failed | Native loading fails after package integrity and compatibility checks and the failure is not one of the four skippable reasons. |
Failure classification is typed, not inferred from exception text. Descriptor presence, structure, hashes, ABI and driver range are classified by package/preflight validation; provider adapters may emit one of the four continue-eligible reasons only from an explicit provider/device status. Unknown exceptions and host allocation failures such as std::bad_alloc are terminal public errors outside the nine D112 reasons; they never become device_memory_insufficient. Unclassified native-load failures use unrecoverable_load_failed. Attempt status describes control flow, not a second taxonomy: a continue-eligible reason is skipped only when another candidate will actually be tried; the same reason on the final CPU candidate is fatal because creation terminates. The broad public ErrorCode remains separate from creationReason; callers decide whether Auto may continue only from a typed D112 reason plus candidate position, never by parsing message, detail, ORT/Core ML text, or a broad error code.
Engine::Run never changes backend. Device loss, driver reset, inference failure, and runtime OOM are returned to the caller; the caller may create a new engine under an explicit policy. Candidates that cannot be safely torn down after a failed attempt, or whose incompatible runtimes cannot be isolated, must not coexist in one Auto list.
Selection observability uses one immutable structured trace with requested provider, optional Auto policy ID/version, ordered candidates, attempts, and optional final selected backend. Each attempt has a provider and selected | skipped | fatal status. selected carries no failure field; skipped carries one of the four continue-eligible creationReason values; fatal carries any typed reason that terminates creation, or, for a terminal public error outside the D112 taxonomy, a stable errorCode with no fabricated reason. A successful EngineInfo contains zero or more skipped accelerator attempts followed by exactly one selected attempt; explicit success contains one selected candidate and no Auto policy. Engine creation failure has no EngineInfo, so the returned C++ creation error and mapped Node OcrError carry the same structured creationTrace: explicit factory failure contains one terminal fatal attempt, and Auto failure always ends in fatal, including failure of the final CPU candidate. Pre-attempt request/capability validation errors have no attempts. fatal is therefore never fabricated inside a successfully created engine.
The selected candidate owns a complete detector/recognizer pair. Partial success is never exposed as a mixed backend: if either session fails, all state for that candidate is destroyed before another candidate starts. Detector/recognizer actual provider, device, precision, model and CPU-partition facts remain per-session fields beneath the selection trace.
Reason: Platform users need one deterministic default that uses an accepted accelerator when available without turning runtime failures or damaged packages into silent CPU execution. Restricting fallback to creation-time auto, with a closed failure taxonomy, preserves reproducibility and makes every selection auditable.
Consequence: D112 is the single source of truth for cross-backend selection, failure classification, explicit-provider behavior, migration-field validation and selection traces on both success and creation failure. D111 remains authoritative for provider-neutral session diagnostics, graph partitioning, Apple internal routing, and the rule against runtime retry. Platform documents define backend-local qualification and routing but reference D112 instead of redefining fallback. Architecture and implementation-status documents change only after implementation lands.
D113 — Select Native WebGPU FP16 through immutable model variants
Status: Superseded by D114 for the public 0.3.0 execution contract
Decision: Linux x64 and Windows x64 Native WebGPU use the official ONNX Runtime 1.24.4 Plugin EP 0.1.0 runtime. Explicit provider=webgpu, precision=fp16 selects deterministic ONNX Runtime float16-derived detector and recognizer models with native float16 graph input/output and Extended graph optimization. The upstream FP32 models remain immutable and continue to serve CPU, explicit WebGPU FP32, and D112 Auto. WebGPU FP16 is therefore opt-in until device evidence justifies a separate Auto-policy change. The WebGPU Plugin EP exposes no provider option that toggles FP16; model tensor types select the precision and Dawn enables ShaderF16 only on a capable adapter.
The derived payload records its source hashes, converter/tool versions, output hashes, graph-I/O type, blocked-operation policy, and runtime contract. CI regenerates both models byte-for-byte and runs deterministic finite FP16 inference before packaging. Manifest schema 1.2 binds the derived payload to its FP32 sources and permits the Apple payload to coexist in one platform-independent npm model superset.
The current graphs require a bounded CPU partition containing only Concat, Gather, and Slice. cpuPartition=allow is the qualified WebGPU contract and ORT profiles must contain no other CPU operator. cpuPartition=forbid is still a meaningful fail-closed request: engine creation returns stable unsupported_capability before session creation rather than allowing ORT to fail after partial placement. Qualification compares CPU FP32, explicit WebGPU FP32, and explicit WebGPU FP16; it verifies strict rejection, FP16 quality/determinism/performance, placement, memory, cold starts, and lifecycle independently.
Reason: The Linux device report proved useful FP32 acceleration but also proved that three recognition operators cannot satisfy the old all-WebGPU strict expectation. A model-selected FP16 path is the upstream-supported mechanism, preserves the published FP32 bundle identity, and makes the unavoidable CPU partition explicit and reviewable.
Consequence: FP16 results cannot be generalized to Auto or to adapters without ShaderF16. Any change to conversion, retained FP32 operations, CPU operator allowlist, graph optimization, or source models creates a new immutable artifact/conversion ID and requires both platform reports again.
D114 — Ship Native WebGPU FP32 as the 0.3.0 public profile
Status: Accepted
Decision: Linux x64/Vulkan and Windows x64/D3D12 publish Native WebGPU with precision=auto|fp32; Auto also selects FP32. provider=webgpu, precision=fp16 is rejected as invalid_argument. The Precision enum and TypeScript union retain fp16 because Apple/Core ML uses it, but FP16 is not a WebGPU compatibility or performance promise in 0.3.0.
The locked FP16 ONNX derivations remain reproducible internal artifacts so existing manifest provenance and the native superset bundle stay deterministic. Their presence in a bundle does not make them a public execution profile. A future WebGPU FP16 release requires a new decision, explicit API/documentation change, and fresh cross-platform quality and performance evidence.
Reason: The final Linux and Windows FP32 reports each passed 164/164 Gates and all 14 quality fixtures matched the CPU FP32 baseline byte-for-byte. The experimental FP16 run did not meet that release quality bar, so publishing FP32 gives one consistent, qualified product contract across both WebGPU platforms.
Consequence: Qualification, report review, examples, release notes, and performance displays use only cpu, WebGPU FP32 allow, strict, and Auto. The required Concat, Gather, and Slice CPU partition remains explicit; cpuPartition=forbid continues to fail closed. No WebGPU FP16 speedup is advertised.
D106 — CLI surface, stdout/stderr, exit codes and JSON schema
Status: Accepted
Authority: N1 CLI, result contract, ROI and detect-only entry (roadmap §5); detailed design in cli-design.md
Decision: The light-ocr bin is exposed by the @arcships/light-ocr facade, so installing the facade yields the command with no second install entry. Since N2, its implementation lives in the shared runtime and each facade owns only a thin command wrapper. The CLI uses a three-subcommand structure rather than a flat flag surface, because N1's primary audience is Agents that resolve intent from a top-level verb before reading that verb's flags:
light-ocr recognize <path|--stdin> [flags] # default OCR: detection + recognition
light-ocr detect <path|--stdin> [flags] # detect only: boxes, no recognition
light-ocr info --model-info | --version # diagnostics, no image read
light-ocr image.png ... # implicit recognize
light-ocr image.png without a subcommand is equivalent to light-ocr recognize image.png, preserving first-use intuition. --model-info and --version are flags of the info subcommand and are mutually exclusive (the version triple is a subset of EngineInfo); this departs from roadmap §5.2 which lists them as top-level flags, in order to keep the verb structure uniform across recognize/detect/info. The semantics are equivalent.
First-version flags are restricted to what Agent and ordinary users actually need. Provider-internal fields are not exposed as CLI flags and are fixed by runtime defaults: sessionFallback (single-value enum, migration legacy), cpuPartition (provider-qualification detail), precision (fp16 is Apple-only and fails elsewhere), detectionStrategy (dev/parity strategy), maxSide (calibrated accuracy/speed tradeoff), and includeDiagnostics (developer-facing provider placement debug). --score-threshold and --no-color are retained as advanced flags relegated to the second help tier. detect does not expose --format at all: its output is always structured JSON (box quad + score, no text semantics), which structurally eliminates the --format text + detect failure path instead of reporting it at runtime.
stdout carries only machine results (json/jsonl/text, info JSON, version triple); stderr carries logs, warnings, progress and usage hints. --quiet suppresses non-error stderr without changing the stdout contract. --no-color controls stderr ANSI; non-TTY or NO_COLOR auto-disables color. Exit codes are a stable surface mapped to the existing OcrErrorCode taxonomy, additive-only after release:
| exit | category | trigger |
|---|---|---|
| 0 | success | normal output |
| 64 | usage | argument parse failure, missing file, mutex flag conflict |
| 65 | invalid_argument | ROI out of bounds, unsupported flag combo, unsupported --schema-version |
| 66 | invalid_image | image not decodable or unsupported format |
| 67 | unsupported_capability | capability not available in this build |
| 68 | model | invalid/unsupported/corrupt model bundle |
| 69 | resource_limit_exceeded | over pixel/memory/timeout limits |
| 70 | env/package | runtime init / package load / platform / adapter failure |
| 71 | inference_failed / postprocess_failed | inference or postprocess failure |
| 72 | internal_error | internal error or engine closed |
The CLI wraps results in a versioned DocumentResult envelope (schemaVersion, source, pages[]) over the existing single-image OcrResult. schemaVersion is an integer major version; additive fields keep v1, semantic changes require a new version. --schema-version 1 requests an exact schema and fails invalid_argument if unsupported (no silent downgrade). OcrLine.id (L{index}) is a CLI-envelope stable ID for future Layout region association. detect output replaces pages[0].lines with detections[] and sets structure: "detect"; --crop embeds per-box PNG crops as base64 in each detection (single-stream stdout contract forbids binary sidecars).
ROI is implemented in the adapter layer (after EXIF correction, before Core), keeping the Core boundary unchanged; out-of-bounds or partially-intersecting regions return invalid_argument without clamping. Flag validation (format, provider, schema-version, region) is performed before input reading so parameter errors surface before filesystem errors — an invalid --region with a nonexistent file returns exit 65, not 64. EXIF orientation uses a self-contained minimal JPEG APP1 parser (zero-dependency, stb-style); PNG eXIf is treated as no transform in v1. --no-exif disables correction while still recording the "not applied" state in appliedTransforms.
Reason: Roadmap §5.2 requires a small, stable command surface, but the draft had transparently mapped every ExecutionOptions/DetectionOptions field to a CLI flag, turning the CLI into a configuration panel. Restricting to Agent/user-facing flags keeps the entry layer honest, while the subcommand structure lets Agents resolve intent from the verb before loading that verb's flag set. The six removed flags either have single values, are provider-internal, or are developer-debug; none serve an Agent or ordinary user.
Consequence: This decision fixes the stable CLI surface for N1. Adding flags requires a new decision; exit codes are additive-only. The info subcommand departure from roadmap §5.2's top-level flag wording is recorded here. Core Engine::detect() signature and DetectionResult structure are implementation details resolved during implementation per cli-design.md §8, not a separate product decision.
D107 — Split model-free runtime from exact model facades
Status: Accepted and implemented Authority: N2 package topology and monorepo migration (roadmap §3.1, §3.4 and §6); directory and migration details in monorepo-design.md
Decision: @arcships/light-ocr-runtime owns the Node adapter, scheduler, encoded-image/native loading and public OCR types without carrying a default model. Direct runtime callers must pass an explicit local bundlePath; the runtime never accepts a model alias and never downloads a bundle. @arcships/light-ocr, @arcships/light-ocr-tiny and @arcships/light-ocr-medium are thin model facades that exact-pin one compatible runtime and one model package, reuse the runtime's OcrError identity and public engine/result types, and differ only in their built-in model resolution and package/CLI names. The default @arcships/light-ocr facade remains PP-OCRv6 Small and remains the only owner of the light-ocr bin.
Packages use independent versions. A facade release exact-pins its tested runtime, native and model combination; no caret, tilde, tag or workspace range enters a published manifest. The new runtime starts private in the workspace and may publish as a preview only after its standalone tarball, platform optional dependencies and model-free API pass install tests. Starting N2 does not itself authorize a 0.4.0 facade release; the minor version is cut only after the runtime topology is publishable and the default Small experience remains compatible.
Migration uses npm workspaces under packages/*. The non-package native CMake tree stays outside npm workspaces. The semantic cutover is complete: bindings/node/ is now only the native development harness, while runtime, Small, Tiny, and Medium sources live under packages/. Tiny and Medium publish only under next; promotion keeps them preview until comparative product evidence justifies GA.
Reason: Three model cups need one adapter, error model and result schema, while users must install only the model they selected. Separating model selection from runtime mechanics prevents three copies of the Node implementation and lets unchanged native/model packages be reused without rebuilding them for a facade-only change.
Consequence: Runtime/model/native packages can evolve independently, while each user-facing facade records an exact compatible composition. Model aliases are facade concerns; explicit bundlePath is the runtime escape hatch. Release acceptance requires exact locked bundles, one-model installation, shared contract tests, package integrity, and a representative real OCR smoke. Larger Pareto studies are promotion evidence, not repeated release-CI work; lack of that evidence keeps Tiny/Medium on next without blocking the completed N2 engineering cutover or N3.
D108 — PDF renderer selection: pdfium-native (N-API)
Status: Accepted Authority: S3 PDF 可行性 Spike (roadmap §8); PDF 渲染方案调研报告
Decision: 使用 pdfium-native(v0.6.1, MIT, N-API binding)作为 PDF 渲染方案。备选 clawpdf(PDFium WASM, 4.1MB)在 pdfium-native 暴露平台或 Node 版本问题时可用。
Reason:
- pdfium-native 是唯一满足 light-ocr 全部非协商原则的 N-API binding:
- 原生性能:N-API binding 比 WASM 快约 1.75-2.5x,内存占用更低
- 开箱即用:内置 PNG/JPG 渲染,无需 sharp 或 canvas
- 本地优先:进程内调用,无子进程、无网络
- 跨平台 prebuild:使用 bblanchon/pdfium-binaries,覆盖 macOS arm64/x64、Windows x64、Linux x64(light-ocr 全部 Tier 1 平台)
- 许可友好:MIT,PDFium 底层 Apache-2.0
- Node >=22:light-ocr 目标就是 Node 22/24,完全兼容
- 排除的方案:
- pdfjs-dist:34MB 包体积 + 需要 canvas 依赖 + 已知 Node.js 渲染 bug
- node-poppler:GPL-2.0 许可传染性 + 外部二进制依赖 + 子进程模式
- WASM 方案(clawpdf/@hyzyla/pdfium):性能不如原生,作为备选保留
Consequence:
- PDF 渲染层作为独立依赖和安全边界,复用现有 OCR 流程
- 页面渲染后转 PNG Buffer 传给现有
recognizeEncoded() - 坐标空间:PDF 渲染后使用
pageSpace(pixel),需要提供pdfSpace(point)到pageSpace的 affine transform - 集成方式:Document Layer 调用 pdfium-native → 渲染 PNG → 调用 OCR engine,流式输出 JSONL
- 风险缓解:
- pdfium-native 较新(9K 月下载量,0 GitHub Stars),需锁定版本使用
- 单一维护者风险,必要时可 fork 或切换到 clawpdf WASM 备选
- Spike 阶段需验证 PDFium 版本是否覆盖目标 PDF 特性(加密、表单、嵌入图片等)
D109 — Keep Document preview outside the stable image facade
Status: Superseded by D110
Authority: N3 package topology, D010 offline installation contract, D108 renderer selection
Decision: PDF and multi-page processing ships as the explicit
@arcships/light-ocr-document preview package with its own
light-ocr-document command. The stable @arcships/light-ocr facade remains
image-only and keeps its no-install-script, offline-installable release closure.
Reason: pdfium-native@0.6.1 uses an install script to download a
checksum-verified platform prebuild and falls back to a local compilation when
needed. Making it an optional dependency of the stable facade would still run
that script during a normal install and would contradict the stable package's
published installation contract. A separate package also matches the topology
already locked in the roadmap and CLI design.
Consequence:
- Document depends exactly on the current stable Small facade for its out-of-box CLI, while its Node API can reuse a caller-provided compatible engine.
- The Document package is published under
nextuntil G3 evidence is met; it is not promoted with the native/runtime/Small stable closure. npm may retain a registry-requiredlatestalias for a first-published package name, which does not change its Preview maturity or the documented@nextinstall. - Release gates keep the stable closure offline with scripts disabled, and add a separate supported-platform smoke that installs the Document package with scripts enabled, renders a real PDF, and runs OCR.
- PDF processing is local at runtime. Installation-time prebuild retrieval is disclosed rather than described as offline.
D110 — Bundle PDFium in the stable platform packages
Status: Accepted
Authority: Product installation contract, D010 offline installation contract,
D108 renderer selection
Decision: @arcships/light-ocr owns the PDF and multi-page API and CLI.
Each of the six existing platform npm packages carries the pinned
pdfium-native@0.6.1 addon and its adjacent PDFium shared library. The stable
facade has no install script and does not depend on the upstream
pdfium-native npm package. @arcships/light-ocr-document remains only as a
compatibility forwarder.
Reason: A separate Document install exposed an implementation detail and the upstream renderer package performed a second network download during postinstall. Users require PDF support to arrive in the npm installation itself, with no customer-machine fetch during installation or use. Reusing the already platform-specific native packages avoids shipping all six PDFium binaries to every user.
Consequence:
npm install @arcships/light-ocrinstalls the model, OCR runtime, and exactly one matching PDF renderer through ordinary npm package bytes.- The supported install path passes with
--ignore-scripts; runtime PDF smoke runs in a disabled network namespace. - Release CI, not the customer machine, obtains the checksum-pinned upstream prebuild, records its licenses/SBOM identity, and embeds the verified files in the immutable platform tarball.
- Omitting npm optional dependencies also omits both OCR and PDF native
execution; the error directs users to reinstall without
--omit=optional.
D115 — Keep external adoption non-blocking for 1.0
Status: Accepted
Authority: Product release policy and N5 stabilization
Decision: External user counts, third-party integration counts, download
metrics, and fixed community observation windows are not release gates for
1.0 or earlier roadmap nodes. Mandatory qualification must be reproducible
by maintainers through locked corpora, clean consumer projects, supported
platform runners, published-package smoke tests, and versioned reports.
Reason: Release readiness is the project's responsibility and must not depend on whether unrelated users publish their workflows or wait a prescribed number of days. External feedback remains useful, but it is neither deterministic nor fully auditable as a blocking check.
Consequence:
- N5 requires three maintained consumer scenarios across at least two public entry points instead of three externally controlled integrations.
- G1, G3, G4, and HC use predeclared internal evidence with no fixed external observation window.
- Community reports and adoption signals may reprioritize work or expose a blocker, but their absence cannot delay a release.
- Internal qualification demonstrates the documented contract; it must not be presented as proof of broad third-party adoption.
3. Deferred decisions
D102 — Public native SDK and ABI policy
Status: Deferred
Deferred items: C ABI, shared-library naming, symbol versioning, long-term ABI compatibility, external installation layout, and consumer package managers.
D103 — Additional model capabilities
Status: Deferred
Deferred items: orientation models, document preprocessing, layout, table, formula, and additional accelerator Execution Providers. PP-OCRv6 Tiny/Medium package topology is resolved by D107.
Each is a separately versioned capability or bundle and requires its own compatibility and resource policy.
D104 — External distribution operations
Status: Deferred
Deferred items: standalone non-npm model download destination, signing, macOS notarization, Windows code signing, long-term registry/package retention, and end-user support policy. npm package topology and built-in model behavior are resolved by D105; public 0.1.0 publication and its release gates are recorded in releases/npm-0.1.0.md.
4. Bootstrap and evidence records
These are not unresolved semantic choices. Their current completion state is tracked in implementation-status.md:
| Record | Repository authority | Current state |
|---|---|---|
| Native dependency lock | models/deps.lock.json | Complete and exercised by the latest Tier 1 CI run |
| Toolchain/CI identity | .github/workflows/core.yml plus generated build manifest | Latest six-job Core workflow passed on Linux, Windows, and both macOS architectures |
| Bundle lock and archive | models/bundles.lock.json, generated bundle and USTAR checksum | Hash complete; npm model staging and public registry evidence recorded for 0.1.0 |
| Oracle environment lock | oracle/oracle.lock.json, oracle/requirements.lock | Complete and locally exercised |
| Corpus manifest | corpus/sources.lock.json, contracts.json, fixture manifests | Complete for current 14-fixture parity corpus; clean regeneration and contract evidence are verified |
| Benchmark declaration/report | oracle/run_benchmark.py, generated benchmark report | Local reference run and controlled Linux oracle CI passed; final release report must be regenerated after D013 |
| Ground-truth report | corpus/ground-truth.lock.json, generated quality report | Complete local first-bundle text/detection baseline with pixel-bound independent boxes |
The C++ implementation and npm 0.1.0 release evidence are complete. Deferred C ABI/install layout and external distribution operations remain separate future decisions.