From 1ea295c9e71fa3721989ecb77b87dd0f174b39bc Mon Sep 17 00:00:00 2001 From: eric8810 Date: Wed, 15 Jul 2026 17:19:25 +0800 Subject: [PATCH 1/9] =?UTF-8?q?feat(runtime):=20=E6=98=9F=E6=A1=A5?= =?UTF-8?q?=E5=88=9D=E6=9E=B6=EF=BC=8C=E5=8F=8C=E8=B7=AF=E6=8E=A8=E7=90=86?= =?UTF-8?q?=E5=90=84=E6=98=8E=E5=BD=92=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 建立 provider-neutral InferenceSession,并为检测与识别 session 分别记录 provider、模型哈希、精度、shape、runtime、cache 与 fallback。 保持 CPU/FP32 默认行为和旧 executionProvider 字段不变;未资格审查的加速器与无效策略组合稳定失败。 Refs #4 --- bindings/node/README.md | 10 +- bindings/node/js/index.d.ts | 51 +++++++ bindings/node/src/addon.cpp | 184 +++++++++++++++++++++++++- bindings/node/test/adapter.test.cjs | 61 +++++++++ docs/apple-device-acceleration.md | 2 + docs/architecture.md | 8 +- docs/decisions.md | 9 +- docs/implementation-status.md | 4 +- docs/napi-design.md | 63 ++++++++- docs/native-api.md | 60 ++++++++- include/light_ocr/types.hpp | 55 ++++++++ src/core/engine.cpp | 60 +++++++-- src/inference/backend.hpp | 61 +++++++++ src/inference/onnxruntime/backend.cpp | 82 +++++++++--- src/inference/onnxruntime/backend.hpp | 38 ++---- src/model/bundle_data.hpp | 4 + src/model/model_bundle.cpp | 21 ++- tests/integration/main.cpp | 51 ++++++- tools/stage_probe/main.cpp | 17 ++- 19 files changed, 759 insertions(+), 82 deletions(-) create mode 100644 src/inference/backend.hpp diff --git a/bindings/node/README.md b/bindings/node/README.md index ebb60c4..ec4eee9 100644 --- a/bindings/node/README.md +++ b/bindings/node/README.md @@ -1,6 +1,6 @@ # light-ocr Node-API adapter -状态:`@arcships/light-ocr@0.2.0` 已发布;tiled detection 和内存 JPEG/PNG 输入可用。macOS arm64/x64、Linux x64 glibc、Windows x64 的 Node.js 22/24 package matrix、真实 PP-OCRv6 和禁网运行均已通过。 +状态:`@arcships/light-ocr@0.2.0` 已发布;当前源码已加入尚未发布的 Perf-1A execution contract。tiled detection 和内存 JPEG/PNG 输入可用,默认推理仍为 CPU。 推荐直接安装公开 package: @@ -21,6 +21,7 @@ npm install @arcships/light-ocr - 支持 `AbortSignal` 协作式取消:queued 请求会从队列移除;running 请求立即拒绝 public Promise,但 Core 会安全运行到返回并丢弃结果。 - native addon 只接收现有绝对 bundle 目录。当前源码开发调用显式传 `bundlePath`;发布后的 facade 默认使用随 npm 安装的 model package 路径。 - 产品 engine 默认报告 `detectionStrategy: 'bounded'`、`detectionMaxSide: 960` 和 `defaultRecognitionBatchSize: 1`。0.2.0 可通过 `detection: {strategy: 'tiled'}` 显式选择 `tiled-v1`;`upstreamExact` 只用于上游对照,单次 `recognize({detectionMaxSide})` 只能继续降低 bounded engine 的 side。 +- `createEngine({execution})` 和 `engine.info.execution.sessions` 已提供 provider-neutral 策略与 detector/recognizer 分阶段执行摘要。当前只接受 CPU/FP32;CoreML 等名称尚未进入公开 union,不能据 provider 注册推断设备 placement。 不支持 WebP、GIF、PDF、EXIF orientation 自动旋转、zero-copy/transfer、运行中 inference 硬中断、Electron 或 Bun。详细契约见 [Node-API 设计](../../docs/napi-design.md)。 @@ -65,7 +66,12 @@ node --test --test-concurrency=1 bindings/node/test/adapter.test.cjs ```js const { createEngine, OcrError } = require('@arcships/light-ocr'); -const engine = await createEngine({ queueCapacity: 4 }); +const engine = await createEngine({ + queueCapacity: 4, + execution: { provider: 'cpu', precision: 'auto' }, +}); + +console.log(engine.info.execution.sessions.detection.actualProviderChain); ``` 当前源码开发用法仍需显式 bundle: diff --git a/bindings/node/js/index.d.ts b/bindings/node/js/index.d.ts index 8534f85..a71f8e6 100644 --- a/bindings/node/js/index.d.ts +++ b/bindings/node/js/index.d.ts @@ -3,12 +3,27 @@ export type PixelFormat = 'gray8' | 'rgb8' | 'bgr8' | 'rgba8'; export type DetectionStrategy = 'bounded' | 'tiled' | 'upstreamExact'; export type BuiltInModel = 'ppocrv6-small'; +export type ExecutionProvider = 'cpu'; +export type SessionFallback = 'error' | 'cpu'; +export type CpuPartition = 'allow' | 'forbid'; +export type PerformanceHint = 'latency' | 'throughput'; +export type Precision = 'auto' | 'fp32' | 'fp16'; export interface DetectionOptions { readonly strategy?: DetectionStrategy; readonly maxSide?: number; } +export interface ExecutionOptions { + /** Only providers shipped and qualified by this release appear in this union. */ + readonly provider?: ExecutionProvider; + readonly sessionFallback?: SessionFallback; + readonly cpuPartition?: CpuPartition; + readonly deviceId?: number; + readonly performanceHint?: PerformanceHint; + readonly precision?: Precision; +} + export interface RawImage { readonly data: Uint8Array; readonly width: number; @@ -43,6 +58,7 @@ export interface CreateEngineOptions { readonly queueCapacity?: number; readonly maxPendingInputBytes?: number; readonly detection?: DetectionOptions; + readonly execution?: ExecutionOptions; } export interface RecognizeOptions { @@ -121,13 +137,48 @@ export interface TiledDetectionInfo { readonly mergeIouThreshold: 0.5; readonly mergeIosThreshold: 0.8; } +export interface ProviderCapabilityInfo { + readonly provider: string; + readonly packageIncluded: boolean; + readonly deviceAvailable: boolean; +} +export interface SessionExecutionInfo { + readonly requestedProvider: string; + readonly actualProviderChain: readonly string[]; + readonly device: string; + readonly precision: string; + readonly shapePolicy: string; + readonly modelId: string; + readonly modelSha256: string; + readonly runtime: string; + readonly runtimeVersion: string; + readonly providerVersion: string; + readonly modelCacheStatus: string; + readonly sessionFallback: boolean; + readonly fallbackReason?: string; +} +export interface ExecutionInfo { + readonly requestedProvider: ExecutionProvider; + readonly sessionFallback: SessionFallback; + readonly cpuPartition: CpuPartition; + readonly deviceId?: number; + readonly performanceHint: PerformanceHint; + readonly requestedPrecision: Precision; + readonly providerCapabilities: readonly ProviderCapabilityInfo[]; + readonly sessions: { + readonly detection: SessionExecutionInfo; + readonly recognition: SessionExecutionInfo; + }; +} export interface EngineInfo { readonly coreVersion: string; readonly modelBundleId: string; readonly modelBundleSchemaVersion: string; readonly normalizedConfigSchemaVersion: string; readonly backend: string; + /** @deprecated Use execution.sessions for stage-specific provider details. */ readonly executionProvider: string; + readonly execution: ExecutionInfo; readonly capabilities: { readonly detection: boolean; readonly recognition: boolean; diff --git a/bindings/node/src/addon.cpp b/bindings/node/src/addon.cpp index e87d450..dd975ce 100644 --- a/bindings/node/src/addon.cpp +++ b/bindings/node/src/addon.cpp @@ -346,12 +346,81 @@ DetectionOptions parse_detection_options(napi_env env, napi_value value) { return parsed; } +ExecutionProvider parse_execution_provider(napi_env env, napi_value value) { + const auto provider = get_string(env, value, "execution.provider"); + if (provider == "cpu") return ExecutionProvider::cpu; + throw AddonFailure( + "invalid_argument", + "execution.provider must be cpu; accelerator providers are not qualified in this release"); +} + +SessionFallback parse_session_fallback(napi_env env, napi_value value) { + const auto fallback = get_string(env, value, "execution.sessionFallback"); + if (fallback == "error") return SessionFallback::error; + if (fallback == "cpu") return SessionFallback::cpu; + throw AddonFailure("invalid_argument", + "execution.sessionFallback must be error or cpu"); +} + +CpuPartition parse_cpu_partition(napi_env env, napi_value value) { + const auto partition = get_string(env, value, "execution.cpuPartition"); + if (partition == "allow") return CpuPartition::allow; + if (partition == "forbid") return CpuPartition::forbid; + throw AddonFailure("invalid_argument", + "execution.cpuPartition must be allow or forbid"); +} + +PerformanceHint parse_performance_hint(napi_env env, napi_value value) { + const auto hint = get_string(env, value, "execution.performanceHint"); + if (hint == "latency") return PerformanceHint::latency; + if (hint == "throughput") return PerformanceHint::throughput; + throw AddonFailure("invalid_argument", + "execution.performanceHint must be latency or throughput"); +} + +Precision parse_precision(napi_env env, napi_value value) { + const auto precision = get_string(env, value, "execution.precision"); + if (precision == "auto") return Precision::automatic; + if (precision == "fp32") return Precision::fp32; + if (precision == "fp16") return Precision::fp16; + throw AddonFailure("invalid_argument", + "execution.precision must be auto, fp32, or fp16"); +} + +ExecutionOptions parse_execution_options(napi_env env, napi_value value) { + require_object(env, value, "execution"); + const std::unordered_set allowed{ + "provider", "sessionFallback", "cpuPartition", "deviceId", + "performanceHint", "precision"}; + reject_unknown_properties(env, value, allowed, "execution"); + ExecutionOptions parsed; + if (const auto option = optional_named(env, value, "provider")) { + parsed.provider = parse_execution_provider(env, *option); + } + if (const auto option = optional_named(env, value, "sessionFallback")) { + parsed.session_fallback = parse_session_fallback(env, *option); + } + if (const auto option = optional_named(env, value, "cpuPartition")) { + parsed.cpu_partition = parse_cpu_partition(env, *option); + } + if (const auto option = optional_named(env, value, "deviceId")) { + parsed.device_id = get_u32(env, *option, "execution.deviceId"); + } + if (const auto option = optional_named(env, value, "performanceHint")) { + parsed.performance_hint = parse_performance_hint(env, *option); + } + if (const auto option = optional_named(env, value, "precision")) { + parsed.precision = parse_precision(env, *option); + } + return parsed; +} + ParsedCreateOptions parse_create_options(napi_env env, napi_value value) { require_object(env, value, "createEngine options"); const std::unordered_set allowed{ "bundlePath", "intraOpThreads", "interOpThreads", "recognitionScoreThreshold", "recognitionBatchSize", "reducedLimits", - "queueCapacity", "maxPendingInputBytes", "detection"}; + "queueCapacity", "maxPendingInputBytes", "detection", "execution"}; reject_unknown_properties(env, value, allowed, "createEngine options"); if (!has_own(env, value, "bundlePath")) { throw AddonFailure("invalid_argument", "bundlePath is required"); @@ -385,6 +454,9 @@ ParsedCreateOptions parse_create_options(napi_env env, napi_value value) { if (const auto option = optional_named(env, value, "detection")) { parsed.core.detection = parse_detection_options(env, *option); } + if (const auto option = optional_named(env, value, "execution")) { + parsed.core.execution = parse_execution_options(env, *option); + } if (const auto option = optional_named(env, value, "queueCapacity")) { parsed.queue_capacity = get_u32(env, *option, "queueCapacity", 1); if (parsed.queue_capacity > kMaximumQueueCapacity) { @@ -1166,6 +1238,115 @@ napi_value create_resource_limits(napi_env env, const ResourceLimits& limits) { return object; } +const char* execution_provider_string(ExecutionProvider provider) { + return provider == ExecutionProvider::cpu ? "cpu" : "unknown"; +} + +const char* session_fallback_string(SessionFallback fallback) { + return fallback == SessionFallback::cpu ? "cpu" : "error"; +} + +const char* cpu_partition_string(CpuPartition partition) { + return partition == CpuPartition::forbid ? "forbid" : "allow"; +} + +const char* performance_hint_string(PerformanceHint hint) { + return hint == PerformanceHint::throughput ? "throughput" : "latency"; +} + +const char* precision_string(Precision precision) { + if (precision == Precision::fp32) return "fp32"; + if (precision == Precision::fp16) return "fp16"; + return "auto"; +} + +napi_value create_session_execution_info(napi_env env, + const SessionExecutionInfo& info) { + napi_value object = nullptr; + check(env, napi_create_object(env, &object), "create session execution info"); + set_named(env, object, "requestedProvider", + string_value(env, info.requested_provider)); + napi_value providers = nullptr; + check(env, + napi_create_array_with_length(env, info.actual_provider_chain.size(), + &providers), + "create actual provider chain"); + for (std::size_t index = 0; index < info.actual_provider_chain.size(); ++index) { + check(env, + napi_set_element(env, providers, static_cast(index), + string_value(env, info.actual_provider_chain[index])), + "set actual provider chain entry"); + } + set_named(env, object, "actualProviderChain", providers); + set_named(env, object, "device", string_value(env, info.device)); + set_named(env, object, "precision", string_value(env, info.precision)); + set_named(env, object, "shapePolicy", string_value(env, info.shape_policy)); + set_named(env, object, "modelId", string_value(env, info.model_id)); + set_named(env, object, "modelSha256", string_value(env, info.model_sha256)); + set_named(env, object, "runtime", string_value(env, info.runtime)); + set_named(env, object, "runtimeVersion", + string_value(env, info.runtime_version)); + set_named(env, object, "providerVersion", + string_value(env, info.provider_version)); + set_named(env, object, "modelCacheStatus", + string_value(env, info.model_cache_status)); + set_named(env, object, "sessionFallback", + boolean_value(env, info.session_fallback)); + if (info.fallback_reason) { + set_named(env, object, "fallbackReason", + string_value(env, *info.fallback_reason)); + } + return object; +} + +napi_value create_execution_info(napi_env env, const ExecutionInfo& info) { + napi_value object = nullptr; + check(env, napi_create_object(env, &object), "create execution info"); + set_named(env, object, "requestedProvider", + string_value(env, execution_provider_string(info.requested_provider))); + set_named(env, object, "sessionFallback", + string_value(env, session_fallback_string(info.session_fallback))); + set_named(env, object, "cpuPartition", + string_value(env, cpu_partition_string(info.cpu_partition))); + if (info.device_id) { + set_named(env, object, "deviceId", uint32_value(env, *info.device_id)); + } + set_named(env, object, "performanceHint", + string_value(env, performance_hint_string(info.performance_hint))); + set_named(env, object, "requestedPrecision", + string_value(env, precision_string(info.requested_precision))); + + napi_value capabilities = nullptr; + check(env, + napi_create_array_with_length(env, info.provider_capabilities.size(), + &capabilities), + "create provider capabilities"); + for (std::size_t index = 0; index < info.provider_capabilities.size(); ++index) { + const auto& capability = info.provider_capabilities[index]; + napi_value entry = nullptr; + check(env, napi_create_object(env, &entry), "create provider capability"); + set_named(env, entry, "provider", string_value(env, capability.provider)); + set_named(env, entry, "packageIncluded", + boolean_value(env, capability.package_included)); + set_named(env, entry, "deviceAvailable", + boolean_value(env, capability.device_available)); + check(env, + napi_set_element(env, capabilities, static_cast(index), + entry), + "set provider capability"); + } + set_named(env, object, "providerCapabilities", capabilities); + + napi_value sessions = nullptr; + check(env, napi_create_object(env, &sessions), "create execution sessions"); + set_named(env, sessions, "detection", + create_session_execution_info(env, info.detection)); + set_named(env, sessions, "recognition", + create_session_execution_info(env, info.recognition)); + set_named(env, object, "sessions", sessions); + return object; +} + napi_value create_engine_info(napi_env env, const EngineState& engine) { const auto& info = engine.info; napi_value object = nullptr; @@ -1178,6 +1359,7 @@ napi_value create_engine_info(napi_env env, const EngineState& engine) { string_value(env, info.normalized_config_schema_version)); set_named(env, object, "backend", string_value(env, info.backend)); set_named(env, object, "executionProvider", string_value(env, info.execution_provider)); + set_named(env, object, "execution", create_execution_info(env, info.execution)); napi_value capabilities = nullptr; check(env, napi_create_object(env, &capabilities), "create capabilities"); set_named(env, capabilities, "detection", boolean_value(env, info.capabilities.detection)); diff --git a/bindings/node/test/adapter.test.cjs b/bindings/node/test/adapter.test.cjs index 59b3bfa..0035ce8 100644 --- a/bindings/node/test/adapter.test.cjs +++ b/bindings/node/test/adapter.test.cjs @@ -108,8 +108,41 @@ test('loads PP-OCRv6, snapshots pixels, maps results, and closes idempotently', assert.equal(engine.info.limits.maxDetectionTiles, 100); assert.equal(engine.info.capabilities.tiledDetection, true); assert.equal(engine.info.tiledDetection, undefined); + assert.equal(engine.info.executionProvider, 'CPUExecutionProvider'); + assert.equal(engine.info.execution.requestedProvider, 'cpu'); + assert.equal(engine.info.execution.sessionFallback, 'error'); + assert.equal(engine.info.execution.cpuPartition, 'allow'); + assert.equal(engine.info.execution.performanceHint, 'latency'); + assert.equal(engine.info.execution.requestedPrecision, 'auto'); + assert.deepEqual(engine.info.execution.providerCapabilities, [{ + provider: 'cpu', + packageIncluded: true, + deviceAvailable: true, + }]); + assert.deepEqual( + engine.info.execution.sessions.detection.actualProviderChain, + ['CPUExecutionProvider'], + ); + assert.deepEqual( + engine.info.execution.sessions.recognition.actualProviderChain, + ['CPUExecutionProvider'], + ); + assert.equal( + engine.info.execution.sessions.detection.modelId, + 'PP-OCRv6_small_det_onnx', + ); + assert.equal( + engine.info.execution.sessions.recognition.modelId, + 'PP-OCRv6_small_rec_onnx', + ); + assert.match(engine.info.execution.sessions.detection.modelSha256, /^[a-f0-9]{64}$/); + assert.equal(engine.info.execution.sessions.detection.precision, 'fp32'); + assert.equal(engine.info.execution.sessions.detection.shapePolicy, 'dynamic'); + assert.equal(engine.info.execution.sessions.detection.sessionFallback, false); + assert.equal(engine.info.execution.sessions.detection.fallbackReason, undefined); assert.ok(Object.isFrozen(engine.info)); assert.ok(Object.isFrozen(engine.info.adapter)); + assert.ok(Object.isFrozen(engine.info.execution.sessions.detection)); const image = loadFixture('generated-hello-123'); const storage = Buffer.alloc(image.data.length + 31); @@ -261,6 +294,34 @@ test('validates input and reports adapter errors as OcrError', async () => { createEngine({ bundlePath: path.join(repositoryRoot, 'models/does-not-exist') }), (error) => error instanceof OcrError && error.code === 'bundle_io_failed', ); + await assert.rejects( + createEngine({ bundlePath, execution: { provider: 'coreml' } }), + (error) => error instanceof OcrError && error.code === 'invalid_argument', + ); + await assert.rejects( + createEngine({ bundlePath, execution: { precision: 'fp16' } }), + (error) => error instanceof OcrError && error.code === 'invalid_argument', + ); + await assert.rejects( + createEngine({ bundlePath, execution: { deviceId: 0 } }), + (error) => error instanceof OcrError && error.code === 'invalid_argument', + ); + await assert.rejects( + createEngine({ bundlePath, execution: { sessionFallback: 'cpu' } }), + (error) => error instanceof OcrError && error.code === 'invalid_argument', + ); + await assert.rejects( + createEngine({ bundlePath, execution: { misspelledOption: true } }), + (error) => error instanceof OcrError && error.code === 'invalid_argument', + ); + + const explicitCpu = await createEngine({ + bundlePath, + execution: { provider: 'cpu', precision: 'fp32' }, + }); + assert.equal(explicitCpu.info.execution.requestedPrecision, 'fp32'); + assert.equal(explicitCpu.info.execution.sessions.detection.precision, 'fp32'); + await explicitCpu.close(); const engine = await createEngine({ bundlePath }); const image = loadFixture('generated-blank'); diff --git a/docs/apple-device-acceleration.md b/docs/apple-device-acceleration.md index e1f747e..be3d29b 100644 --- a/docs/apple-device-acceleration.md +++ b/docs/apple-device-acceleration.md @@ -6,6 +6,8 @@ 范围:以 macOS Apple Silicon 为当前交付目标;iPhone/iPad 只保留架构兼容性,不在当前 Tier 1 平台承诺内 +实施状态:Perf-1A 基础已完成。Core 通过 provider-neutral `InferenceSession` 运行 detector/recognizer,公共 `execution` 策略和逐 stage `EngineInfo.execution.sessions` 已建立;默认仍为 ONNX Runtime CPU。Direct CoreML bridge、Apple capability manifest、FP16 模型派生物和资格审查工具尚未实现,不能请求或宣称 Apple 加速。 + 关联 Roadmap:[Perf-0–Perf-4](roadmap.md#7-perf-0perf-4--性能与宿主加速线) ## 1. 结论 diff --git a/docs/architecture.md b/docs/architecture.md index e745ddb..825c8c1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -28,7 +28,7 @@ flowchart TB Request["Request context"] Image["Image validation and conversion"] DetPre["Detection preprocess"] - Backend["ONNX Runtime boundary"] + Backend["Provider-neutral inference boundary"] DetPost["DB postprocess"] Geometry["Geometry and polygon operations"] Crop["Sort and perspective crop"] @@ -109,9 +109,9 @@ Conversion and normalization are separate functions so tensor input can be compa Location: `src/inference/` -The internal `OnnxSession` boundary accepts a float vector and shape, validates storage size with checked arithmetic, invokes ONNX Runtime CPU Execution Provider, and copies a validated float tensor result into Core-owned storage. No backend type appears in a public header. +The internal `InferenceSession` boundary accepts a float vector and shape, validates storage size with checked arithmetic, and returns a lifetime-owning validated float tensor view. `OnnxSession` implements that boundary with the bundled ONNX Runtime CPU Execution Provider; future qualified backends implement the same contract. No backend type appears in a public header. -The interface owns no OCR algorithm. Session input and output names are discovered at creation, then checked against the bundle contract. +The interface owns no OCR algorithm. Each session exposes immutable execution metadata separately for detector and recognizer, including requested/actual provider chain, model hash, precision, shape policy, runtime/cache, and fallback status. Provider-chain configuration is not treated as proof of per-node accelerator placement. ### 3.6 Detection postprocessing @@ -187,7 +187,7 @@ One recognition call executes: 7. Run DB postprocessing and restore coordinates. 8. Sort boxes and create lightweight recognition batch plans from box geometry. 9. Crop and normalize only the current recognition batch. -10. Run recognition inference and decode directly from the owning ORT output view. +10. Run recognition inference and decode directly from the owning backend output view. 11. Release the current crop/input/output, then continue; restore original line order by index. 12. Filter, assemble diagnostics, and validate the public result. 13. Release request memory and admission. diff --git a/docs/decisions.md b/docs/decisions.md index 55723d6..f4a99ed 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -126,6 +126,13 @@ Decision: The public entry is `@arcships/light-ocr`. It has one exact-version no 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; CPU implementation complete, provider qualification pending
+Decision: `EngineOptions.execution` owns the stable provider policy. The default remains `cpu` with `sessionFallback=error`, `cpuPartition=allow`, `performanceHint=latency`, and `precision=auto`. A release exposes only providers that are bundled and have passed the Provider Gate; therefore the current TypeScript `ExecutionProvider` union contains only `cpu`. Unsupported provider, device, precision, partition, fallback, or performance combinations return `invalid_argument` rather than being ignored. `EngineInfo.execution.sessions` reports detection and recognition independently, including requested provider, actual configured provider chain, device, effective precision, shape policy, model identity/hash, runtime/provider version, cache status, and session fallback. The legacy aggregate `executionProvider` remains as a compatibility field while callers migrate.
+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 and the ONNX Runtime CPU session is its first implementation. CoreML, DirectML, OpenVINO, CUDA, QNN, `auto`, CPU partition prohibition, and throughput profiles remain unavailable until a provider-specific D111 addendum locks descriptors, distribution, qualification devices, and Gate ceilings. Runtime inference errors never trigger an undeclared CPU retry. + ## 3. Deferred decisions ### D102 — Public native SDK and ABI policy @@ -136,7 +143,7 @@ Deferred items: C ABI, shared-library naming, symbol versioning, long-term ABI c ### D103 — Additional model capabilities Status: Deferred -Deferred items: PP-OCRv6 tiny/medium, orientation models, document preprocessing, layout, table, formula, and accelerator Execution Providers. +Deferred items: PP-OCRv6 tiny/medium, orientation models, document preprocessing, layout, table, formula, and shipping accelerator Execution Providers. The provider-neutral Perf-1A contract is accepted by D111; it does not publish an accelerator. Each is a separately versioned capability or bundle and requires its own compatibility and resource policy. diff --git a/docs/implementation-status.md b/docs/implementation-status.md index 6e1fb6e..d1704f1 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -1,6 +1,6 @@ # C++ Core 与 Node-API 实施状态 -更新时间:2026-07-14 +更新时间:2026-07-15
结论:`@arcships/light-ocr@0.2.0` 已发布并提升为 npm `latest`。它包含 `tiled-v1`、schema 1.2 bundle、八张独立 ground truth、Python oracle、确定性/质量门禁,以及 Node.js 内存 JPEG/PNG 输入;四平台 Core/Node baseline、无 benchmark release preflight、六包 provenance、registry integrity 与禁网运行证据均已保存。0.1.0 及其 bounded/960 行为保持不变,bounded/960 在 0.2.0 中也仍是默认策略。 状态含义: @@ -25,6 +25,7 @@ | 无 network/shell/cwd/locale 运行依赖 | Done | sterile cwd/minimal env 与 Linux network namespace disabled 测试通过;npm release 另完成已安装 package 的禁网运行。 | | manifest、hash、licenses、SBOM、parity、benchmark | Done | Release commit 已重新生成并保存四平台 metadata、六个 npm tarballs 的 hashes/integrity、parity、quality 与 benchmark 证据。 | | N-API/npm 非本 Core milestone | Done / `0.2.0` published | raw Node-API v8、CJS/ESM、`.d.ts`、内置模型解析、四平台 prebuild、双重背压、AbortSignal 与生命周期均已完成;[npm release run 29340467784](https://github.com/arcships/light-ocr/actions/runs/29340467784) 与 [promotion run 29342178842](https://github.com/arcships/light-ocr/actions/runs/29342178842) 保存六包发布、registry 和禁网证据。 | +| Perf-1A execution contract | Done(本地,未发布 accelerator) | provider-neutral `InferenceSession`、`EngineOptions.execution`、detector/recognizer 分 stage `EngineInfo.execution.sessions`、模型 hash/runtime/cache/fallback 诊断和 Node deep-frozen 映射已完成;当前 union 仅含 CPU,Apple/CoreML payload 与 placement qualification 仍 pending。 | | Node.js JPEG/PNG 内存输入 | Done / `0.2.0` published | `recognizeEncoded(Uint8Array)` 在 engine worker 上使用固定 stb revision 解码,保持 Core raw-pixel 边界;格式、尺寸、pixels、临时内存、queue/snapshot budget、AbortSignal 与 `timingUs.decode` 均有四平台 Node 22/24 package 测试。 | | 高分辨率峰值内存 | Done | Release 原生独立进程本机参考:2048² 空白 `318.8 MiB ≤ 384 MiB`;xfund 密集表单 116 框 `400.5 MiB ≤ 640 MiB`。四平台 release jobs 的真实模型与 RSS gates 均通过。 | | Tiled 高分辨率准确模式 | Done / `0.2.0` published | 1280 tile、2048→4-pass row-major、全局 candidate ceiling、IoU/IOS greedy merge、原图 recognition、C++/Node contract、8-fixture/196-line corpus、独立 oracle、四平台 36-entry accepted baseline 与 package smoke 均已完成。 | @@ -45,6 +46,7 @@ | offline contract | sterile cwd/minimal locale environment passed | | model archive | 已发布 `.1`:31,334,400 bytes / `74e246bf…de17`;已发布 tiled `.2`:31,334,400 bytes / `e543b93b…712f` | | Node-API v1 | Node.js 22.13.0;macOS arm64 Release/Werror 构建;CTest 3/3;bounded/exact 映射、真实 PP-OCRv6 API、snapshot/byteOffset、校验、symlink root、双重背压、abort、heartbeat、close/worker teardown 测试通过 | +| Perf-1A local validation | macOS arm64 Release/Werror 构建;Release CTest 22/22(含 15 项 acceptance、2 项 canonical oracle、4 项 memory)和 Node-enabled CTest 3/3;CPU 默认结果不变,逐 session execution summary、未知 provider、FP16、device ID 和无效 fallback 组合均有 C++/Node integration 覆盖 | | Tiled corpus | 八张 2048² locked fixtures 共 196 行:196 TP / 0 FP / 0 FN、CER 0、duplicate line 0;独立 oracle 与原生 pass tensor、candidate source、suppression、representative、crop、decode 和 final order 对齐;side override、tile ceiling、global candidate ceiling 均返回稳定错误 | | Tiled qualification | [run 29336329115](https://github.com/arcships/light-ocr/actions/runs/29336329115) 四个平台采样 jobs 成功;36 个 Core/Node 22/Node 24 entries 已受审。各平台最大 Core/Node 峰值:Linux x64 639.7/715.6 MiB、Windows x64 616.1/667.5 MiB、macOS arm64 667.4/733.6 MiB、macOS x64 623.1/672.8 MiB | diff --git a/docs/napi-design.md b/docs/napi-design.md index ab15475..5f4485f 100644 --- a/docs/napi-design.md +++ b/docs/napi-design.md @@ -1,10 +1,10 @@ # light-ocr Node-API 适配器设计 -状态:`@arcships/light-ocr@0.2.0` 已发布;tiled mapping 与内存 JPEG/PNG 输入可用
-更新时间:2026-07-14 +状态:`@arcships/light-ocr@0.2.0` 已发布;Perf-1A execution contract 已实现、尚未发布 accelerator
+更新时间:2026-07-15
Authority:JavaScript/TypeScript API、异步调度、内存所有权、Node.js 生命周期与 npm 布局 Core contract:[native-api.md](native-api.md) -Decision:[decisions.md](decisions.md) D101、D105 +Decision:[decisions.md](decisions.md) D101、D105、D111 `DetectionStrategy: "tiled"` 的 additive Node types、diagnostics 和 runtime identity,以及 `recognizeEncoded()` JPEG/PNG 内存输入均已随 0.2.0 发布。tiled 算法与 lockstep 发布证据见 [Tiled Detection 技术设计与验收规格](tiled-design-and-acceptance.md)和 [npm 0.2.0 发布记录](releases/npm-0.2.0.md)。这些能力不属于不可变的 `0.1.0` API。 @@ -43,7 +43,7 @@ Decision:[decisions.md](decisions.md) D101、D105 - install/postinstall 或运行时网络下载、默认目录扫描或模型自动更新。 - 无模型瘦包、按语言拆分模型、tiny/medium/orientation 模型。 - 对运行中的 ONNX Runtime inference 做硬中断或强制超时终止。 -- GPU/Metal/CUDA/DirectML Execution Provider。 +- 发布 GPU/ANE/CUDA/DirectML Execution Provider;provider-neutral 配置与诊断契约已由 D111 接受,但当前只允许 CPU。 - Electron、Bun、Deno 或浏览器支持声明。 - Linux musl、Linux arm64、Windows arm64。 - 跨进程共享 engine、跨 Node.js Environment 传递 engine。 @@ -86,6 +86,21 @@ export interface DetectionOptions { readonly maxSide?: number; } +export type ExecutionProvider = "cpu"; +export type SessionFallback = "error" | "cpu"; +export type CpuPartition = "allow" | "forbid"; +export type PerformanceHint = "latency" | "throughput"; +export type Precision = "auto" | "fp32" | "fp16"; + +export interface ExecutionOptions { + readonly provider?: ExecutionProvider; + readonly sessionFallback?: SessionFallback; + readonly cpuPartition?: CpuPartition; + readonly deviceId?: number; + readonly performanceHint?: PerformanceHint; + readonly precision?: Precision; +} + export interface CreateEngineOptions { /** Built-in package model. Defaults to ppocrv6-small. */ readonly model?: BuiltInModel; @@ -96,6 +111,7 @@ export interface CreateEngineOptions { readonly recognitionScoreThreshold?: number; readonly recognitionBatchSize?: number; readonly detection?: DetectionOptions; + readonly execution?: ExecutionOptions; /** Complete replacement; every value may only reduce the bundle ceiling. */ readonly reducedLimits?: Omit & { /** Omission preserves the 0.1 reducedLimits source shape. */ @@ -193,13 +209,47 @@ export interface OcrResult { readonly diagnostics?: Diagnostics; } +export interface SessionExecutionInfo { + readonly requestedProvider: string; + readonly actualProviderChain: readonly string[]; + readonly device: string; + readonly precision: string; + readonly shapePolicy: string; + readonly modelId: string; + readonly modelSha256: string; + readonly runtime: string; + readonly runtimeVersion: string; + readonly providerVersion: string; + readonly modelCacheStatus: string; + readonly sessionFallback: boolean; + readonly fallbackReason?: string; +} + export interface EngineInfo { readonly coreVersion: string; readonly modelBundleId: string; readonly modelBundleSchemaVersion: string; readonly normalizedConfigSchemaVersion: string; readonly backend: string; + /** Compatibility aggregate; use execution.sessions. */ readonly executionProvider: string; + readonly execution: { + readonly requestedProvider: ExecutionProvider; + readonly sessionFallback: SessionFallback; + readonly cpuPartition: CpuPartition; + readonly deviceId?: number; + readonly performanceHint: PerformanceHint; + readonly requestedPrecision: Precision; + readonly providerCapabilities: readonly { + readonly provider: string; + readonly packageIncluded: boolean; + readonly deviceAvailable: boolean; + }[]; + readonly sessions: { + readonly detection: SessionExecutionInfo; + readonly recognition: SessionExecutionInfo; + }; + }; readonly capabilities: { readonly detection: boolean; readonly recognition: boolean; @@ -273,9 +323,11 @@ export interface OcrEngine { export function createEngine(options?: CreateEngineOptions): Promise; ``` +`SessionExecutionInfo` 分别保存 requested provider、实际配置的 provider chain、device、有效 precision、shape policy、模型 ID/SHA-256、runtime/provider version、model cache status,以及是否发生 session fallback 和稳定原因。provider chain 只证明 session 配置,不能替代逐节点 compute-plan/profiling 证据。 + `Buffer` 是 `Uint8Array` 的子类,因此可以直接作为 `RawImage.data` 或 `recognizeEncoded()` 输入。不接受 `DataView`、其他 TypedArray 或以 `SharedArrayBuffer` 为 backing store 的 `Uint8Array`。 -`OcrEngine` 没有 public constructor,只能由成功的 `createEngine` 创建。未传 `model`/`bundlePath` 时默认使用内置 `ppocrv6-small`;二者同时出现是 `invalid_argument`。`reducedLimits` 一旦提供就必须包含全部八个字段;适配器把 Core 固定的 `maxConcurrentCalls=1` 补入 native options。所有配置对象拒绝未知 own property,避免拼写错误被静默忽略。预期的参数、package、I/O、Core 和队列错误都通过 Promise rejection 返回 `OcrError`;取消按 `AbortSignal.reason` 拒绝,默认 `AbortController.abort()` 因而得到标准 `AbortError`。只有非法 receiver、Node-API 无法创建 Promise 或不可恢复的运行时故障可能同步抛出。 +`OcrEngine` 没有 public constructor,只能由成功的 `createEngine` 创建。未传 `model`/`bundlePath` 时默认使用内置 `ppocrv6-small`;二者同时出现是 `invalid_argument`。`execution` 默认选择 CPU;当前 `.d.ts` 只把 `cpu` 放入 provider union,且不支持的 FP16、device、partition、fallback 或 throughput 组合稳定失败。`reducedLimits` 一旦提供就必须包含全部八个字段;适配器把 Core 固定的 `maxConcurrentCalls=1` 补入 native options。所有配置对象拒绝未知 own property,避免拼写错误被静默忽略。预期的参数、package、I/O、Core 和队列错误都通过 Promise rejection 返回 `OcrError`;取消按 `AbortSignal.reason` 拒绝,默认 `AbortController.abort()` 因而得到标准 `AbortError`。只有非法 receiver、Node-API 无法创建 Promise 或不可恢复的运行时故障可能同步抛出。 ### 3.1 使用示例 @@ -317,6 +369,7 @@ JavaScript 使用 camelCase,C++ 使用 snake_case;除命名外不改变值 | `optional` | `diagnostics?` | 未请求时属性缺失 | | `ErrorCode` | `OcrError.code` | Core 字符串逐字保持 | | `Error::detail` | `OcrError.detail` | 空字符串映射为属性缺失 | +| `EngineInfo.execution` | `info.execution` | detector/recognizer 分 stage 映射;对象及数组随 `info` deep-freeze | Core timing 的 `uint64_t` 映射为 JavaScript `number`。转换前必须检查不超过 `Number.MAX_SAFE_INTEGER`;微秒计时达到该边界需要约 285 年,正常调用不会触发。越界按 `internal_error` 处理,不能静默丢精度。 diff --git a/docs/native-api.md b/docs/native-api.md index 14ee706..01532ff 100644 --- a/docs/native-api.md +++ b/docs/native-api.md @@ -286,6 +286,21 @@ struct DetectionOptions { std::optional max_side; }; +enum class ExecutionProvider { cpu }; +enum class SessionFallback { error, cpu }; +enum class CpuPartition { allow, forbid }; +enum class PerformanceHint { latency, throughput }; +enum class Precision { automatic, fp32, fp16 }; + +struct ExecutionOptions { + ExecutionProvider provider = ExecutionProvider::cpu; + SessionFallback session_fallback = SessionFallback::error; + CpuPartition cpu_partition = CpuPartition::allow; + std::optional device_id; + PerformanceHint performance_hint = PerformanceHint::latency; + Precision precision = Precision::automatic; +}; + struct EngineOptions { std::uint32_t intra_op_threads = 1; std::uint32_t inter_op_threads = 1; @@ -293,6 +308,7 @@ struct EngineOptions { std::optional recognition_batch_size; std::optional reduced_limits; DetectionOptions detection; + ExecutionOptions execution; }; struct RecognizeOptions { @@ -309,6 +325,8 @@ struct RecognizeOptions { Rules: - Thread counts are positive and fixed at creation. +- The current release accepts only the default CPU execution policy. Explicit `fp32` is equivalent to `auto`; accelerator provider names, `fp16`, `device_id`, `cpuPartition=forbid`, `sessionFallback=cpu`, and the unqualified throughput hint fail with `invalid_argument` instead of being ignored. +- Provider-specific values are added only after their self-contained release payload and qualification Gate are accepted. Runtime failures do not retry on CPU. - Score thresholds are finite and in `[0, 1]`. - Batch sizes are positive and no larger than the effective limit. - `bounded` defaults to side 960; its side is a positive 32 multiple no larger than the effective detection ceiling. @@ -346,13 +364,49 @@ struct TiledDetectionInfo { float merge_ios_threshold = 0; }; +struct ProviderCapabilityInfo { + std::string provider; + bool package_included = false; + bool device_available = false; +}; + +struct SessionExecutionInfo { + std::string requested_provider; + std::vector actual_provider_chain; + std::string device; + std::string precision; + std::string shape_policy; + std::string model_id; + std::string model_sha256; + std::string runtime; + std::string runtime_version; + std::string provider_version; + std::string model_cache_status; + bool session_fallback = false; + std::optional fallback_reason; +}; + +struct ExecutionInfo { + ExecutionProvider requested_provider; + SessionFallback session_fallback; + CpuPartition cpu_partition; + std::optional device_id; + PerformanceHint performance_hint; + Precision requested_precision; + std::vector provider_capabilities; + SessionExecutionInfo detection; + SessionExecutionInfo recognition; +}; + struct EngineInfo { std::string core_version; std::string model_bundle_id; std::string model_bundle_schema_version; std::string normalized_config_schema_version; std::string backend; + // Compatibility aggregate. Prefer execution.detection/recognition. std::string execution_provider; + ExecutionInfo execution; Capabilities capabilities; ConcurrencyMode concurrency_mode; ResourceLimits limits; @@ -368,7 +422,7 @@ struct EngineInfo { } // namespace light_ocr ``` -`info` is an immutable creation snapshot. The returned reference remains valid until the engine object is destroyed, including after `close`. +`info` is an immutable creation snapshot. The returned reference remains valid until the engine object is destroyed, including after `close`. `provider_capabilities` distinguishes a provider included in the package from one available on the current device; each session then records what was actually configured. An ORT provider chain is configuration evidence, not proof of per-node device placement. Accelerator qualification records compute-plan/profiling evidence separately. ## 9. Engine API @@ -409,8 +463,8 @@ The concrete implementation is hidden behind the factory. 1. Validates engine options. 2. Revalidates the bundle compatibility contract. -3. Creates the ORT environment relationship. -4. Creates detection and recognition sessions. +3. Selects the bundled inference backend from the validated execution policy. +4. Creates detection and recognition sessions independently. 5. Validates session inputs and outputs. 6. Publishes a Ready engine. diff --git a/include/light_ocr/types.hpp b/include/light_ocr/types.hpp index 612ba86..627bb08 100644 --- a/include/light_ocr/types.hpp +++ b/include/light_ocr/types.hpp @@ -13,6 +13,16 @@ enum class PixelFormat { gray8, rgb8, bgr8, rgba8 }; enum class DetectionStrategy { bounded, tiled, upstream_exact }; +enum class ExecutionProvider { cpu }; + +enum class SessionFallback { error, cpu }; + +enum class CpuPartition { allow, forbid }; + +enum class PerformanceHint { latency, throughput }; + +enum class Precision { automatic, fp32, fp16 }; + struct ImageView { const std::uint8_t* data = nullptr; std::size_t size = 0; @@ -121,6 +131,15 @@ struct DetectionOptions { std::optional max_side; }; +struct ExecutionOptions { + ExecutionProvider provider = ExecutionProvider::cpu; + SessionFallback session_fallback = SessionFallback::error; + CpuPartition cpu_partition = CpuPartition::allow; + std::optional device_id; + PerformanceHint performance_hint = PerformanceHint::latency; + Precision precision = Precision::automatic; +}; + struct EngineOptions { std::uint32_t intra_op_threads = 1; std::uint32_t inter_op_threads = 1; @@ -128,6 +147,7 @@ struct EngineOptions { std::optional recognition_batch_size; std::optional reduced_limits; DetectionOptions detection; + ExecutionOptions execution; }; struct RecognizeOptions { @@ -156,6 +176,40 @@ struct TiledDetectionInfo { float merge_ios_threshold = 0; }; +struct ProviderCapabilityInfo { + std::string provider; + bool package_included = false; + bool device_available = false; +}; + +struct SessionExecutionInfo { + std::string requested_provider; + std::vector actual_provider_chain; + std::string device; + std::string precision; + std::string shape_policy; + std::string model_id; + std::string model_sha256; + std::string runtime; + std::string runtime_version; + std::string provider_version; + std::string model_cache_status; + bool session_fallback = false; + std::optional fallback_reason; +}; + +struct ExecutionInfo { + ExecutionProvider requested_provider = ExecutionProvider::cpu; + SessionFallback session_fallback = SessionFallback::error; + CpuPartition cpu_partition = CpuPartition::allow; + std::optional device_id; + PerformanceHint performance_hint = PerformanceHint::latency; + Precision requested_precision = Precision::automatic; + std::vector provider_capabilities; + SessionExecutionInfo detection; + SessionExecutionInfo recognition; +}; + struct EngineInfo { std::string core_version; std::string model_bundle_id; @@ -163,6 +217,7 @@ struct EngineInfo { std::string normalized_config_schema_version; std::string backend; std::string execution_provider; + ExecutionInfo execution; Capabilities capabilities; ConcurrencyMode concurrency_mode = ConcurrencyMode::serialized_reject_when_busy; ResourceLimits limits; diff --git a/src/core/engine.cpp b/src/core/engine.cpp index c69c204..9c44e1a 100644 --- a/src/core/engine.cpp +++ b/src/core/engine.cpp @@ -16,6 +16,7 @@ #include "detection/db_postprocess.hpp" #include "detection/tiled.hpp" #include "geometry/geometry.hpp" +#include "inference/backend.hpp" #include "inference/onnxruntime/backend.hpp" #include "model/bundle_data.hpp" #include "preprocess/image.hpp" @@ -64,11 +65,22 @@ bool valid_limits(const ResourceLimits& value, const ResourceLimits& ceiling) { value.max_concurrent_calls == 1; } +bool valid_execution_options(const ExecutionOptions& options) { + return options.provider == ExecutionProvider::cpu && + options.session_fallback == SessionFallback::error && + options.cpu_partition == CpuPartition::allow && + !options.device_id.has_value() && + options.performance_hint == PerformanceHint::latency && + (options.precision == Precision::automatic || + options.precision == Precision::fp32); +} + class EngineImpl final : public Engine { public: EngineImpl(std::shared_ptr bundle, - std::unique_ptr detection, - std::unique_ptr recognition, EngineInfo info) + std::unique_ptr detection, + std::unique_ptr recognition, + EngineInfo info) : bundle_(std::move(bundle)), detection_(std::move(detection)), recognition_(std::move(recognition)), @@ -476,8 +488,8 @@ class EngineImpl final : public Engine { private: std::shared_ptr bundle_; - std::unique_ptr detection_; - std::unique_ptr recognition_; + std::unique_ptr detection_; + std::unique_ptr recognition_; EngineInfo info_; mutable std::mutex state_mutex_; std::condition_variable state_changed_; @@ -500,6 +512,11 @@ Result> Engine::create(ModelBundle bundle, return failure>(ErrorCode::invalid_argument, "ONNX Runtime thread counts must be positive"); } + if (!valid_execution_options(options.execution)) { + return failure>( + ErrorCode::invalid_argument, + "Execution options are unsupported by the bundled CPU backend"); + } auto limits = options.reduced_limits.value_or(bundle.data_->limits); if (!valid_limits(limits, bundle.data_->limits)) { return failure>(ErrorCode::invalid_argument, @@ -556,13 +573,27 @@ Result> Engine::create(ModelBundle bundle, } const auto& detection_bytes = bundle.data_->files.at(bundle.data_->detection_model_path); const auto& recognition_bytes = bundle.data_->files.at(bundle.data_->recognition_model_path); + internal::InferenceSessionConfig detection_config; + detection_config.intra_op_threads = options.intra_op_threads; + detection_config.inter_op_threads = options.inter_op_threads; + detection_config.provider = options.execution.provider; + detection_config.session_fallback = options.execution.session_fallback; + detection_config.cpu_partition = options.execution.cpu_partition; + detection_config.device_id = options.execution.device_id; + detection_config.performance_hint = options.execution.performance_hint; + detection_config.precision = options.execution.precision; + detection_config.model_id = bundle.data_->detection_model_id; + detection_config.model_sha256 = bundle.data_->detection_model_sha256; + detection_config.shape_policy = "dynamic"; + auto recognition_config = detection_config; + recognition_config.model_id = bundle.data_->recognition_model_id; + recognition_config.model_sha256 = bundle.data_->recognition_model_sha256; auto detection = internal::OnnxSession::create( - detection_bytes, options.intra_op_threads, options.inter_op_threads, - internal::ModelKind::detection); + detection_bytes, detection_config, internal::ModelKind::detection); if (!detection) return Result>::failure(detection.error()); auto recognition = internal::OnnxSession::create( - recognition_bytes, options.intra_op_threads, options.inter_op_threads, - internal::ModelKind::recognition, bundle.data_->recognition.characters.size() + 1); + recognition_bytes, recognition_config, internal::ModelKind::recognition, + bundle.data_->recognition.characters.size() + 1); if (!recognition) return Result>::failure(recognition.error()); EngineInfo info; @@ -571,8 +602,19 @@ Result> Engine::create(ModelBundle bundle, info.model_bundle_schema_version = bundle.data_->schema_version; info.normalized_config_schema_version = bundle.data_->normalized_config_schema_version; - info.backend = "ONNX Runtime 1.22.0"; + info.backend = detection.value()->execution_info().runtime + " " + + detection.value()->execution_info().runtime_version; info.execution_provider = "CPUExecutionProvider"; + info.execution.requested_provider = options.execution.provider; + info.execution.session_fallback = options.execution.session_fallback; + info.execution.cpu_partition = options.execution.cpu_partition; + info.execution.device_id = options.execution.device_id; + info.execution.performance_hint = options.execution.performance_hint; + info.execution.requested_precision = options.execution.precision; + info.execution.provider_capabilities = { + ProviderCapabilityInfo{"cpu", true, true}}; + info.execution.detection = detection.value()->execution_info(); + info.execution.recognition = recognition.value()->execution_info(); info.capabilities = bundle.data_->capabilities; info.limits = limits; info.intra_op_threads = options.intra_op_threads; diff --git a/src/inference/backend.hpp b/src/inference/backend.hpp new file mode 100644 index 0000000..7e2fcab --- /dev/null +++ b/src/inference/backend.hpp @@ -0,0 +1,61 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "light_ocr/error.hpp" +#include "light_ocr/types.hpp" + +namespace light_ocr::internal { + +struct InferenceSessionConfig { + std::uint32_t intra_op_threads = 1; + std::uint32_t inter_op_threads = 1; + ExecutionProvider provider = ExecutionProvider::cpu; + SessionFallback session_fallback = SessionFallback::error; + CpuPartition cpu_partition = CpuPartition::allow; + std::optional device_id; + PerformanceHint performance_hint = PerformanceHint::latency; + Precision precision = Precision::automatic; + std::string model_id; + std::string model_sha256; + std::string shape_policy; +}; + +class TensorOutput { + public: + TensorOutput(std::shared_ptr storage, const float* data, + std::vector shape, std::size_t size) + : storage_(std::move(storage)), data_(data), shape_(std::move(shape)), size_(size) {} + + TensorOutput(TensorOutput&&) noexcept = default; + TensorOutput& operator=(TensorOutput&&) noexcept = default; + TensorOutput(const TensorOutput&) = delete; + TensorOutput& operator=(const TensorOutput&) = delete; + + const float* data() const noexcept { return data_; } + std::size_t size() const noexcept { return size_; } + const std::vector& shape() const noexcept { return shape_; } + + private: + std::shared_ptr storage_; + const float* data_ = nullptr; + std::vector shape_; + std::size_t size_ = 0; +}; + +class InferenceSession { + public: + virtual ~InferenceSession() noexcept = default; + + virtual Result run(const std::vector& values, + const std::vector& shape) noexcept = 0; + virtual const SessionExecutionInfo& execution_info() const noexcept = 0; +}; + +} // namespace light_ocr::internal diff --git a/src/inference/onnxruntime/backend.cpp b/src/inference/onnxruntime/backend.cpp index d943da4..32f7f20 100644 --- a/src/inference/onnxruntime/backend.cpp +++ b/src/inference/onnxruntime/backend.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -67,34 +68,74 @@ void validate_model_contract(Ort::Session& session, ModelKind kind, } } +void validate_session_config(const InferenceSessionConfig& config) { + if (config.intra_op_threads == 0 || config.inter_op_threads == 0) { + throw std::invalid_argument("ONNX Runtime thread counts must be positive"); + } + if (config.provider != ExecutionProvider::cpu) { + throw std::invalid_argument("ONNX Runtime build only supports the CPU provider"); + } + if (config.session_fallback != SessionFallback::error) { + throw std::invalid_argument("CPU sessions do not support a CPU fallback policy"); + } + if (config.cpu_partition != CpuPartition::allow) { + throw std::invalid_argument("CPU sessions require cpuPartition=allow"); + } + if (config.device_id) { + throw std::invalid_argument("CPU sessions do not accept a device ID"); + } + if (config.performance_hint != PerformanceHint::latency) { + throw std::invalid_argument( + "CPU throughput profiles are not qualified in this release"); + } + if (config.precision != Precision::automatic && + config.precision != Precision::fp32) { + throw std::invalid_argument("CPU sessions only support FP32 precision"); + } + if (config.model_id.empty() || config.model_sha256.size() != 64 || + config.shape_policy.empty()) { + throw std::invalid_argument("Inference session identity is incomplete"); + } +} + +SessionExecutionInfo make_execution_info(const InferenceSessionConfig& config) { + SessionExecutionInfo info; + info.requested_provider = "cpu"; + info.actual_provider_chain = {"CPUExecutionProvider"}; + info.device = "cpu"; + info.precision = "fp32"; + info.shape_policy = config.shape_policy; + info.model_id = config.model_id; + info.model_sha256 = config.model_sha256; + info.runtime = "ONNX Runtime"; + info.runtime_version = Ort::GetVersionString(); + info.provider_version = info.runtime_version; + info.model_cache_status = "not_applicable"; + return info; +} + } // namespace OnnxSession::OnnxSession(std::unique_ptr session, std::string input_name, - std::string output_name) + std::string output_name, SessionExecutionInfo execution_info) : session_(std::move(session)), input_name_(std::move(input_name)), - output_name_(std::move(output_name)) {} - -TensorOutput::TensorOutput(Ort::Value value, std::vector shape, - std::size_t size) - : value_(std::move(value)), - data_(value_.GetTensorData()), - shape_(std::move(shape)), - size_(size) {} + output_name_(std::move(output_name)), + execution_info_(std::move(execution_info)) {} Result> OnnxSession::create( - const SharedBytes& model, std::uint32_t intra_op_threads, - std::uint32_t inter_op_threads, ModelKind kind, + const SharedBytes& model, const InferenceSessionConfig& config, ModelKind kind, std::size_t expected_recognition_classes) { try { if (!model || model->empty()) { return runtime_failure>( ErrorCode::invalid_model_bundle, "ONNX model bytes are empty"); } + validate_session_config(config); Ort::SessionOptions options; - options.SetIntraOpNumThreads(static_cast(intra_op_threads)); - options.SetInterOpNumThreads(static_cast(inter_op_threads)); - options.SetExecutionMode(inter_op_threads > 1 ? ORT_PARALLEL : ORT_SEQUENTIAL); + options.SetIntraOpNumThreads(static_cast(config.intra_op_threads)); + options.SetInterOpNumThreads(static_cast(config.inter_op_threads)); + options.SetExecutionMode(config.inter_op_threads > 1 ? ORT_PARALLEL : ORT_SEQUENTIAL); options.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL); auto session = std::make_unique(environment(), model->data(), model->size(), options); validate_model_contract(*session, kind, expected_recognition_classes); @@ -106,7 +147,12 @@ Result> OnnxSession::create( ErrorCode::unsupported_model, "Model input or output name is empty"); } return Result>::success(std::unique_ptr( - new OnnxSession(std::move(session), input_name.get(), output_name.get()))); + new OnnxSession(std::move(session), input_name.get(), output_name.get(), + make_execution_info(config)))); + } catch (const std::invalid_argument& exception) { + return runtime_failure>( + ErrorCode::invalid_argument, "ONNX Runtime session options are invalid", + exception.what()); } catch (const Ort::Exception& exception) { return runtime_failure>( ErrorCode::runtime_initialization_failed, "ONNX Runtime failed to create a session", @@ -157,8 +203,10 @@ Result OnnxSession::run(const std::vector& values, } const auto count = info.GetElementCount(); auto output_shape = info.GetShape(); - return Result::success( - TensorOutput(std::move(outputs[0]), std::move(output_shape), count)); + auto storage = std::make_shared(std::move(outputs[0])); + const auto* data = storage->GetTensorData(); + return Result::success(TensorOutput( + std::move(storage), data, std::move(output_shape), count)); } catch (const Ort::Exception& exception) { return runtime_failure(ErrorCode::inference_failed, "ONNX Runtime inference failed", exception.what()); diff --git a/src/inference/onnxruntime/backend.hpp b/src/inference/onnxruntime/backend.hpp index 79725f3..7eabf7b 100644 --- a/src/inference/onnxruntime/backend.hpp +++ b/src/inference/onnxruntime/backend.hpp @@ -7,55 +7,37 @@ #include +#include "inference/backend.hpp" #include "light_ocr/core.hpp" namespace light_ocr::internal { enum class ModelKind { detection, recognition }; -class TensorOutput { - public: - TensorOutput(TensorOutput&&) noexcept = default; - TensorOutput& operator=(TensorOutput&&) noexcept = default; - TensorOutput(const TensorOutput&) = delete; - TensorOutput& operator=(const TensorOutput&) = delete; - - const float* data() const noexcept { return data_; } - std::size_t size() const noexcept { return size_; } - const std::vector& shape() const noexcept { return shape_; } - - private: - friend class OnnxSession; - - TensorOutput(Ort::Value value, std::vector shape, - std::size_t size); - - Ort::Value value_; - const float* data_ = nullptr; - std::vector shape_; - std::size_t size_ = 0; -}; - -class OnnxSession { +class OnnxSession final : public InferenceSession { public: static Result> create( - const SharedBytes& model, std::uint32_t intra_op_threads, - std::uint32_t inter_op_threads, ModelKind kind, + const SharedBytes& model, const InferenceSessionConfig& config, ModelKind kind, std::size_t expected_recognition_classes = 0); Result run(const std::vector& values, - const std::vector& shape) noexcept; + const std::vector& shape) noexcept override; + + const SessionExecutionInfo& execution_info() const noexcept override { + return execution_info_; + } const std::string& input_name() const noexcept { return input_name_; } const std::string& output_name() const noexcept { return output_name_; } private: OnnxSession(std::unique_ptr session, std::string input_name, - std::string output_name); + std::string output_name, SessionExecutionInfo execution_info); std::unique_ptr session_; std::string input_name_; std::string output_name_; + SessionExecutionInfo execution_info_; }; } // namespace light_ocr::internal diff --git a/src/model/bundle_data.hpp b/src/model/bundle_data.hpp index 6a473ff..c93972b 100644 --- a/src/model/bundle_data.hpp +++ b/src/model/bundle_data.hpp @@ -68,7 +68,11 @@ struct BundleData { std::string schema_version; std::string normalized_config_schema_version; std::string detection_model_path; + std::string detection_model_id; + std::string detection_model_sha256; std::string recognition_model_path; + std::string recognition_model_id; + std::string recognition_model_sha256; std::unordered_map files; DetectionConfig detection; std::optional tiled_detection; diff --git a/src/model/model_bundle.cpp b/src/model/model_bundle.cpp index 8f61b96..1557a1a 100644 --- a/src/model/model_bundle.cpp +++ b/src/model/model_bundle.cpp @@ -601,12 +601,14 @@ std::shared_ptr parse_bundle(std::vector const auto& models = manifest.at("models"); const auto& detection_model = models.at("detection"); const auto& recognition_model = models.at("recognition"); - if (required(detection_model, "id", "models.detection") != - "PP-OCRv6_small_det_onnx") { + const auto detection_model_id = + required(detection_model, "id", "models.detection"); + const auto recognition_model_id = + required(recognition_model, "id", "models.recognition"); + if (detection_model_id != "PP-OCRv6_small_det_onnx") { unsupported_model("Unsupported detection model"); } - if (required(recognition_model, "id", "models.recognition") != - "PP-OCRv6_small_rec_onnx") { + if (recognition_model_id != "PP-OCRv6_small_rec_onnx") { unsupported_model("Unsupported recognition model"); } require(required(detection_model, "sourceRevision", "models.detection") == @@ -650,6 +652,13 @@ std::shared_ptr parse_bundle(std::vector file_at(files, "LICENSES/MODEL-NOTICE.md"); validate_file_inventory(manifest, files); + const auto& file_inventory = manifest.at("files"); + const auto detection_model_sha256 = required( + file_inventory.at(detection_model_path), "sha256", + "files." + detection_model_path); + const auto recognition_model_sha256 = required( + file_inventory.at(recognition_model_path), "sha256", + "files." + recognition_model_path); const auto normalized_path = required(manifest, "normalizedConfigPath", "manifest"); require(is_normalized_path(normalized_path), "Normalized configuration path is invalid", @@ -668,7 +677,11 @@ std::shared_ptr parse_bundle(std::vector data->schema_version = schema_version; data->normalized_config_schema_version = normalized_schema; data->detection_model_path = detection_model_path; + data->detection_model_id = detection_model_id; + data->detection_model_sha256 = detection_model_sha256; data->recognition_model_path = recognition_model_path; + data->recognition_model_id = recognition_model_id; + data->recognition_model_sha256 = recognition_model_sha256; data->files = std::move(files); data->detection = parse_detection(normalized, normalized_schema); data->tiled_detection = diff --git a/tests/integration/main.cpp b/tests/integration/main.cpp index ea642f0..75ca714 100644 --- a/tests/integration/main.cpp +++ b/tests/integration/main.cpp @@ -35,8 +35,14 @@ int main() { const auto corrupt_model = std::make_shared>( std::initializer_list{1, 2, 3}); + light_ocr::internal::InferenceSessionConfig detection_config; + detection_config.model_id = "integration-detection"; + detection_config.model_sha256 = std::string(64, '0'); + detection_config.shape_policy = "dynamic"; + auto recognition_config = detection_config; + recognition_config.model_id = "integration-recognition"; auto corrupt_session = light_ocr::internal::OnnxSession::create( - corrupt_model, 1, 1, light_ocr::internal::ModelKind::detection); + corrupt_model, detection_config, light_ocr::internal::ModelKind::detection); if (corrupt_session || corrupt_session.error().code != light_ocr::ErrorCode::runtime_initialization_failed) { std::cerr << "corrupt ONNX did not return runtime_initialization_failed\n"; @@ -44,7 +50,8 @@ int main() { } auto wrong_contract = light_ocr::internal::OnnxSession::create( - detection_model->bytes, 1, 1, light_ocr::internal::ModelKind::recognition, 1); + detection_model->bytes, recognition_config, + light_ocr::internal::ModelKind::recognition, 1); if (wrong_contract || wrong_contract.error().code != light_ocr::ErrorCode::unsupported_model) { std::cerr << "incompatible model contract did not return unsupported_model\n"; @@ -52,7 +59,8 @@ int main() { } auto detection_session = light_ocr::internal::OnnxSession::create( - detection_model->bytes, 1, 1, light_ocr::internal::ModelKind::detection); + detection_model->bytes, detection_config, + light_ocr::internal::ModelKind::detection); if (!detection_session) { std::cerr << "failed to create detection session for tensor boundary tests\n"; return 1; @@ -90,6 +98,31 @@ int main() { std::cerr << "product bundle did not select bounded/960 and recognition batch 1\n"; return 1; } + const auto& execution = engine.value()->info().execution; + if (execution.requested_provider != light_ocr::ExecutionProvider::cpu || + execution.session_fallback != light_ocr::SessionFallback::error || + execution.cpu_partition != light_ocr::CpuPartition::allow || + execution.performance_hint != light_ocr::PerformanceHint::latency || + execution.requested_precision != light_ocr::Precision::automatic || + execution.provider_capabilities.size() != 1 || + execution.provider_capabilities.front().provider != "cpu" || + !execution.provider_capabilities.front().package_included || + !execution.provider_capabilities.front().device_available || + execution.detection.actual_provider_chain != + std::vector{"CPUExecutionProvider"} || + execution.recognition.actual_provider_chain != + std::vector{"CPUExecutionProvider"} || + execution.detection.model_id != "PP-OCRv6_small_det_onnx" || + execution.recognition.model_id != "PP-OCRv6_small_rec_onnx" || + execution.detection.model_sha256.size() != 64 || + execution.recognition.model_sha256.size() != 64 || + execution.detection.precision != "fp32" || + execution.recognition.shape_policy != "dynamic" || + execution.detection.session_fallback || + execution.detection.fallback_reason.has_value()) { + std::cerr << "default CPU execution summary is invalid\n"; + return 1; + } const std::uint8_t tiny_pixel = 255; const light_ocr::ImageView tiny_image{&tiny_pixel, 1, 1, 1, 1, light_ocr::PixelFormat::gray8}; @@ -279,6 +312,18 @@ int main() { return 1; } + auto invalid_execution_bundle = light_ocr::ModelBundle::create(bundle_files); + light_ocr::EngineOptions invalid_execution_options; + invalid_execution_options.execution.device_id = 0; + auto invalid_execution_engine = light_ocr::Engine::create( + std::move(invalid_execution_bundle).value(), invalid_execution_options); + if (invalid_execution_engine || + invalid_execution_engine.error().code != + light_ocr::ErrorCode::invalid_argument) { + std::cerr << "invalid CPU execution options did not return invalid_argument\n"; + return 1; + } + auto exact_bundle = light_ocr::ModelBundle::create(bundle_files); light_ocr::EngineOptions exact_options; exact_options.detection.strategy = light_ocr::DetectionStrategy::upstream_exact; diff --git a/tools/stage_probe/main.cpp b/tools/stage_probe/main.cpp index 73f5a82..ebfc6d4 100644 --- a/tools/stage_probe/main.cpp +++ b/tools/stage_probe/main.cpp @@ -343,11 +343,20 @@ class StageProbe { const auto& data = *bundle.data_; const auto& detection_bytes = data.files.at(data.detection_model_path); const auto& recognition_bytes = data.files.at(data.recognition_model_path); - auto detection_session = - checked(OnnxSession::create(detection_bytes, 1, 1, ModelKind::detection), - "detection session"); + InferenceSessionConfig detection_config; + detection_config.model_id = data.detection_model_id; + detection_config.model_sha256 = data.detection_model_sha256; + detection_config.shape_policy = "dynamic"; + auto recognition_config = detection_config; + recognition_config.model_id = data.recognition_model_id; + recognition_config.model_sha256 = data.recognition_model_sha256; + auto detection_session = checked( + OnnxSession::create(detection_bytes, detection_config, + ModelKind::detection), + "detection session"); auto recognition_session = checked( - OnnxSession::create(recognition_bytes, 1, 1, ModelKind::recognition, + OnnxSession::create(recognition_bytes, recognition_config, + ModelKind::recognition, data.recognition.characters.size() + 1), "recognition session"); auto validated = checked(validate_and_convert_image(image, data.limits), "image"); From 0c9453c249357e8eb4afa69ee8941a3ae6adea10 Mon Sep 17 00:00:00 2001 From: eric8810 Date: Wed, 15 Jul 2026 22:22:34 +0800 Subject: [PATCH 2/9] =?UTF-8?q?feat(apple):=20=E8=8A=AF=E6=B2=B3=E8=B4=AF?= =?UTF-8?q?=E5=A4=9C=EF=BC=8C=E4=B9=9D=E5=8D=81=E4=B8=80=E5=BE=84=E5=B0=BD?= =?UTF-8?q?=E5=BD=92=E5=8F=8C=E9=80=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/apple-qualification.yml | 179 +++++ .github/workflows/npm-promote.yml | 2 +- .github/workflows/npm-release.yml | 39 +- CMakeLists.txt | 42 +- bindings/node/CMakeLists.txt | 12 +- bindings/node/README.md | 11 +- bindings/node/js/index.cjs | 15 +- bindings/node/js/index.d.ts | 8 +- bindings/node/package.json | 2 +- bindings/node/src/addon.cpp | 19 +- bindings/node/test/adapter.test.cjs | 77 ++ .../apple-provider-baselines.schema.json | 52 ++ docs/apple-device-acceleration.md | 83 +- docs/architecture.md | 4 +- docs/build-and-release.md | 17 +- docs/decisions.md | 6 +- docs/implementation-status.md | 13 +- docs/model-bundle.md | 49 +- docs/napi-design.md | 16 +- docs/native-api.md | 20 +- docs/npm-packaging.md | 12 +- include/light_ocr/types.hpp | 8 +- src/core/engine.cpp | 271 ++++++- src/inference/backend.hpp | 25 + src/inference/coreml/backend.hpp | 40 + src/inference/coreml/backend.mm | 706 ++++++++++++++++++ src/inference/onnxruntime/backend.cpp | 6 +- src/inference/onnxruntime/backend.hpp | 2 - src/model/bundle_data.hpp | 23 + src/model/model_bundle.cpp | 161 +++- src/preprocess/tensor.cpp | 52 +- src/preprocess/tensor.hpp | 7 +- tests/integration/apple.cpp | 217 ++++++ tests/python/test_apple_qualification.py | 263 +++++++ tests/python/test_npm_release.py | 19 +- tests/unit/test_image.cpp | 24 + tests/unit/test_model_bundle.cpp | 154 ++++ tools/apple/accept_qualification.py | 55 ++ tools/apple/acceptance.json | 54 ++ tools/apple/cache_concurrency_gate.py | 119 +++ tools/apple/collect_qualification.py | 256 +++++++ tools/apple/convert_models.py | 288 +++++++ tools/apple/package_bundle.py | 234 ++++++ tools/apple/performance_gate.py | 255 +++++++ tools/apple/qualify_models.py | 422 +++++++++++ tools/apple/quality_gate.py | 234 ++++++ tools/apple/requirements.in | 6 + tools/apple/requirements.lock | 515 +++++++++++++ tools/benchmark/main.cpp | 67 +- tools/common/arguments.hpp | 34 +- tools/leak_check/main.cpp | 87 ++- tools/npm/smoke.cjs | 27 +- tools/npm_release.py | 19 +- tools/validate/main.cpp | 15 + 54 files changed, 5195 insertions(+), 148 deletions(-) create mode 100644 .github/workflows/apple-qualification.yml create mode 100644 contracts/apple-provider-baselines.schema.json create mode 100644 src/inference/coreml/backend.hpp create mode 100644 src/inference/coreml/backend.mm create mode 100644 tests/integration/apple.cpp create mode 100644 tests/python/test_apple_qualification.py create mode 100644 tools/apple/accept_qualification.py create mode 100644 tools/apple/acceptance.json create mode 100644 tools/apple/cache_concurrency_gate.py create mode 100644 tools/apple/collect_qualification.py create mode 100644 tools/apple/convert_models.py create mode 100644 tools/apple/package_bundle.py create mode 100644 tools/apple/performance_gate.py create mode 100644 tools/apple/qualify_models.py create mode 100644 tools/apple/quality_gate.py create mode 100644 tools/apple/requirements.in create mode 100644 tools/apple/requirements.lock diff --git a/.github/workflows/apple-qualification.yml b/.github/workflows/apple-qualification.yml new file mode 100644 index 0000000..7222fdd --- /dev/null +++ b/.github/workflows/apple-qualification.yml @@ -0,0 +1,179 @@ +name: Apple device qualification + +on: + workflow_dispatch: + inputs: + run_qualification: + description: Confirm the full M1 and M2 Apple qualification run + required: true + default: false + type: boolean + +permissions: + contents: read + +concurrency: + group: apple-qualification-${{ github.ref }} + cancel-in-progress: false + +jobs: + derive-models: + if: inputs.run_qualification + runs-on: macos-15 + timeout-minutes: 90 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: "3.12" + - name: Install the hash-locked Apple model toolchain + run: python -m pip install --require-hashes -r tools/apple/requirements.lock + - name: Bootstrap and derive the deterministic Apple models + shell: bash + run: | + python tools/bootstrap_models.py --cache-dir .cache/models + python tools/package_model_bundle.py + python tools/apple/convert_models.py + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: apple-fp16-models + path: models/generated/apple-fp16-20260715.1 + if-no-files-found: error + retention-days: 30 + + qualify: + if: inputs.run_qualification + needs: derive-models + name: qualify ${{ matrix.id }} + strategy: + fail-fast: false + matrix: + include: + - id: apple-m1 + runner: macos-15 + family: Apple M1 + - id: apple-m2 + runner: macos-15-xlarge + family: Apple M2 + runs-on: ${{ matrix.runner }} + timeout-minutes: 180 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: "3.12" + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: "22" + - name: Install the hash-locked Apple toolchain + run: python -m pip install --require-hashes -r tools/apple/requirements.lock + - name: Bootstrap pinned dependencies and CPU model + shell: bash + run: | + python tools/bootstrap_dependencies.py --cache-dir .cache/dependencies + python tools/bootstrap_dependencies.py --cache-dir .cache/dependencies --offline + python tools/bootstrap_models.py --cache-dir .cache/models + python tools/package_model_bundle.py + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: apple-fp16-models + path: models/generated/apple-fp16-20260715.1 + - name: Verify the runner and package its qualified provider + shell: bash + env: + EXPECTED_FAMILY: ${{ matrix.family }} + RUNNER_LABEL: ${{ matrix.runner }} + run: | + brand="$(sysctl -n machdep.cpu.brand_string)" + [[ "$brand" == "$EXPECTED_FAMILY"* ]] + python tools/apple/package_bundle.py \ + --qualified-device-family "$EXPECTED_FAMILY" + mkdir -p "reports/apple/${{ matrix.id }}" + python -c 'import json,os,platform,subprocess; print(json.dumps({"schemaVersion":"1.0","expectedDeviceFamily":os.environ["EXPECTED_FAMILY"],"deviceBrand":subprocess.check_output(["sysctl","-n","machdep.cpu.brand_string"],text=True).strip(),"operatingSystem":platform.platform(),"runnerLabel":os.environ["RUNNER_LABEL"]},sort_keys=True))' \ + > "reports/apple/${{ matrix.id }}/identity.json" + - name: Install verified Node development files + shell: bash + run: | + node_version="$(node -p process.versions.node)" + node_dev="$PWD/.cache/node-gyp/$node_version" + npx --yes node-gyp@11.4.2 install "$node_version" --devdir "$PWD/.cache/node-gyp" + test -f "$node_dev/include/node/node_api.h" + echo "NODE_INCLUDE_DIR=$node_dev/include/node" >> "$GITHUB_ENV" + - name: Configure and build the Apple runtime + shell: bash + run: >- + cmake -S . -B build-apple -G Ninja + -DCMAKE_BUILD_TYPE=Release + -DLIGHT_OCR_DEPENDENCY_CACHE_DIR="$PWD/.cache/dependencies" + -DLIGHT_OCR_BUILD_NODE=ON + -DLIGHT_OCR_BUILD_TESTS=ON + -DLIGHT_OCR_BUILD_TOOLS=ON + -DLIGHT_OCR_NODE_INCLUDE_DIR="$NODE_INCLUDE_DIR" + -DLIGHT_OCR_NODE_EXECUTABLE="$(command -v node)" + - name: Run native and Node contract tests + run: | + cmake --build build-apple --parallel + ctest --test-dir build-apple --output-on-failure + - name: Qualify all Core ML functions and the locked quality corpus + shell: bash + run: | + root="$PWD/reports/apple/${{ matrix.id }}" + python tools/apple/qualify_models.py \ + --jobs 2 \ + --report "$root/model-qualification.json" + python tools/apple/quality_gate.py \ + --native-validate build-apple/bin/light_ocr_validate \ + --bundle models/generated/ppocrv6-small-apple-20260715.1 \ + --report "$root/quality.json" + - name: Run latency, CPU-time, cache, RSS, and lifecycle gates + shell: bash + run: | + root="$PWD/reports/apple/${{ matrix.id }}" + python tools/apple/cache_concurrency_gate.py \ + --native-benchmark build-apple/bin/light_ocr_benchmark \ + --bundle models/generated/ppocrv6-small-apple-20260715.1 \ + --report "$root/cache-concurrency.json" + python tools/apple/performance_gate.py \ + --native-benchmark build-apple/bin/light_ocr_benchmark \ + --cpu-bundle models/generated/ppocrv6-small-onnx-20260714.2 \ + --apple-bundle models/generated/ppocrv6-small-apple-20260715.1 \ + --clear-compiled-cache \ + --report "$root/performance.json" + build-apple/bin/light_ocr_leak_check \ + --bundle models/generated/ppocrv6-small-apple-20260715.1 \ + --pixels corpus/fixtures/paddleocr-xfund-form/pixels.bin \ + --width 1488 --height 2105 --stride 4464 --format bgr8 \ + --profile apple_interactive --reuse-engine \ + --warmup 2 --iterations 100 \ + --report "$root/lifecycle.json" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + if: always() + with: + name: apple-qualification-${{ matrix.id }} + path: reports/apple/${{ matrix.id }} + if-no-files-found: error + retention-days: 90 + + collect: + if: inputs.run_qualification + needs: qualify + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + pattern: apple-qualification-* + path: reports/apple/devices + merge-multiple: false + - name: Validate and collect the two-device candidate + run: >- + python tools/apple/collect_qualification.py + --reports-root reports/apple/devices + --git-commit "$GITHUB_SHA" + --output reports/apple/provider-baselines.candidate.json + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: apple-provider-baselines-candidate + path: reports/apple/provider-baselines.candidate.json + if-no-files-found: error + retention-days: 90 diff --git a/.github/workflows/npm-promote.yml b/.github/workflows/npm-promote.yml index 05abd7f..d6d13b9 100644 --- a/.github/workflows/npm-promote.yml +++ b/.github/workflows/npm-promote.yml @@ -6,7 +6,7 @@ on: version: description: Already-published lockstep version to promote required: true - default: 0.2.0 + default: 0.2.1 type: string release_run_id: description: npm release run containing the verified release artifact diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml index 3c19863..0a7d6e3 100644 --- a/.github/workflows/npm-release.yml +++ b/.github/workflows/npm-release.yml @@ -6,7 +6,7 @@ on: version: description: Lockstep version for all six packages required: true - default: 0.2.0 + default: 0.2.1 type: string publish_to_registry: description: Publish the fully gated candidate to npm after preflight @@ -42,6 +42,7 @@ jobs: test "$RELEASE_VERSION" = "$source_version" if [[ "${{ inputs.publish_to_registry }}" == "true" ]]; then test -f contracts/tiled-platform-baselines.json + test -f contracts/apple-provider-baselines.json fi build-native: @@ -182,8 +183,32 @@ jobs: if-no-files-found: error retention-days: 90 + derive-apple-models: + needs: validate + runs-on: macos-15 + timeout-minutes: 90 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: "3.12" + - name: Install the hash-locked Apple model toolchain + run: python -m pip install --require-hashes -r tools/apple/requirements.lock + - name: Bootstrap and derive the deterministic Apple models + shell: bash + run: | + python tools/bootstrap_models.py --cache-dir .cache/models + python tools/package_model_bundle.py + python tools/apple/convert_models.py + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: npm-apple-fp16-models + path: models/generated/apple-fp16-20260715.1 + if-no-files-found: error + retention-days: 30 + assemble: - needs: build-native + needs: [build-native, derive-apple-models] runs-on: ubuntu-24.04 env: RELEASE_VERSION: ${{ inputs.version }} @@ -199,17 +224,23 @@ jobs: pattern: native-* path: dist/native-input merge-multiple: false - - name: Bootstrap and verify the model bundle + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: npm-apple-fp16-models + path: models/generated/apple-fp16-20260715.1 + - name: Bootstrap and package the self-contained Apple model bundle shell: bash run: | python tools/bootstrap_models.py --cache-dir .cache/models python tools/package_model_bundle.py + python tools/apple/package_bundle.py \ + --qualification-report contracts/apple-provider-baselines.json - name: Assemble and deterministically pack six packages shell: bash run: | python tools/npm_release.py assemble \ --version "$RELEASE_VERSION" \ - --bundle models/generated/ppocrv6-small-onnx-20260714.2 \ + --bundle models/generated/ppocrv6-small-apple-20260715.1 \ --native-root dist/native-input \ --output-dir dist/npm/staging python tools/npm_release.py pack \ diff --git a/CMakeLists.txt b/CMakeLists.txt index ad56a49..51e32b4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -7,7 +7,11 @@ if(APPLE AND (NOT DEFINED CMAKE_OSX_DEPLOYMENT_TARGET OR "Minimum macOS deployment target" FORCE) endif() -project(light_ocr VERSION 0.2.0 LANGUAGES CXX) +project(light_ocr VERSION 0.2.1 LANGUAGES CXX) + +if(APPLE) + enable_language(OBJCXX) +endif() include(CTest) include(GNUInstallDirs) @@ -73,6 +77,9 @@ add_library(light_ocr_core STATIC src/result/result.cpp src/util/sha256.cpp ) +if(APPLE) + target_sources(light_ocr_core PRIVATE src/inference/coreml/backend.mm) +endif() add_library(light_ocr::core ALIAS light_ocr_core) target_include_directories(light_ocr_core @@ -93,6 +100,17 @@ target_link_libraries(light_ocr_core Threads::Threads ) +if(APPLE) + find_library(LIGHT_OCR_COREML_FRAMEWORK CoreML REQUIRED) + find_library(LIGHT_OCR_FOUNDATION_FRAMEWORK Foundation REQUIRED) + target_link_libraries(light_ocr_core PRIVATE + "${LIGHT_OCR_COREML_FRAMEWORK}" + "${LIGHT_OCR_FOUNDATION_FRAMEWORK}") + target_compile_options(light_ocr_core PRIVATE + $<$:-fobjc-arc>) + target_compile_definitions(light_ocr_core PRIVATE LIGHT_OCR_HAS_COREML=1) +endif() + target_compile_definitions(light_ocr_core PRIVATE LIGHT_OCR_VERSION="${PROJECT_VERSION}") if(MSVC) @@ -128,7 +146,8 @@ if(LIGHT_OCR_BUILD_TOOLS) add_executable(light_ocr_leak_check tools/leak_check/main.cpp tools/common/bundle_files.cpp) target_link_libraries(light_ocr_leak_check PRIVATE light_ocr::core nlohmann_json::nlohmann_json) - target_include_directories(light_ocr_leak_check PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/tools) + target_include_directories(light_ocr_leak_check PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src ${CMAKE_CURRENT_SOURCE_DIR}/tools) light_ocr_stage_onnxruntime(light_ocr_leak_check) add_executable(light_ocr_stage_probe tools/stage_probe/main.cpp tools/common/bundle_files.cpp) @@ -172,6 +191,25 @@ if(LIGHT_OCR_BUILD_TESTS) light_ocr_stage_onnxruntime(light_ocr_integration_tests) add_test(NAME light_ocr_integration_tests COMMAND light_ocr_integration_tests) set_tests_properties(light_ocr_integration_tests PROPERTIES SKIP_RETURN_CODE 77) + if(APPLE) + add_executable(light_ocr_apple_integration_tests + tests/integration/apple.cpp tools/common/bundle_files.cpp) + target_link_libraries(light_ocr_apple_integration_tests PRIVATE + light_ocr::core nlohmann_json::nlohmann_json opencv_core opencv_imgproc + Threads::Threads) + target_include_directories(light_ocr_apple_integration_tests PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src ${CMAKE_CURRENT_SOURCE_DIR}/tools) + light_ocr_stage_onnxruntime(light_ocr_apple_integration_tests) + add_test(NAME light_ocr_apple_integration_tests + COMMAND light_ocr_apple_integration_tests) + set_tests_properties(light_ocr_apple_integration_tests PROPERTIES + SKIP_RETURN_CODE 77 LABELS "acceptance;apple" TIMEOUT 180) + if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/models/generated/ppocrv6-small-apple-20260715.1/manifest.json") + set_tests_properties(light_ocr_apple_integration_tests PROPERTIES + ENVIRONMENT + "LIGHT_OCR_APPLE_MODEL_BUNDLE=${CMAKE_CURRENT_SOURCE_DIR}/models/generated/ppocrv6-small-apple-20260715.1;LIGHT_OCR_APPLE_TEST_PIXELS=${CMAKE_CURRENT_SOURCE_DIR}/corpus/fixtures/generated-hello-123/pixels.bin") + endif() + endif() if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/models/generated/ppocrv6-small-onnx-20260714.2/manifest.json") set_tests_properties(light_ocr_integration_tests PROPERTIES ENVIRONMENT "LIGHT_OCR_MODEL_BUNDLE=${CMAKE_CURRENT_SOURCE_DIR}/models/generated/ppocrv6-small-onnx-20260714.2") diff --git a/bindings/node/CMakeLists.txt b/bindings/node/CMakeLists.txt index 2d8cbc8..77c0cb9 100644 --- a/bindings/node/CMakeLists.txt +++ b/bindings/node/CMakeLists.txt @@ -68,13 +68,21 @@ if(LIGHT_OCR_BUILD_TESTS) "${PROJECT_SOURCE_DIR}/models/generated/ppocrv6-small-onnx-20260714.2") if(LIGHT_OCR_NODE_EXECUTABLE AND EXISTS "${_light_ocr_test_bundle}/manifest.json") + set(_light_ocr_node_test_environment + "LIGHT_OCR_NODE_BINARY=$" + "LIGHT_OCR_MODEL_BUNDLE=${_light_ocr_test_bundle}") + set(_light_ocr_apple_test_bundle + "${PROJECT_SOURCE_DIR}/models/generated/ppocrv6-small-apple-20260715.1") + if(APPLE AND EXISTS "${_light_ocr_apple_test_bundle}/manifest.json") + list(APPEND _light_ocr_node_test_environment + "LIGHT_OCR_APPLE_MODEL_BUNDLE=${_light_ocr_apple_test_bundle}") + endif() add_test(NAME light_ocr_node_tests COMMAND "${LIGHT_OCR_NODE_EXECUTABLE}" --test --test-concurrency=1 "${CMAKE_CURRENT_SOURCE_DIR}/test/adapter.test.cjs") set_tests_properties(light_ocr_node_tests PROPERTIES WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}" - ENVIRONMENT - "LIGHT_OCR_NODE_BINARY=$;LIGHT_OCR_MODEL_BUNDLE=${_light_ocr_test_bundle}" + ENVIRONMENT "${_light_ocr_node_test_environment}" LABELS "node;integration") else() message(STATUS diff --git a/bindings/node/README.md b/bindings/node/README.md index ec4eee9..ffef670 100644 --- a/bindings/node/README.md +++ b/bindings/node/README.md @@ -1,6 +1,6 @@ # light-ocr Node-API adapter -状态:`@arcships/light-ocr@0.2.0` 已发布;当前源码已加入尚未发布的 Perf-1A execution contract。tiled detection 和内存 JPEG/PNG 输入可用,默认推理仍为 CPU。 +状态:`@arcships/light-ocr@0.2.0` 已发布;当前 0.2.1 源码候选加入受资格约束的 Apple/Core ML provider。tiled detection 和内存 JPEG/PNG 输入继续可用,默认推理仍为 CPU。 推荐直接安装公开 package: @@ -21,7 +21,7 @@ npm install @arcships/light-ocr - 支持 `AbortSignal` 协作式取消:queued 请求会从队列移除;running 请求立即拒绝 public Promise,但 Core 会安全运行到返回并丢弃结果。 - native addon 只接收现有绝对 bundle 目录。当前源码开发调用显式传 `bundlePath`;发布后的 facade 默认使用随 npm 安装的 model package 路径。 - 产品 engine 默认报告 `detectionStrategy: 'bounded'`、`detectionMaxSide: 960` 和 `defaultRecognitionBatchSize: 1`。0.2.0 可通过 `detection: {strategy: 'tiled'}` 显式选择 `tiled-v1`;`upstreamExact` 只用于上游对照,单次 `recognize({detectionMaxSide})` 只能继续降低 bounded engine 的 side。 -- `createEngine({execution})` 和 `engine.info.execution.sessions` 已提供 provider-neutral 策略与 detector/recognizer 分阶段执行摘要。当前只接受 CPU/FP32;CoreML 等名称尚未进入公开 union,不能据 provider 注册推断设备 placement。 +- `createEngine({execution})` 接受 `cpu` 或 `apple`。Apple interactive 使用 FP16 ANE + 宽文本 FP16 GPU 混合路由,strict 使用全 GPU,显式 CPU fallback 会报告稳定原因;`engine.info.execution.sessions` 和逐批 diagnostics 提供模型、设备、缓存、qualification ID、shape bucket 与实际 compute unit。 不支持 WebP、GIF、PDF、EXIF orientation 自动旋转、zero-copy/transfer、运行中 inference 硬中断、Electron 或 Bun。详细契约见 [Node-API 设计](../../docs/napi-design.md)。 @@ -52,6 +52,7 @@ macOS/Linux 产物在 `build-node/bin/light_ocr_node.node`,锁定的 ONNX Runt ```bash export LIGHT_OCR_NODE_BINARY="$PWD/build-node/bin/light_ocr_node.node" export LIGHT_OCR_MODEL_BUNDLE="$PWD/models/generated/ppocrv6-small-onnx-20260714.2" +export LIGHT_OCR_APPLE_MODEL_BUNDLE="$PWD/models/generated/ppocrv6-small-apple-20260715.1" node --test --test-concurrency=1 bindings/node/test/adapter.test.cjs # 或:ctest --test-dir build-node -R '^light_ocr_node_tests$' --output-on-failure @@ -68,7 +69,11 @@ const { createEngine, OcrError } = require('@arcships/light-ocr'); const engine = await createEngine({ queueCapacity: 4, - execution: { provider: 'cpu', precision: 'auto' }, + execution: { + provider: 'apple', + precision: 'fp16', + sessionFallback: 'cpu', + }, }); console.log(engine.info.execution.sessions.detection.actualProviderChain); diff --git a/bindings/node/js/index.cjs b/bindings/node/js/index.cjs index 41d01f0..4985f1c 100644 --- a/bindings/node/js/index.cjs +++ b/bindings/node/js/index.cjs @@ -7,7 +7,8 @@ const { loadNative } = require('./load-native.cjs'); const DEFAULT_MODEL = 'ppocrv6-small'; const MODEL_PACKAGE = '@arcships/light-ocr-model-ppocrv6-small'; -const EXPECTED_BUNDLE_ID = 'ppocrv6-small-onnx-20260714.2'; +const CPU_BUNDLE_ID = 'ppocrv6-small-onnx-20260714.2'; +const APPLE_BUNDLE_ID = 'ppocrv6-small-apple-20260715.1'; class OcrError extends Error { constructor(code, message, detail) { @@ -49,7 +50,7 @@ function abortReason(signal) { : signal.reason; } -function resolveBuiltInBundle(model) { +function resolveBuiltInBundle(model, requireApple) { if (model !== DEFAULT_MODEL) { throw new OcrError( 'invalid_argument', @@ -76,11 +77,14 @@ function resolveBuiltInBundle(model) { cause instanceof Error ? cause.message : String(cause), ); } - if (manifest.bundleId !== EXPECTED_BUNDLE_ID) { + const compatibleBundleIds = requireApple + ? [APPLE_BUNDLE_ID] + : [CPU_BUNDLE_ID, APPLE_BUNDLE_ID]; + if (!compatibleBundleIds.includes(manifest.bundleId)) { throw new OcrError( 'package_load_failed', 'The installed model package is incompatible with this light-ocr release', - `expected ${EXPECTED_BUNDLE_ID}, received ${String(manifest.bundleId)}`, + `expected ${compatibleBundleIds.join(' or ')}, received ${String(manifest.bundleId)}`, ); } return path.dirname(manifestPath); @@ -101,7 +105,8 @@ function resolveCreateOptions(options) { } if (hasBundlePath) return options; const model = hasModel ? options.model : DEFAULT_MODEL; - const resolved = { ...options, bundlePath: resolveBuiltInBundle(model) }; + const requireApple = options.execution?.provider === 'apple'; + const resolved = { ...options, bundlePath: resolveBuiltInBundle(model, requireApple) }; delete resolved.model; return resolved; } diff --git a/bindings/node/js/index.d.ts b/bindings/node/js/index.d.ts index a71f8e6..f2027fb 100644 --- a/bindings/node/js/index.d.ts +++ b/bindings/node/js/index.d.ts @@ -3,7 +3,7 @@ export type PixelFormat = 'gray8' | 'rgb8' | 'bgr8' | 'rgba8'; export type DetectionStrategy = 'bounded' | 'tiled' | 'upstreamExact'; export type BuiltInModel = 'ppocrv6-small'; -export type ExecutionProvider = 'cpu'; +export type ExecutionProvider = 'cpu' | 'apple'; export type SessionFallback = 'error' | 'cpu'; export type CpuPartition = 'allow' | 'forbid'; export type PerformanceHint = 'latency' | 'throughput'; @@ -83,6 +83,9 @@ export interface RecognitionBatchShape { readonly batchSize: number; readonly height: number; readonly width: number; + readonly computeUnit: 'cpu' | 'ane' | 'gpu'; + readonly modelId: string; + readonly shapeBucket: string; } export interface DetectionPassShape { readonly tileOrdinal: number; @@ -146,6 +149,8 @@ export interface SessionExecutionInfo { readonly requestedProvider: string; readonly actualProviderChain: readonly string[]; readonly device: string; + readonly deviceFamily: string; + readonly operatingSystem: string; readonly precision: string; readonly shapePolicy: string; readonly modelId: string; @@ -154,6 +159,7 @@ export interface SessionExecutionInfo { readonly runtimeVersion: string; readonly providerVersion: string; readonly modelCacheStatus: string; + readonly qualificationId: string; readonly sessionFallback: boolean; readonly fallbackReason?: string; } diff --git a/bindings/node/package.json b/bindings/node/package.json index 12dd027..006db95 100644 --- a/bindings/node/package.json +++ b/bindings/node/package.json @@ -1,6 +1,6 @@ { "name": "@arcships/light-ocr", - "version": "0.2.0", + "version": "0.2.1", "private": true, "description": "Node-API adapter for the light-ocr C++ core", "license": "Apache-2.0", diff --git a/bindings/node/src/addon.cpp b/bindings/node/src/addon.cpp index dd975ce..6b3c2a0 100644 --- a/bindings/node/src/addon.cpp +++ b/bindings/node/src/addon.cpp @@ -349,9 +349,9 @@ DetectionOptions parse_detection_options(napi_env env, napi_value value) { ExecutionProvider parse_execution_provider(napi_env env, napi_value value) { const auto provider = get_string(env, value, "execution.provider"); if (provider == "cpu") return ExecutionProvider::cpu; - throw AddonFailure( - "invalid_argument", - "execution.provider must be cpu; accelerator providers are not qualified in this release"); + if (provider == "apple") return ExecutionProvider::apple; + throw AddonFailure("invalid_argument", + "execution.provider must be cpu or apple"); } SessionFallback parse_session_fallback(napi_env env, napi_value value) { @@ -1186,6 +1186,11 @@ napi_value create_diagnostics(napi_env env, const Diagnostics& diagnostics) { set_named(env, entry, "batchSize", uint32_value(env, shape.batch_size)); set_named(env, entry, "height", uint32_value(env, shape.height)); set_named(env, entry, "width", uint32_value(env, shape.width)); + set_named(env, entry, "computeUnit", + string_value(env, shape.compute_unit)); + set_named(env, entry, "modelId", string_value(env, shape.model_id)); + set_named(env, entry, "shapeBucket", + string_value(env, shape.shape_bucket)); check(env, napi_set_element(env, batch_shapes, static_cast(index), entry), @@ -1239,7 +1244,7 @@ napi_value create_resource_limits(napi_env env, const ResourceLimits& limits) { } const char* execution_provider_string(ExecutionProvider provider) { - return provider == ExecutionProvider::cpu ? "cpu" : "unknown"; + return provider == ExecutionProvider::apple ? "apple" : "cpu"; } const char* session_fallback_string(SessionFallback fallback) { @@ -1279,6 +1284,10 @@ napi_value create_session_execution_info(napi_env env, } set_named(env, object, "actualProviderChain", providers); set_named(env, object, "device", string_value(env, info.device)); + set_named(env, object, "deviceFamily", + string_value(env, info.device_family)); + set_named(env, object, "operatingSystem", + string_value(env, info.operating_system)); set_named(env, object, "precision", string_value(env, info.precision)); set_named(env, object, "shapePolicy", string_value(env, info.shape_policy)); set_named(env, object, "modelId", string_value(env, info.model_id)); @@ -1290,6 +1299,8 @@ napi_value create_session_execution_info(napi_env env, string_value(env, info.provider_version)); set_named(env, object, "modelCacheStatus", string_value(env, info.model_cache_status)); + set_named(env, object, "qualificationId", + string_value(env, info.qualification_id)); set_named(env, object, "sessionFallback", boolean_value(env, info.session_fallback)); if (info.fallback_reason) { diff --git a/bindings/node/test/adapter.test.cjs b/bindings/node/test/adapter.test.cjs index 0035ce8..a08a171 100644 --- a/bindings/node/test/adapter.test.cjs +++ b/bindings/node/test/adapter.test.cjs @@ -15,6 +15,9 @@ const bundlePath = path.resolve( process.env.LIGHT_OCR_MODEL_BUNDLE || path.join(repositoryRoot, 'models/generated/ppocrv6-small-onnx-20260714.2'), ); +const appleBundlePath = process.env.LIGHT_OCR_APPLE_MODEL_BUNDLE + ? path.resolve(process.env.LIGHT_OCR_APPLE_MODEL_BUNDLE) + : undefined; const encodedBlankPng = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAADCAIAAAA2iEnWAAAAFUlEQVR4nGP8//8/AwMDEwMDA4ICADkbAwP+wj6MAAAAAElFTkSuQmCC', @@ -176,6 +179,76 @@ test('loads PP-OCRv6, snapshots pixels, maps results, and closes idempotently', ); }); +test('exposes qualified Apple interactive and strict routing', { + skip: appleBundlePath === undefined, +}, async () => { + const image = loadFixture('generated-hello-123'); + const interactive = await createEngine({ + bundlePath: appleBundlePath, + execution: { provider: 'apple', precision: 'fp16' }, + }); + try { + assert.equal(interactive.info.executionProvider, 'CoreML'); + assert.equal(interactive.info.execution.requestedProvider, 'apple'); + assert.deepEqual( + interactive.info.execution.sessions.detection.actualProviderChain, + ['CoreML(MLNeuralEngine,qualified-MLCPU)'], + ); + assert.deepEqual( + interactive.info.execution.sessions.recognition.actualProviderChain, + ['CoreML(MLNeuralEngine,qualified-MLCPU)', 'CoreML(MLGPU)'], + ); + assert.match( + interactive.info.execution.sessions.detection.qualificationId, + /^apple-/, + ); + assert.match( + interactive.info.execution.sessions.detection.deviceFamily, + /^Apple M/, + ); + assert.ok( + interactive.info.execution.sessions.detection.operatingSystem.length > 0, + ); + const result = await interactive.recognize(image, { includeDiagnostics: true }); + assert.deepEqual(result.lines.map((line) => line.text), ['HELLO 123']); + assert.deepEqual( + result.diagnostics.recognitionBatchShapes.map((shape) => shape.computeUnit), + ['ane'], + ); + assert.match(result.diagnostics.recognitionBatchShapes[0].modelId, /_coreml_fp16_/); + assert.match(result.diagnostics.recognitionBatchShapes[0].shapeBucket, /^w\d{4}$/); + } finally { + await interactive.close(); + } + + const strict = await createEngine({ + bundlePath: appleBundlePath, + execution: { + provider: 'apple', + precision: 'fp16', + cpuPartition: 'forbid', + }, + }); + try { + assert.deepEqual( + strict.info.execution.sessions.detection.actualProviderChain, + ['CoreML(MLGPU)'], + ); + assert.deepEqual( + strict.info.execution.sessions.recognition.actualProviderChain, + ['CoreML(MLGPU)'], + ); + const result = await strict.recognize(image, { includeDiagnostics: true }); + assert.deepEqual(result.lines.map((line) => line.text), ['HELLO 123']); + assert.deepEqual( + result.diagnostics.recognitionBatchShapes.map((shape) => shape.computeUnit), + ['gpu'], + ); + } finally { + await strict.close(); + } +}); + test('decodes JPEG and PNG snapshots on the engine worker', async () => { const engine = await createEngine({ bundlePath }); const png = Buffer.from(encodedBlankPng); @@ -298,6 +371,10 @@ test('validates input and reports adapter errors as OcrError', async () => { createEngine({ bundlePath, execution: { provider: 'coreml' } }), (error) => error instanceof OcrError && error.code === 'invalid_argument', ); + await assert.rejects( + createEngine({ bundlePath, execution: { provider: 'apple' } }), + (error) => error instanceof OcrError && error.code === 'unsupported_capability', + ); await assert.rejects( createEngine({ bundlePath, execution: { precision: 'fp16' } }), (error) => error instanceof OcrError && error.code === 'invalid_argument', diff --git a/contracts/apple-provider-baselines.schema.json b/contracts/apple-provider-baselines.schema.json new file mode 100644 index 0000000..f05368b --- /dev/null +++ b/contracts/apple-provider-baselines.schema.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/arcships/light-ocr/contracts/apple-provider-baselines.schema.json", + "title": "light-ocr Apple provider qualification baselines", + "type": "object", + "required": [ + "schema", + "status", + "qualificationId", + "generatedFromCommit", + "acceptanceSha256", + "modelArtifactId", + "modelPackageSha256", + "qualifiedDeviceFamilies", + "devices", + "reportSha256" + ], + "properties": { + "schema": { "const": "light-ocr-apple-provider-baselines/1.0" }, + "status": { "enum": ["candidate", "accepted"] }, + "qualificationId": { "type": "string", "minLength": 1 }, + "generatedFromCommit": { "type": "string", "minLength": 7 }, + "approvedByCommit": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "candidateReportSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "acceptanceSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "modelArtifactId": { "type": "string", "minLength": 1 }, + "modelPackageSha256": { + "type": "object", + "required": ["detection", "recognition"], + "properties": { + "detection": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "recognition": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + }, + "additionalProperties": false + }, + "qualifiedDeviceFamilies": { + "type": "array", + "minItems": 2, + "uniqueItems": true, + "items": { "enum": ["Apple M1", "Apple M2", "Apple M3", "Apple M4"] } + }, + "devices": { "type": "array", "minItems": 2, "items": { "type": "object" } }, + "reportSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + }, + "allOf": [ + { + "if": { "properties": { "status": { "const": "accepted" } } }, + "then": { "required": ["approvedByCommit", "candidateReportSha256"] } + } + ], + "additionalProperties": false +} diff --git a/docs/apple-device-acceleration.md b/docs/apple-device-acceleration.md index be3d29b..05e8544 100644 --- a/docs/apple-device-acceleration.md +++ b/docs/apple-device-acceleration.md @@ -1,12 +1,12 @@ # Apple Device 加速技术方案 -状态:Draft;用于方案讨论,不代表已经批准实现或发布 +状态:Implemented candidate;M4 Max 本地实现、放置、质量、性能、缓存和 100 页生命周期 Gate 已通过,M1/M2 远端资格尚待通过;不代表已经发布 更新时间:2026-07-15 范围:以 macOS Apple Silicon 为当前交付目标;iPhone/iPad 只保留架构兼容性,不在当前 Tier 1 平台承诺内 -实施状态:Perf-1A 基础已完成。Core 通过 provider-neutral `InferenceSession` 运行 detector/recognizer,公共 `execution` 策略和逐 stage `EngineInfo.execution.sessions` 已建立;默认仍为 ONNX Runtime CPU。Direct CoreML bridge、Apple capability manifest、FP16 模型派生物和资格审查工具尚未实现,不能请求或宣称 Apple 加速。 +实施状态:Direct Objective-C++ Core ML bridge、schema 1.1 capability manifest、哈希锁 FP16 模型派生、自包含 npm 模型包、ANE/GPU 混合路由、严格 GPU 模式、离线编译缓存、跨进程锁、20 个加权宽度桶的有界函数缓存、设备资格与显式 CPU fallback、C++/Node API 和资格工具均已实现。默认仍为 ONNX Runtime CPU;Apple 必须显式请求并由 bundle 中的设备族 allow-list 放行。正式发布仍取决于本节 Gate 和双设备 CI 证据。 关联 Roadmap:[Perf-0–Perf-4](roadmap.md#7-perf-0perf-4--性能与宿主加速线) @@ -91,6 +91,28 @@ flowchart TD 设备为 Apple M4 Max。完整文档 workload 是同一份 15 页参考 PDF,SHA-256 为 `d9be780fe4674e16ca78a09e1513dff0665ac02cbbbbc56f80381d8f0f5e12c4`,200 DPI 渲染为 1700×2200 页面。以下数据是 spike 证据,不是发布性能承诺。 +该 SHA 对应的 PDF 不在仓库、git history 或当前工作区可访问的本机文件中,因此旧 15 页数据目前只能作为历史 spike,不能被本次实现冒充为可复跑的正式证据。当前自动化 Gate 使用提交前锁定的 `generated-hello-123` 与 `paddleocr-xfund-form` 两个 workload、完整 14-fixture 质量语料和 100 次生命周期;恢复上述 PDF 后还必须补跑同一 SHA 的 15 页 scoreboard。 + +#### FP16 混合生产候选的锁定 Gate + +2026-07-15 的 M4 Max 本机报告使用 acceptance SHA-256 `97b99d6e…f57d6`,得到: + +| 验收面 | 本机结果 | 锁定门槛 | +| --- | ---: | ---: | +| Core ML placement | detector 通过;recognition 91/91 函数通过 | 覆盖完整,无未声明设备回退 | +| 字符相似度 | 99.6484% | ≥99.5% | +| detection recall / 平均 IoU | 100% / 99.5508% | ≥99.5% / ≥98% | +| `generated-hello-123` warm P50 | 8.708 ms,相对 CPU 2.287× | ≥1.5× | +| `paddleocr-xfund-form` warm P50 | 330.837 ms,相对 CPU 2.808× | ≥1.5× | +| 两 workload CPU time 降幅 | 95.86% / 97.65% | ≥80% | +| canary cold start | cache miss 7.289 s;hit 1.285/1.282 s | miss ≤30 s;hit ≤3 s | +| warm peak RSS | 最大 695.97 MiB | ≤768 MiB | +| Apple bundle 增量 | 25.42 MiB | ≤32 MiB | +| 四进程缓存竞争 | 通过,无残留临时目录 | 只允许每阶段一个 miss | +| 同 engine 100 页 RSS 增长 | -21.39 MiB,测量最大 745.14 MiB | ≤64 MiB(工具实际执行 ≤32 MiB) | + +密集表单的首次整页耗时另行保留:cache miss 54.487 s,hit 12.834/12.784 s;其中包含 113 行 OCR 和 14 个 Core ML 函数的按需装载,不纳入固定 canary 的 provider cold-start ceiling。确定性派生的 detector/recognizer 包哈希分别为 `2097bd78…7f76` 与 `c54a0719…5f4b`;模型放置、质量、性能、缓存和生命周期报告哈希分别为 `e9d371db…c7373`、`0c4d9865…326e`、`e373a9a4…a983`、`df0e7b75…5b2c` 和 `5c20fc47…6a8fb`。 + #### CPU 与 FP16 GPU | 模式 | 测量范围 | 结果 | 解释 | @@ -112,13 +134,13 @@ FP16 GPU warm OCR 期间父进程平均约占用 0.6 个 CPU core;整条 PDF p | Detector 960×768 | 7.710 ms | 6.568 ms | 8.767 ms | **5.390 ms** | | Recognizer width=320 | 2.164 ms | 2.784 ms | 0.895 ms | **0.841 ms** | -当前 compute plan 显示上述静态模型的操作完整落在所请求的 MLGPU 或 MLNeuralEngine。由此可以确认: +当前生产候选的 Compute Plan 显示:detector interactive 为 190 个 ANE 操作及 2 个已声明 MLCPU 操作,strict 为 192 个 GPU 操作且没有 MLCPU;recognizer 的 ANE shape 具有 ANE placement,并只允许资格报告中声明的最大 MLCPU envelope,宽 shape strict 路径为 213 个 GPU 操作且没有 MLCPU。由此可以确认: - 当前模型不是算法上无法使用 ANE;早期动态模型“没有 ANE placement”的结论已经被静态模型探针推翻。 - Detector 的 W8A8 ANE 相对 FP16 ANE 快约 1.63×,延迟降低约 38%。 - width=320 recognition 的 W8A8 ANE 相对 FP16 ANE 只快约 6%。 - width=320 recognition 的 W8A8 GPU 相对 FP16 GPU 慢约 29%。 -- 当前 M4 Max probe 中,recognition width 320、1024、1600 可以完整进入 ANE;2168、3200 不能据此承诺 ANE,暂按 FP16 GPU 路径设计。这个 1600 边界是当前模型、转换方式、OS/runtime 和设备的实测边界,不是 Core ML 的通用常量。 +- 当前 M4 Max probe 中,recognition width 320–1600 都有 Neural Engine placement并位于已声明 MLCPU envelope 内;1632–3200 使用无 MLCPU 的 FP16 GPU 路径。这个 1600 边界是当前模型、转换方式、OS/runtime、设备和资格 ID 的路由合同,不是 Core ML 的通用常量。 W8 weight-only 模型把实验模型总大小从约 55.95 MiB 降到 29.14 MiB,但 15 页 warm OCR 从 FP16 GPU 的约 6.13 s 增至约 9.34 s,RSS 没有明显下降。该结果只能说明权重压缩效果,不能作为原生 INT8 benchmark。 @@ -207,14 +229,16 @@ Detector 和 recognizer 不要求使用同一种 precision 或 compute unit: ### 6.3 Recognition -实验性五档 bucket 为 320、1024、1600、2168、3200,适合快速证明 backend 可行性,但 padding 浪费和六份静态模型的 RSS 不适合作为最终设计。 +实验性五档 bucket 为 320、1024、1600、2168、3200,适合快速证明 backend 可行性,但 padding 浪费和多份静态模型的 RSS 不适合作为最终设计。 -生产候选是把 recognition width 向上取整到 32 的倍数,并拆成两个模型族: +生产候选使用一个包含 91 个函数的 MLProgram;每个函数仍是 320–3200、步长 32 的精确宽度,用于完整资格审查和可追溯路由。运行时不再对每个实际宽度保留独立模型实例,而是向上取整到锁定的 20 个加权 bucket: -- **ANE 模型族:** 320–当前设备资格审查上限;优先使用一个 enumerated-shape MLProgram。 -- **GPU 模型族:** 超过 ANE 上限至 3200;使用 FP16 enumerated-shape MLProgram。 +`320, 384, 480, 544, 576, 608, 704, 736, 832, 960, 1056, 1184, 1248, 1376, 1600, 1984, 2240, 2560, 2880, 3200` -320–3200 每 32 一个宽度一共 91 个 shape,理论上不超过 Core ML 的 128 enumerated-shape 上限;是否拆成两个模型、每个 shape 是否完整落在目标 compute unit、编译/加载成本是否可接受,必须由实际 compute plan 和 benchmark 决定。 +- **ANE 路由:** bucket ≤1600 使用通过资格审查的 ANE/已声明 MLCPU envelope。 +- **GPU 路由:** bucket >1600 使用无 MLCPU 操作的 FP16 GPU 路径。 + +91 个函数全部执行 compute-plan placement 审查;20 个运行时 bucket 则在质量、延迟与 warm RSS 门槛之间给出有界折中。该列表、ANE 上限和最大缓存函数同时锁入 acceptance、bundle manifest 与 runtime validation,不能由调用者任意改写。 ## 7. 量化设计 @@ -256,15 +280,13 @@ Apple interactive profile 继续保持每个 engine 单 active call,不用多 ## 9. 模型产物、加载与内存 -Apple provider release set 需要独立、版本锁定的模型派生物,不修改默认 ONNX CPU bundle。Core ML 模型与 native runtime 是否位于同一个 npm package,由 D111 决定: +Apple provider release set 使用独立、版本锁定的模型派生物,同时保留同包 ONNX CPU payload 作为显式 fallback。D111 已决定模型位于公共 model package,Core ML bridge 位于 Darwin native package: ```text Apple provider release set ├── capability manifest ├── detector FP16 MLProgram -├── recognition FP16 ANE MLProgram -├── recognition FP16 GPU MLProgram -├── optional qualified W8A8 ANE variants +├── recognition FP16 91-function MLProgram(ANE/GPU 按 width 路由) ├── conversion provenance / hashes / licenses └── native CoreML-enabled runtime ``` @@ -272,13 +294,12 @@ Apple provider release set 产物规则: - Core ML 模型必须能追溯到固定的 PP-OCRv6 Small 权重、转换脚本版本和完整参数。 -- W8A8 是独立 model ID,不能覆盖 FP16 bundle。 -- 优先一个 enumerated-shape 模型而不是多个独立静态 session;以 RSS、cold start 和 placement 结果决定最终数量。 -- 宽 GPU 模型和 W8A8 变体按需 lazy load;未使用的模型不得常驻。 -- 评估随包携带预编译产物和首次本地离线编译缓存两种路径;缓存必须按模型 hash、OS/runtime 和设备能力失效。 +- 当前只发布 FP16 候选;W8A8 仍是后续独立 model ID,不能覆盖 FP16 bundle。 +- recognition 采用一个 91-function MLProgram;运行时只向上取整到锁定的 20 个宽度 bucket,按需加载并以 LRU 固定最多 20 个实例,不创建 91 个常驻 session。 +- 随包携带 `.mlpackage` 源工件,首次本地离线编译;缓存以 package hash、OS build、Mac model 和 CPU brand 失效,并由进程内 mutex + 跨进程 `flock` 原子保护。 - 不在安装或首次运行时联网下载编译器、provider 或模型。 -当前约 1.0–1.07 GiB RSS 是实验结构数据,不是可接受默认预算。正式内存 ceiling 必须在 D111/Provider Gate 前锁定。 +正式 ceiling 已在查看生产候选结果前锁定:warm peak RSS ≤768 MiB、同一 engine 连续处理 100 页的 RSS 增长 ≤64 MiB、Apple bundle 增量 ≤32 MiB;页生命周期工具另执行更严格的 32 MiB 增长门槛。反复创建/销毁 engine 是独立的 Core 安全回归,不冒充 `maximumResidentGrowthAfter100PagesBytes` 页面门槛。cold start 使用固定 canary `generated-hello-123`:编译缓存 miss ≤30 s、hit ≤3 s。密集页首次整页 OCR 会随文本行数和触发的函数数量增长,保留为独立观测,不冒充 provider cold start。 ## 10. 公共策略与可观测性 @@ -330,13 +351,15 @@ Apple provider 继承 Roadmap Provider Gate,并增加交互式 CPU 目标: - 无未声明的 ORT CPU、MLCPU 或整 session fallback; - 公共 contract 100% 通过;FP16/W8A8 的质量容差在查看最终 benchmark 前锁定; - reference PDF 不允许出现预注册的关键文本漏检; -- cold start、RSS、包增量和缓存行为通过预注册 ceiling; +- 固定启动 canary 的 cold start、warm RSS、包增量和缓存行为通过预注册 ceiling;其他 workload 仍报告首次整页耗时; - 至少在两台目标设备上复核,其中 W8A8 必须覆盖计划宣称支持的硬件代际。 ## 12. 分阶段落地 ### Phase A — 固化证据和决策 +状态:除缺失的旧 15 页 PDF 重跑外已完成。Direct Core ML、shape contract、质量/性能/RSS/cache 阈值和 D111 addendum 已锁定。 + - 统一 CPU/CoreML 的 15 页 PDF benchmark harness。 - 固定测试 corpus、质量指标和最简 scoreboard。 - 对照 Direct CoreML 与新版 ORT CoreML EP 的静态/enumerated shape placement。 @@ -346,6 +369,8 @@ Apple provider 继承 Roadmap Provider Gate,并增加交互式 CPU 目标: ### Phase B — FP16 Apple interactive preview +状态:实现完成,M4 Max 本机 Gate 已通过,M1/M2 双设备 Gate 待远端验收。 + - Detector、常规 recognition 优先 FP16 ANE。 - ANE-unqualified recognition shape 使用 FP16 GPU。 - 加入 lazy load、缓存、严格 placement 诊断和显式 fallback。 @@ -355,6 +380,8 @@ Apple provider 继承 Roadmap Provider Gate,并增加交互式 CPU 目标: ### Phase C — W8A8 ANE qualification +状态:明确延期,不属于 0.2.1 FP16 Apple provider 的完成条件,也不得借 FP16 结果宣称 INT8。 + - 建立代表性 calibration corpus,先 PTQ 后按需要 QAT。 - Detector 和 recognizer 分别决定是否量化。 - 只在 Apple 明确支持并由项目复核的设备族启用。 @@ -364,21 +391,23 @@ Apple provider 继承 Roadmap Provider Gate,并增加交互式 CPU 目标: ### Phase D — 稳定自包含分发 +状态:代码与 workflow 已实现;npm release 会在 macOS 从哈希锁工具链派生 Core ML 工件,并把 Apple superset bundle 与四平台 native package 组装为原有六包入口。正式发布仍需双设备 candidate 被接受。 + - 将通过 Gate 的 Apple runtime/provider、模型派生物、SBOM、licenses、provenance 和签名纳入由主 facade 自动取得的 Darwin native release set,不新增用户安装入口。 - 固化 device/OS/model compatibility manifest 和故障语义。 - 在未安装任何额外 provider/runtime 的干净目标机上,通过两个目标设备、正式 corpus、禁网安装和 release qualification。 退出条件:用户仅安装 `@arcships/light-ocr` 即可运行;从 `engine.info()` 和 qualification report 可以证明实际执行路径,且稳定 CPU fallback 保持可用。 -## 13. 仍需讨论的决策 +## 13. 已落地决策与剩余外部证据 -1. Direct CoreML 是否成为正式 Apple backend,还是新版 ORT CoreML EP 已能达到同等 placement 与资源表现? -2. 第一版最低设备是所有 Apple Silicon,还是只对 M4+ 发布 W8A8、对 M1–M3 仅发布 FP16? -3. Detector 使用 enumerated exact shapes、padding buckets,还是保留 GPU 兼容路径处理非高频 shape? -4. Recognition 采用每 32 一个 enumerated width,还是更少的 bucket 换取更低编译/加载成本? -5. W8A8 只量化 detector,还是 recognition 在更宽 shape 上也能获得足够累计收益? -6. 模型随包携带预编译产物,还是首次运行离线编译并缓存? -7. Apple interactive 的 RSS、cold start、包体积和质量硬阈值是多少?这些阈值必须在完整结果出来前锁定。 +1. 正式 backend 候选为 Direct Core ML;ORT 1.22 CoreML EP 在禁止 CPU fallback 时不能完整放置当前 graph,保留为未来对照而非产品路径。 +2. FP16 只对 manifest 明列且独立通过资格的 Apple Silicon family 启用;当前本机包只列 M4,M1/M2 必须由双设备 CI 产生证据后才能加入发布 allow-list。W8A8 仍不发布。 +3. Detector 使用 32–960 的受限 range MLProgram;interactive 使用资格内 ANE/MLCPU envelope,strict 使用全 GPU。 +4. Recognition 使用 320–3200、步长 32 的 91-function MLProgram 做全量资格审查;运行时向上取整到锁定的 20 个加权 bucket,≤1600 走 ANE envelope,>1600 走 GPU,LRU≤20。 +5. 随包携带源 `.mlpackage`,首次运行离线编译并以 package/OS/device identity 缓存;不分发跨 OS 的预编译 `.mlmodelc`。 +6. 质量、两 workload speedup、CPU-time 降幅、canary 的 3 次 cold start、30 次 warm、RSS、同 engine 100 页生命周期和 32 MiB 包增量阈值由 `tools/apple/acceptance.json` 锁定。 +7. 剩余外部证据只有:M1/M2 workflow 的真实通过报告,以及重新取得 SHA `d9be…12c4` 的旧 15 页 PDF 后补跑文档 scoreboard。两者没有完成前,状态保持 candidate。 ## 14. 关联工作 diff --git a/docs/architecture.md b/docs/architecture.md index 825c8c1..8411fe0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -109,9 +109,9 @@ Conversion and normalization are separate functions so tensor input can be compa Location: `src/inference/` -The internal `InferenceSession` boundary accepts a float vector and shape, validates storage size with checked arithmetic, and returns a lifetime-owning validated float tensor view. `OnnxSession` implements that boundary with the bundled ONNX Runtime CPU Execution Provider; future qualified backends implement the same contract. No backend type appears in a public header. +The internal `InferenceSession` boundary accepts a float vector and shape, validates storage size with checked arithmetic, and returns a lifetime-owning validated float tensor view. `OnnxSession` implements that boundary with the bundled ONNX Runtime CPU Execution Provider. On Apple builds, `CoreMlSession` implements the same boundary with system Core ML, zero-copy Float32 inputs, checked Float16/strided output conversion, lazy multifunction loading and a bounded compiled-model cache. No backend type appears in a public header. -The interface owns no OCR algorithm. Each session exposes immutable execution metadata separately for detector and recognizer, including requested/actual provider chain, model hash, precision, shape policy, runtime/cache, and fallback status. Provider-chain configuration is not treated as proof of per-node accelerator placement. +The interface owns no OCR algorithm. Each session exposes immutable execution metadata separately for detector and recognizer, including requested/actual provider chain, device family/OS, model hash, precision, shape policy, runtime/cache, qualification ID, and fallback status. Recognition diagnostics add the per-call model function and compute unit. Provider-chain configuration is not treated as proof of per-node accelerator placement; the release tool independently checks every Core ML function's Compute Plan. ### 3.6 Detection postprocessing diff --git a/docs/build-and-release.md b/docs/build-and-release.md index 4dc94fd..b8064db 100644 --- a/docs/build-and-release.md +++ b/docs/build-and-release.md @@ -109,7 +109,7 @@ Node-API 是默认关闭的可选 target。开发构建需显式提供 Node head | `light_ocr_stage_probe` | 测试专用全阶段记录 | | `light_ocr_benchmark` | load、初始化、各阶段、总延迟和 RSS | | `light_ocr_memory_gate` | 独立进程高分辨率 resize、tensor-shape、文本框和 absolute peak RSS 门槛;不依赖 Python | -| `light_ocr_leak_check` | 重复完整生命周期的 RSS 门槛 | +| `light_ocr_leak_check` | 默认重复完整 engine 生命周期;`--reuse-engine` 测量单 engine 连续处理页面的 RSS 增长 | | `light_ocr_unit_tests` | 算法、边界和错误契约 | | `light_ocr_integration_tests` | 真实模型、golden、并发、关闭和 ORT 错误映射 | | `light_ocr_fuzz_*` | image、bundle、geometry、lifecycle 四个 fuzz 入口 | @@ -195,6 +195,7 @@ macOS arm64 高分辨率绝对 RSS gates 由 `light_ocr_memory_gate` 独立进 `.github/workflows/npm-release.yml` 是仅允许从 `main` 手动触发的发布候选与发布流程。默认 `publish_to_registry=false`,所以第一次运行不会读取 `NPM_TOKEN` 或改动 npm registry: +- 0.2.1 候选先在 macOS/Python 3.12 的哈希锁工具链中派生并校验固定 Core ML FP16 package hashes,再把 Apple superset bundle 交给 Linux assemble;用户安装、postinstall 和首次运行都不会执行转换或联网。 - 四个平台分别原生构建 Node-API addon,并保存许可证与 SPDX SBOM。 - 汇聚为一个 facade、一个 model 和四个 native packages,执行两次 `npm pack` 并要求 tarball SHA-256 完全一致。 - 在 macOS arm64/x64、Linux x64 glibc、Windows x64 上分别使用 Node.js 22 和 24,从本地 tarballs 执行 `--ignore-scripts` 安装、CJS/ESM bounded OCR、单次 tiled contract/结果 smoke 与 TypeScript compile test。 @@ -218,6 +219,20 @@ gh workflow run tiled-qualification.yml --ref main -f run_benchmark=true benchmark 结果是独立资格审查证据,不是每次发布的重复步骤。需要建立或更新 accepted baseline 时,仍须人工 review 并作为源码提交;脚本不会自动接受当前值。`promote_latest` 默认为 `false`,需要在 registry evidence 人工核对后显式选择。 +Apple provider 另使用显式双设备资格 workflow;它在标准 `macos-15` +(M1) 与 arm64 larger `macos-15-xlarge` (M2) 上消费同一模型 artifact,逐一运行 +91-function placement、5 个哨兵宽度 tensor parity、14-fixture 质量、两 workload 性能/CPU-time、并发空缓存、 +cold start/RSS 和 100 次生命周期 Gate: + +```bash +gh workflow run apple-qualification.yml \ + --ref codex/apple-device-acceleration \ + -f run_qualification=true +``` + +`collect` 只输出 candidate;它会验证模型、质量、性能、缓存和生命周期报告哈希,但仍必须审阅设备身份、报告内容和门槛。审阅后用 +`tools/apple/accept_qualification.py` 生成并提交 `contracts/apple-provider-baselines.json`;npm release 会校验该文件的自身哈希、acceptance、模型身份与至少两个设备族,并只从这个 accepted contract 生成发布 bundle allow-list。 + `.github/workflows/npm-promote.yml` 只负责给已经发布且完整性已验证的 release set 更新 dist-tag。它必须引用原 `npm release` run 保存的 `light-ocr-npm-` artifact,逐包复核 registry integrity,并按 model/native 依赖优先、facade 最后的顺序更新;不会重新构建、测试或发布 tarball。该 workflow 用于人工分阶段 promotion,以及 npm metadata 最终一致性导致主发布 job 在 tag 校验阶段中断后的安全恢复。 Actions 均固定到 commit SHA。D013 之前的四平台 workflow 已通过;bounded/streaming 变更后的发布候选必须重新保留每个 job 的不可变 run/artifact 证据,旧 run 不能替代当前代码。 diff --git a/docs/decisions.md b/docs/decisions.md index f4a99ed..7a62faa 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -128,10 +128,10 @@ Consequence: Six packages release in lockstep. The facade is published last, aft ### D111 — Freeze a provider-neutral execution contract before enabling accelerators -Status: Accepted for Perf-1A; CPU implementation complete, provider qualification pending
-Decision: `EngineOptions.execution` owns the stable provider policy. The default remains `cpu` with `sessionFallback=error`, `cpuPartition=allow`, `performanceHint=latency`, and `precision=auto`. A release exposes only providers that are bundled and have passed the Provider Gate; therefore the current TypeScript `ExecutionProvider` union contains only `cpu`. Unsupported provider, device, precision, partition, fallback, or performance combinations return `invalid_argument` rather than being ignored. `EngineInfo.execution.sessions` reports detection and recognition independently, including requested provider, actual configured provider chain, device, effective precision, shape policy, model identity/hash, runtime/provider version, cache status, and session fallback. The legacy aggregate `executionProvider` remains as a compatibility field while callers migrate.
+Status: Accepted for Perf-1A;Apple provider implementation complete locally, release qualification pending two-device CI
+Decision: `EngineOptions.execution` owns the stable provider policy. The default remains `cpu` with `sessionFallback=error`, `cpuPartition=allow`, `performanceHint=latency`, and `precision=auto`. The 0.2.1 source union adds `apple`: Direct Core ML FP16 routes detector and recognition widths through the qualified ANE/MLCPU envelope, sends recognition widths above 1600 to FP16 GPU, and uses all-GPU execution when CPU partitions are forbidden. Apple requires macOS 15, arm64, batch 1, bounded/960 detection, schema 1.1 provider payloads and an explicitly qualified device-family prefix. `sessionFallback=cpu` is a whole-session creation fallback with a stable reason; runtime inference never retries. Unsupported provider, device, 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, 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 and the ONNX Runtime CPU session is its first implementation. CoreML, DirectML, OpenVINO, CUDA, QNN, `auto`, CPU partition prohibition, and throughput profiles remain unavailable until a provider-specific D111 addendum locks descriptors, distribution, qualification devices, and Gate ceilings. Runtime inference errors never trigger an undeclared CPU retry. +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 are placement-qualified, while runtime inputs round up to 20 locked weighted width buckets under an LRU ceiling of 20. DirectML, OpenVINO, CUDA, QNN, provider `auto`, and throughput profiles remain unavailable. Apple release remains blocked until the locked quality/performance/RSS/cache gates pass on at least two target device families and their report hashes are accepted. ## 3. Deferred decisions diff --git a/docs/implementation-status.md b/docs/implementation-status.md index d1704f1..452be8c 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -1,7 +1,7 @@ # C++ Core 与 Node-API 实施状态 更新时间:2026-07-15
-结论:`@arcships/light-ocr@0.2.0` 已发布并提升为 npm `latest`。它包含 `tiled-v1`、schema 1.2 bundle、八张独立 ground truth、Python oracle、确定性/质量门禁,以及 Node.js 内存 JPEG/PNG 输入;四平台 Core/Node baseline、无 benchmark release preflight、六包 provenance、registry integrity 与禁网运行证据均已保存。0.1.0 及其 bounded/960 行为保持不变,bounded/960 在 0.2.0 中也仍是默认策略。 +结论:`@arcships/light-ocr@0.2.0` 已发布并提升为 npm `latest`。当前 0.2.1 源码候选已实现 Direct Core ML FP16 Apple provider、自包含模型派生物、ANE/GPU 混合路由、缓存/回退和 C++/Node 可观测性;CPU 与 bounded/960 仍是默认。M4 Max 本机全部锁定 Gate 已通过,M1/M2 双设备 workflow 已配置但尚无远端通过证据,因此还不能宣称 Apple provider 已发布。 状态含义: @@ -25,14 +25,14 @@ | 无 network/shell/cwd/locale 运行依赖 | Done | sterile cwd/minimal env 与 Linux network namespace disabled 测试通过;npm release 另完成已安装 package 的禁网运行。 | | manifest、hash、licenses、SBOM、parity、benchmark | Done | Release commit 已重新生成并保存四平台 metadata、六个 npm tarballs 的 hashes/integrity、parity、quality 与 benchmark 证据。 | | N-API/npm 非本 Core milestone | Done / `0.2.0` published | raw Node-API v8、CJS/ESM、`.d.ts`、内置模型解析、四平台 prebuild、双重背压、AbortSignal 与生命周期均已完成;[npm release run 29340467784](https://github.com/arcships/light-ocr/actions/runs/29340467784) 与 [promotion run 29342178842](https://github.com/arcships/light-ocr/actions/runs/29342178842) 保存六包发布、registry 和禁网证据。 | -| Perf-1A execution contract | Done(本地,未发布 accelerator) | provider-neutral `InferenceSession`、`EngineOptions.execution`、detector/recognizer 分 stage `EngineInfo.execution.sessions`、模型 hash/runtime/cache/fallback 诊断和 Node deep-frozen 映射已完成;当前 union 仅含 CPU,Apple/CoreML payload 与 placement qualification 仍 pending。 | +| Perf-1A / Apple execution | Implemented locally / qualification running | provider-neutral `InferenceSession` 已加入 Objective-C++ Direct Core ML;公开 union 为 `cpu | apple`。detector 使用 FP16 range model,recognizer 使用 91-function FP16 MLProgram 完成全宽度放置审查,运行时使用锁定的 20 个加权宽度桶;interactive 为 ANE + 宽文本 GPU,strict 为 GPU,整 session CPU fallback 有稳定原因。schema 1.1 bundle、哈希锁模型、离线编译缓存、跨进程锁、LRU≤20、device/OS/qualification/逐批 route 诊断和 Node 映射均已完成。M1/M2 远端双设备 Gate 尚待运行。 | | Node.js JPEG/PNG 内存输入 | Done / `0.2.0` published | `recognizeEncoded(Uint8Array)` 在 engine worker 上使用固定 stb revision 解码,保持 Core raw-pixel 边界;格式、尺寸、pixels、临时内存、queue/snapshot budget、AbortSignal 与 `timingUs.decode` 均有四平台 Node 22/24 package 测试。 | | 高分辨率峰值内存 | Done | Release 原生独立进程本机参考:2048² 空白 `318.8 MiB ≤ 384 MiB`;xfund 密集表单 116 框 `400.5 MiB ≤ 640 MiB`。四平台 release jobs 的真实模型与 RSS gates 均通过。 | | Tiled 高分辨率准确模式 | Done / `0.2.0` published | 1280 tile、2048→4-pass row-major、全局 candidate ceiling、IoU/IOS greedy merge、原图 recognition、C++/Node contract、8-fixture/196-line corpus、独立 oracle、四平台 36-entry accepted baseline 与 package smoke 均已完成。 | ## 本机最终验证快照 -环境:macOS arm64,Apple Clang 21.0.0,CMake 4.2.1,macOS deployment target 13.3,ONNX Runtime CPU,intra/inter-op threads 均为 1。 +环境:macOS arm64 Apple M4 Max,macOS 26.5.1,Apple Clang 21.0.0,CMake 4.2.1,macOS deployment target 13.3;CPU 使用 ONNX Runtime,Apple 候选使用系统 Core ML。 | 验证 | 结果 | | --- | --- | @@ -46,7 +46,12 @@ | offline contract | sterile cwd/minimal locale environment passed | | model archive | 已发布 `.1`:31,334,400 bytes / `74e246bf…de17`;已发布 tiled `.2`:31,334,400 bytes / `e543b93b…712f` | | Node-API v1 | Node.js 22.13.0;macOS arm64 Release/Werror 构建;CTest 3/3;bounded/exact 映射、真实 PP-OCRv6 API、snapshot/byteOffset、校验、symlink root、双重背压、abort、heartbeat、close/worker teardown 测试通过 | -| Perf-1A local validation | macOS arm64 Release/Werror 构建;Release CTest 22/22(含 15 项 acceptance、2 项 canonical oracle、4 项 memory)和 Node-enabled CTest 3/3;CPU 默认结果不变,逐 session execution summary、未知 provider、FP16、device ID 和无效 fallback 组合均有 C++/Node integration 覆盖 | +| Perf-1A local validation | macOS arm64 Release/Werror 构建;Apple Release CTest 7/7、Node 绑定 16/16、Python Apple/npm 合约 9/9;CPU 默认结果不变,逐 session execution summary、未知 provider、FP16、device ID 和无效 fallback 组合均有 C++/Node integration 覆盖 | +| Apple model placement | detector interactive 为 190 ANE + 2 个已声明 MLCPU 操作,strict 为 192 GPU;recognition 91/91 宽度函数全部通过,宽区间 213 GPU 且无 MLCPU;detector/recognizer 包哈希 `2097bd78…7f76` / `c54a0719…5f4b`,报告 `e9d371db…c7373` | +| Apple quality | 14 fixtures 全部通过;字符相似度 99.6484%,detection recall 100%,平均 IoU 99.5508%,平均置信度差 0.004349,critical failure 0;报告 `0c4d9865…326e` | +| Apple performance | hello / xfund warm P50 为 8.708 / 330.837 ms,相对 CPU-fast 加速 2.287× / 2.808×,CPU time 降低 95.86% / 97.65%;canary cold cache miss 7.289 s、hit 1.285/1.282 s;warm peak RSS 最大 695.97 MiB,bundle 增量 25.42 MiB;报告 `e373a9a4…a983` | +| Apple cache concurrency | 4 进程竞争通过;detector/recognizer 各恰好一个 miss、3 个 hit,结果哈希一致且无临时目录残留;报告 `df0e7b75…5b2c` | +| Apple 100-page lifecycle | 同一 interactive engine 预热 2 页后连续处理 100 个 xfund 密集页;RSS baseline/final/maximum 为 743.28/721.89/745.14 MiB,growth -21.39 MiB,通过 32 MiB 工具门槛和 64 MiB acceptance;报告 `5c20fc47…6a8fb` | | Tiled corpus | 八张 2048² locked fixtures 共 196 行:196 TP / 0 FP / 0 FN、CER 0、duplicate line 0;独立 oracle 与原生 pass tensor、candidate source、suppression、representative、crop、decode 和 final order 对齐;side override、tile ceiling、global candidate ceiling 均返回稳定错误 | | Tiled qualification | [run 29336329115](https://github.com/arcships/light-ocr/actions/runs/29336329115) 四个平台采样 jobs 成功;36 个 Core/Node 22/Node 24 entries 已受审。各平台最大 Core/Node 峰值:Linux x64 639.7/715.6 MiB、Windows x64 616.1/667.5 MiB、macOS arm64 667.4/733.6 MiB、macOS x64 623.1/672.8 MiB | diff --git a/docs/model-bundle.md b/docs/model-bundle.md index 7e66ebf..f2ac58d 100644 --- a/docs/model-bundle.md +++ b/docs/model-bundle.md @@ -1,6 +1,6 @@ # light-ocr Model Bundle -Status: schema 1.2 / `tiled-v1` bundle published in npm `0.2.0`; schema 1.1 remains the immutable `0.1.0` bundle
+Status: normalized schema 1.2 / `tiled-v1` published in npm `0.2.0`; manifest schema 1.1 Apple provider candidate implemented for 0.2.1
Authority: model identity, bundle schema, normalized configuration, integrity, and licensing Requirements: [requirements.md](requirements.md) @@ -16,6 +16,11 @@ recognition: PP-OCRv6_small_rec_onnx text-line orientation: unavailable ``` +The 0.2.1 Apple candidate is a self-contained superset named +`ppocrv6-small-apple-20260715.1`. It preserves the same ONNX CPU payload and +normalized configuration while adding hash-locked FP16 Core ML packages and a +qualified-device allow-list. + PP-OCRv6 tiny is a future independent bundle. PP-OCRv6 medium is architecture-compatible but is not a release target until an official ONNX artifact is pinned and validated. ## 2. Upstream snapshot @@ -80,6 +85,20 @@ ppocrv6-small-onnx-20260714.2/ SHA256SUMS ``` +The Apple bundle additionally contains: + +```text +apple/ + detector-fp16.mlpackage/ + recognizer-fp16.mlpackage/ + provenance.json +``` + +The recognizer is one 91-function MLProgram (`w0320` through `w3200`, step 32), +not 91 resident sessions. The runtime rounds up to one of 20 locked weighted +width buckets and lazily keeps at most 20 selected functions; the detector +accepts the bounded 32–960 range. + `ModelBundle::create` receives the complete directory as immutable in-memory files. It requires every payload named by `manifest.json`, including normalized config, dictionary, both ONNX/YAML pairs, licenses and notice; `SHA256SUMS` is the one permitted external checksum file. Recognition never parses YAML. ## 5. Integrity model @@ -97,7 +116,7 @@ Runtime bundle validation verifies: - Path normalization and uniqueness. - Required files. - Complete `SHA256SUMS` coverage, including `manifest.json`. -- Exact manifest schema `1.0` and Core compatibility. +- Manifest schema `1.0` for CPU-only bundles or `1.1` for the explicit Apple provider payload, plus Core compatibility. - Every manifest payload hash. - Model ID and configuration agreement. - Tensor contract and dictionary identity. @@ -157,7 +176,31 @@ A hash mismatch returns `model_integrity_failed`. A structurally invalid but cor } ``` -The real manifest lists every payload file. Core `0.1.x` and `0.2.0` accept manifest schema `1.0` exactly; normalized configuration evolves independently. A future manifest schema revision requires an explicit Core compatibility decision instead of being silently accepted. +The real manifest lists every payload file. Core `0.1.x` and `0.2.0` accept manifest schema `1.0`; the 0.2.1 source accepts schema `1.1` only when the complete, versioned Apple provider object below validates. Normalized configuration evolves independently. Any other manifest schema revision is rejected instead of being silently accepted. + +### 6.1 Apple provider extension + +Schema 1.1 adds a top-level `providers.apple` object. Its release contract fixes: + +- `minimumMacOS: "15.0"`, `architecture: "arm64"`, a non-empty qualified + Apple Silicon family list, and `qualificationId: "apple-fp16-mixed-20260715.1"`; +- detector package/model/hash/tensor/shape identities, interactive ANE and + strict GPU policies, plus the maximum qualified MLCPU operation envelope; +- recognizer package identity, 32-pixel width multiple, ANE maximum width 1600, + `w%04u` function mapping, all 91 qualified widths, the locked 20 runtime + width buckets, and an LRU ceiling of 20 functions; +- every `.mlpackage` member in the normal manifest inventory and a package-level + inventory hash, so changing either protobuf or weights invalidates the bundle. +- conversion removes the volatile Core ML conversion date, deterministically + serializes every model protobuf, and replaces package entry UUIDs with stable + UUIDv5 identifiers before checking the locked package hashes. + +`qualifiedMLCPUOperations` is a maximum reviewed envelope, not a requirement +that every shape use every listed CPU operation. Qualification rejects unknown +or excess MLCPU operations, missing ANE placement below the boundary, any CPU +operation on the strict GPU route, incomplete width coverage, or argmax parity +changes. A device family not present in `qualifiedDeviceFamilies` cannot start +Core ML; only an explicit session-level CPU fallback may continue. ## 7. Normalized configuration diff --git a/docs/napi-design.md b/docs/napi-design.md index 5f4485f..6d2709c 100644 --- a/docs/napi-design.md +++ b/docs/napi-design.md @@ -1,6 +1,6 @@ # light-ocr Node-API 适配器设计 -状态:`@arcships/light-ocr@0.2.0` 已发布;Perf-1A execution contract 已实现、尚未发布 accelerator
+状态:`@arcships/light-ocr@0.2.0` 已发布;0.2.1 Apple/Core ML provider 候选已实现并进入资格审查
更新时间:2026-07-15
Authority:JavaScript/TypeScript API、异步调度、内存所有权、Node.js 生命周期与 npm 布局 Core contract:[native-api.md](native-api.md) @@ -43,7 +43,7 @@ Decision:[decisions.md](decisions.md) D101、D105、D111 - install/postinstall 或运行时网络下载、默认目录扫描或模型自动更新。 - 无模型瘦包、按语言拆分模型、tiny/medium/orientation 模型。 - 对运行中的 ONNX Runtime inference 做硬中断或强制超时终止。 -- 发布 GPU/ANE/CUDA/DirectML Execution Provider;provider-neutral 配置与诊断契约已由 D111 接受,但当前只允许 CPU。 +- 发布 CUDA/DirectML 等其他 Execution Provider;本增量只实现 Apple Silicon 上受资格约束的 Core ML ANE/GPU 路由。 - Electron、Bun、Deno 或浏览器支持声明。 - Linux musl、Linux arm64、Windows arm64。 - 跨进程共享 engine、跨 Node.js Environment 传递 engine。 @@ -86,7 +86,7 @@ export interface DetectionOptions { readonly maxSide?: number; } -export type ExecutionProvider = "cpu"; +export type ExecutionProvider = "cpu" | "apple"; export type SessionFallback = "error" | "cpu"; export type CpuPartition = "allow" | "forbid"; export type PerformanceHint = "latency" | "throughput"; @@ -182,6 +182,9 @@ export interface Diagnostics { readonly batchSize: number; readonly height: number; readonly width: number; + readonly computeUnit: "cpu" | "ane" | "gpu"; + readonly modelId: string; + readonly shapeBucket: string; }[]; } @@ -213,6 +216,8 @@ export interface SessionExecutionInfo { readonly requestedProvider: string; readonly actualProviderChain: readonly string[]; readonly device: string; + readonly deviceFamily: string; + readonly operatingSystem: string; readonly precision: string; readonly shapePolicy: string; readonly modelId: string; @@ -221,6 +226,7 @@ export interface SessionExecutionInfo { readonly runtimeVersion: string; readonly providerVersion: string; readonly modelCacheStatus: string; + readonly qualificationId: string; readonly sessionFallback: boolean; readonly fallbackReason?: string; } @@ -323,11 +329,11 @@ export interface OcrEngine { export function createEngine(options?: CreateEngineOptions): Promise; ``` -`SessionExecutionInfo` 分别保存 requested provider、实际配置的 provider chain、device、有效 precision、shape policy、模型 ID/SHA-256、runtime/provider version、model cache status,以及是否发生 session fallback 和稳定原因。provider chain 只证明 session 配置,不能替代逐节点 compute-plan/profiling 证据。 +`SessionExecutionInfo` 分别保存 requested provider、实际配置的 provider chain、device/device family/OS、有效 precision、shape policy、模型 ID/SHA-256、runtime/provider version、model cache status、qualification ID,以及是否发生 session fallback 和稳定原因。`recognitionBatchShapes` 进一步报告每个请求使用的 Core ML function bucket 和 ANE/GPU/CPU 路由。provider chain 只证明 session 配置,不能替代逐函数 Compute Plan 证据。 `Buffer` 是 `Uint8Array` 的子类,因此可以直接作为 `RawImage.data` 或 `recognizeEncoded()` 输入。不接受 `DataView`、其他 TypedArray 或以 `SharedArrayBuffer` 为 backing store 的 `Uint8Array`。 -`OcrEngine` 没有 public constructor,只能由成功的 `createEngine` 创建。未传 `model`/`bundlePath` 时默认使用内置 `ppocrv6-small`;二者同时出现是 `invalid_argument`。`execution` 默认选择 CPU;当前 `.d.ts` 只把 `cpu` 放入 provider union,且不支持的 FP16、device、partition、fallback 或 throughput 组合稳定失败。`reducedLimits` 一旦提供就必须包含全部八个字段;适配器把 Core 固定的 `maxConcurrentCalls=1` 补入 native options。所有配置对象拒绝未知 own property,避免拼写错误被静默忽略。预期的参数、package、I/O、Core 和队列错误都通过 Promise rejection 返回 `OcrError`;取消按 `AbortSignal.reason` 拒绝,默认 `AbortController.abort()` 因而得到标准 `AbortError`。只有非法 receiver、Node-API 无法创建 Promise 或不可恢复的运行时故障可能同步抛出。 +`OcrEngine` 没有 public constructor,只能由成功的 `createEngine` 创建。未传 `model`/`bundlePath` 时默认使用内置 `ppocrv6-small`;二者同时出现是 `invalid_argument`。`execution` 默认选择 CPU;Apple 需要自包含 Apple bundle,接受 `fp16`、`latency`、batch 1 和 bounded detection,并按 `sessionFallback` 决定稳定失败或整 session CPU 回退。`reducedLimits` 一旦提供就必须包含全部八个字段;适配器把 Core 固定的 `maxConcurrentCalls=1` 补入 native options。所有配置对象拒绝未知 own property,避免拼写错误被静默忽略。预期的参数、package、I/O、Core 和队列错误都通过 Promise rejection 返回 `OcrError`;取消按 `AbortSignal.reason` 拒绝,默认 `AbortController.abort()` 因而得到标准 `AbortError`。只有非法 receiver、Node-API 无法创建 Promise 或不可恢复的运行时故障可能同步抛出。 ### 3.1 使用示例 diff --git a/docs/native-api.md b/docs/native-api.md index 01532ff..1c91a32 100644 --- a/docs/native-api.md +++ b/docs/native-api.md @@ -1,11 +1,11 @@ # light-ocr Native C++ API -Status: Core 0.2.0 tiled source contract published with `@arcships/light-ocr@0.2.0`
+Status: Core 0.2.0 tiled contract published;0.2.1 Apple provider source candidate implemented and under qualification
Authority: public C++ source contract, ownership, lifecycle, errors, and compatibility Requirements: [requirements.md](requirements.md) Architecture: [architecture.md](architecture.md) -The declarations below track the current source tree. Version 0.2.0 publishes the additive `DetectionStrategy::tiled` contract; bounded/960 remains the default and 0.1.0 retains only bounded/upstream detection. +The declarations below track the current source tree. Version 0.2.0 publishes the additive `DetectionStrategy::tiled` contract; the 0.2.1 candidate adds an opt-in Apple provider without changing the CPU/bounded default. ## 1. Scope @@ -194,6 +194,9 @@ struct RecognitionBatchShape { std::uint32_t batch_size = 0; std::uint32_t height = 0; std::uint32_t width = 0; + std::string compute_unit; + std::string model_id; + std::string shape_bucket; }; struct DetectionPassShape { @@ -286,7 +289,7 @@ struct DetectionOptions { std::optional max_side; }; -enum class ExecutionProvider { cpu }; +enum class ExecutionProvider { cpu, apple }; enum class SessionFallback { error, cpu }; enum class CpuPartition { allow, forbid }; enum class PerformanceHint { latency, throughput }; @@ -325,8 +328,10 @@ struct RecognizeOptions { Rules: - Thread counts are positive and fixed at creation. -- The current release accepts only the default CPU execution policy. Explicit `fp32` is equivalent to `auto`; accelerator provider names, `fp16`, `device_id`, `cpuPartition=forbid`, `sessionFallback=cpu`, and the unqualified throughput hint fail with `invalid_argument` instead of being ignored. -- Provider-specific values are added only after their self-contained release payload and qualification Gate are accepted. Runtime failures do not retry on CPU. +- CPU remains the default. It accepts `auto`/`fp32`, requires `cpuPartition=allow`, `sessionFallback=error`, and uses the existing ONNX Runtime path. +- The Apple provider accepts `auto`/`fp16`, bounded detection no larger than 960, recognition batch 1, and latency mode. `cpuPartition=allow` selects the qualified FP16 ANE path plus the width-based FP16 GPU route; `cpuPartition=forbid` selects the all-GPU strict path used for qualification. +- `sessionFallback=cpu` permits one explicit whole-session fallback only when the Apple build, device family, or Core ML initialization is unavailable. The chosen CPU sessions report `session_fallback=true` and one of the stable reasons `apple_provider_not_built`, `apple_device_unavailable`, `apple_device_unqualified`, or `apple_initialization_failed`. Inference-time failures never retry on CPU. +- Apple execution requires a schema 1.1 bundle containing the hash-locked provider payload. Unsupported device IDs, throughput mode, precision/provider combinations, detection strategies, and batch sizes fail instead of being ignored. - Score thresholds are finite and in `[0, 1]`. - Batch sizes are positive and no larger than the effective limit. - `bounded` defaults to side 960; its side is a positive 32 multiple no larger than the effective detection ceiling. @@ -374,6 +379,8 @@ struct SessionExecutionInfo { std::string requested_provider; std::vector actual_provider_chain; std::string device; + std::string device_family; + std::string operating_system; std::string precision; std::string shape_policy; std::string model_id; @@ -382,6 +389,7 @@ struct SessionExecutionInfo { std::string runtime_version; std::string provider_version; std::string model_cache_status; + std::string qualification_id; bool session_fallback = false; std::optional fallback_reason; }; @@ -422,7 +430,7 @@ struct EngineInfo { } // namespace light_ocr ``` -`info` is an immutable creation snapshot. The returned reference remains valid until the engine object is destroyed, including after `close`. `provider_capabilities` distinguishes a provider included in the package from one available on the current device; each session then records what was actually configured. An ORT provider chain is configuration evidence, not proof of per-node device placement. Accelerator qualification records compute-plan/profiling evidence separately. +`info` is an immutable creation snapshot. The returned reference remains valid until the engine object is destroyed, including after `close`. `provider_capabilities` distinguishes a provider included in the package from one qualified on the current device; each session then records what was actually configured. `RecognitionBatchShape` adds the per-request model/function bucket and ANE/GPU/CPU route. A configured provider chain is not itself placement proof: the Apple release gate separately checks every Core ML function with Compute Plan evidence and binds the result through `qualification_id`. ## 9. Engine API diff --git a/docs/npm-packaging.md b/docs/npm-packaging.md index ab5a960..9855491 100644 --- a/docs/npm-packaging.md +++ b/docs/npm-packaging.md @@ -1,7 +1,7 @@ # @arcships/light-ocr npm Package Design -状态:六包设计与 `0.2.0` lockstep 发布已完成;[发布证据](releases/npm-0.2.0.md)
-更新时间:2026-07-14
+状态:六包设计与 `0.2.0` lockstep 发布已完成;0.2.1 Apple superset bundle release candidate 已接入同一流程
+更新时间:2026-07-15
Authority:npm 包名、包拆分、依赖关系、内置模型、版本与发布门槛
Node API:[napi-design.md](napi-design.md)
Model contract:[model-bundle.md](model-bundle.md)
@@ -9,6 +9,12 @@ Decision:[decisions.md](decisions.md) D105 0.2.0 继续使用本文六包 lockstep 规则,并强校验 schema 1.2、`tiled-v1`、新 bundle ID、minimum package version,以及 native package 中 JPEG/PNG decoder 的 license/SBOM identity。额外的类型、四平台基线和发布证据见 [Tiled Detection 技术设计与验收规格](tiled-design-and-acceptance.md)。这些增量不改变已发布 `0.1.0` 的不可变包内容。 +0.2.1 候选不增加第七个包或第二个安装入口。model package 改为 +`ppocrv6-small-apple-20260715.1` 自包含 superset:所有平台继续使用其中的 +ONNX CPU payload,只有通过 allow-list 的 macOS arm64 才能显式请求 Core ML。 +release workflow 在 macOS 以哈希锁 Python 3.12 工具链派生固定 Core ML 工件, +Linux assemble job 只消费该 artifact;运行时和 postinstall 都不转换或下载模型。 + ## 1. 用户契约 v1 的唯一推荐安装入口是: @@ -42,7 +48,7 @@ const engine = await createEngine(); | 包 | 类型 | 内容 | 安装关系 | | --- | --- | --- | --- | | `@arcships/light-ocr` | facade | CJS、ESM、TypeScript types、平台与模型解析器 | 用户直接安装 | -| `@arcships/light-ocr-model-ppocrv6-small` | model | 完整 `ppocrv6-small-onnx-20260714.2` bundle、模型 license、可解析的 manifest subpath | facade 的普通 dependency | +| `@arcships/light-ocr-model-ppocrv6-small` | model | 0.2.0 为 CPU bundle;0.2.1 候选为包含同一 ONNX payload 与 Core ML FP16 工件的 `ppocrv6-small-apple-20260715.1`、模型 license、可解析 manifest | facade 的普通 dependency | | `@arcships/light-ocr-darwin-arm64` | native | arm64 `.node`、ONNX Runtime dylib、licenses、SBOM、hashes | facade 的 optional dependency | | `@arcships/light-ocr-darwin-x64` | native | x64 `.node`、ONNX Runtime dylib、licenses、SBOM、hashes | facade 的 optional dependency | | `@arcships/light-ocr-win32-x64` | native | x64 `.node`、`onnxruntime.dll`、licenses、SBOM、hashes | facade 的 optional dependency | diff --git a/include/light_ocr/types.hpp b/include/light_ocr/types.hpp index 627bb08..5acb6db 100644 --- a/include/light_ocr/types.hpp +++ b/include/light_ocr/types.hpp @@ -13,7 +13,7 @@ enum class PixelFormat { gray8, rgb8, bgr8, rgba8 }; enum class DetectionStrategy { bounded, tiled, upstream_exact }; -enum class ExecutionProvider { cpu }; +enum class ExecutionProvider { cpu, apple }; enum class SessionFallback { error, cpu }; @@ -63,6 +63,9 @@ struct RecognitionBatchShape { std::uint32_t batch_size = 0; std::uint32_t height = 0; std::uint32_t width = 0; + std::string compute_unit; + std::string model_id; + std::string shape_bucket; }; struct DetectionPassShape { @@ -186,6 +189,8 @@ struct SessionExecutionInfo { std::string requested_provider; std::vector actual_provider_chain; std::string device; + std::string device_family; + std::string operating_system; std::string precision; std::string shape_policy; std::string model_id; @@ -194,6 +199,7 @@ struct SessionExecutionInfo { std::string runtime_version; std::string provider_version; std::string model_cache_status; + std::string qualification_id; bool session_fallback = false; std::optional fallback_reason; }; diff --git a/src/core/engine.cpp b/src/core/engine.cpp index 9c44e1a..c298d4a 100644 --- a/src/core/engine.cpp +++ b/src/core/engine.cpp @@ -17,6 +17,9 @@ #include "detection/tiled.hpp" #include "geometry/geometry.hpp" #include "inference/backend.hpp" +#if defined(LIGHT_OCR_HAS_COREML) +#include "inference/coreml/backend.hpp" +#endif #include "inference/onnxruntime/backend.hpp" #include "model/bundle_data.hpp" #include "preprocess/image.hpp" @@ -39,6 +42,11 @@ std::uint64_t elapsed_us(Clock::time_point begin, Clock::time_point end) { std::chrono::duration_cast(end - begin).count()); } +std::string apple_recognition_function_name(std::uint32_t width) { + const auto value = std::to_string(width); + return "w" + std::string(4 - value.size(), '0') + value; +} + template Result failure(ErrorCode code, const char* message, std::string detail = {}) { return Result::failure(Error{code, message, std::move(detail)}); @@ -66,13 +74,56 @@ bool valid_limits(const ResourceLimits& value, const ResourceLimits& ceiling) { } bool valid_execution_options(const ExecutionOptions& options) { - return options.provider == ExecutionProvider::cpu && - options.session_fallback == SessionFallback::error && - options.cpu_partition == CpuPartition::allow && - !options.device_id.has_value() && - options.performance_hint == PerformanceHint::latency && + if (options.device_id.has_value() || + options.performance_hint != PerformanceHint::latency) { + return false; + } + if (options.provider == ExecutionProvider::cpu) { + return options.session_fallback == SessionFallback::error && + options.cpu_partition == CpuPartition::allow && + (options.precision == Precision::automatic || + options.precision == Precision::fp32); + } + return options.provider == ExecutionProvider::apple && + (options.session_fallback == SessionFallback::error || + options.session_fallback == SessionFallback::cpu) && + (options.cpu_partition == CpuPartition::allow || + options.cpu_partition == CpuPartition::forbid) && (options.precision == Precision::automatic || - options.precision == Precision::fp32); + options.precision == Precision::fp16); +} + +internal::AppleModelPackage make_apple_package( + const internal::BundleData& bundle, + const internal::AppleModelConfig& model, + const internal::AppleProviderConfig& provider, + bool recognition) { + internal::AppleModelPackage package; + package.root_path = model.package_path; + package.package_sha256 = model.package_sha256; + package.input_name = model.input_name; + package.output_name = model.output_name; + package.qualification_id = provider.qualification_id; + package.qualified_device_families = provider.qualified_device_families; + const auto prefix = model.package_path + "/"; + for (const auto& file : bundle.files) { + if (file.first.compare(0, prefix.size(), prefix) == 0) { + package.files.push_back(internal::ModelPackageFile{ + file.first.substr(prefix.size()), file.second}); + } + } + std::sort(package.files.begin(), package.files.end(), + [](const auto& left, const auto& right) { + return left.path < right.path; + }); + if (recognition) { + package.recognition_width_multiple = + provider.recognition_width_multiple; + package.recognition_ane_maximum_width = + provider.recognition_ane_maximum_width; + package.maximum_cached_functions = provider.maximum_cached_functions; + } + return package; } class EngineImpl final : public Engine { @@ -80,11 +131,16 @@ class EngineImpl final : public Engine { EngineImpl(std::shared_ptr bundle, std::unique_ptr detection, std::unique_ptr recognition, - EngineInfo info) + EngineInfo info, std::uint32_t recognition_width_multiple, + std::vector recognition_width_buckets, + std::uint32_t maximum_backend_batch_size) : bundle_(std::move(bundle)), detection_(std::move(detection)), recognition_(std::move(recognition)), - info_(std::move(info)) {} + info_(std::move(info)), + recognition_width_multiple_(recognition_width_multiple), + recognition_width_buckets_(std::move(recognition_width_buckets)), + maximum_backend_batch_size_(maximum_backend_batch_size) {} ~EngineImpl() noexcept override { close(); } @@ -127,6 +183,7 @@ class EngineImpl final : public Engine { info_.detection_max_side); if (!valid_score(score_threshold) || batch_size == 0 || batch_size > info_.limits.max_recognition_batch_size || + batch_size > maximum_backend_batch_size_ || detection_max_side == 0 || detection_max_side > info_.detection_max_side || (info_.detection_strategy != DetectionStrategy::bounded && @@ -346,7 +403,8 @@ class EngineImpl final : public Engine { internal::sort_reading_order(std::move(detected.boxes), bundle_->geometry); auto plans_result = internal::plan_recognition_batches( sorted_boxes, bundle_->geometry, bundle_->recognition, batch_size, - info_.limits); + info_.limits, recognition_width_multiple_, + recognition_width_buckets_); stage_end = Clock::now(); timing.crop_and_sort_us = elapsed_us(stage_begin, stage_end); if (!plans_result) { @@ -396,7 +454,8 @@ class EngineImpl final : public Engine { recognition_limits.max_temporary_bytes -= crop_bytes; stage_begin = Clock::now(); auto batch_result = internal::make_recognition_batch( - crops, plan, bundle_->recognition, recognition_limits); + crops, plan, bundle_->recognition, recognition_limits, + recognition_width_multiple_, recognition_width_buckets_); stage_end = Clock::now(); timing.recognition_preprocess_us += elapsed_us(stage_begin, stage_end); if (!batch_result) { @@ -404,10 +463,22 @@ class EngineImpl final : public Engine { } auto batch = std::move(batch_result).value(); if (options.include_diagnostics) { + const auto width = static_cast(batch.shape[3]); + const bool coreml = + info_.execution.recognition.runtime == "Core ML"; + const bool gpu = + coreml && + (info_.execution.cpu_partition == CpuPartition::forbid || + width > bundle_->apple_provider->recognition_ane_maximum_width); recognition_batch_shapes.push_back( RecognitionBatchShape{static_cast(batch.shape[0]), static_cast(batch.shape[2]), - static_cast(batch.shape[3])}); + width, + coreml ? (gpu ? "gpu" : "ane") : "cpu", + info_.execution.recognition.model_id, + coreml + ? apple_recognition_function_name(width) + : "dynamic"}); } std::vector().swap(crops); @@ -491,6 +562,9 @@ class EngineImpl final : public Engine { std::unique_ptr detection_; std::unique_ptr recognition_; EngineInfo info_; + std::uint32_t recognition_width_multiple_ = 1; + std::vector recognition_width_buckets_; + std::uint32_t maximum_backend_batch_size_ = 1; mutable std::mutex state_mutex_; std::condition_variable state_changed_; bool active_ = false; @@ -515,7 +589,13 @@ Result> Engine::create(ModelBundle bundle, if (!valid_execution_options(options.execution)) { return failure>( ErrorCode::invalid_argument, - "Execution options are unsupported by the bundled CPU backend"); + "Execution options are unsupported"); + } + if (options.execution.provider == ExecutionProvider::apple && + !bundle.data_->apple_provider) { + return failure>( + ErrorCode::unsupported_capability, + "The model bundle does not include the Apple provider payload"); } auto limits = options.reduced_limits.value_or(bundle.data_->limits); if (!valid_limits(limits, bundle.data_->limits)) { @@ -528,7 +608,9 @@ Result> Engine::create(ModelBundle bundle, bundle.data_->recognition.default_batch_size); if (!valid_score(score_threshold) || batch_size == 0 || batch_size > limits.max_recognition_batch_size || - batch_size > bundle.data_->recognition.maximum_batch_size) { + batch_size > bundle.data_->recognition.maximum_batch_size || + (options.execution.provider == ExecutionProvider::apple && + batch_size != 1)) { return failure>(ErrorCode::invalid_argument, "Engine recognition defaults are outside limits"); } @@ -540,6 +622,12 @@ Result> Engine::create(ModelBundle bundle, ErrorCode::unsupported_capability, "Tiled detection is unavailable in this bundle"); } + if (options.execution.provider == ExecutionProvider::apple && + detection_strategy != DetectionStrategy::bounded) { + return failure>( + ErrorCode::invalid_argument, + "The Apple provider requires bounded detection"); + } const auto default_detection_max_side = detection_strategy == bundle.data_->default_detection_strategy ? bundle.data_->default_detection_max_side @@ -571,6 +659,12 @@ Result> Engine::create(ModelBundle bundle, ErrorCode::invalid_argument, "Engine detection defaults are outside limits"); } + if (options.execution.provider == ExecutionProvider::apple && + detection_max_side > 960) { + return failure>( + ErrorCode::invalid_argument, + "The Apple detector is qualified only through side length 960"); + } const auto& detection_bytes = bundle.data_->files.at(bundle.data_->detection_model_path); const auto& recognition_bytes = bundle.data_->files.at(bundle.data_->recognition_model_path); internal::InferenceSessionConfig detection_config; @@ -588,13 +682,124 @@ Result> Engine::create(ModelBundle bundle, auto recognition_config = detection_config; recognition_config.model_id = bundle.data_->recognition_model_id; recognition_config.model_sha256 = bundle.data_->recognition_model_sha256; - auto detection = internal::OnnxSession::create( - detection_bytes, detection_config, internal::ModelKind::detection); - if (!detection) return Result>::failure(detection.error()); - auto recognition = internal::OnnxSession::create( - recognition_bytes, recognition_config, internal::ModelKind::recognition, - bundle.data_->recognition.characters.size() + 1); - if (!recognition) return Result>::failure(recognition.error()); + std::unique_ptr detection; + std::unique_ptr recognition; + std::uint32_t recognition_width_multiple = 1; + std::vector recognition_width_buckets; + std::uint32_t maximum_backend_batch_size = + bundle.data_->recognition.maximum_batch_size; + bool apple_device_qualified = false; +#if defined(LIGHT_OCR_HAS_COREML) + const bool apple_device_available = internal::coreml_device_available(); + apple_device_qualified = + apple_device_available && bundle.data_->apple_provider && + internal::coreml_device_is_qualified( + bundle.data_->apple_provider->qualified_device_families); +#endif + + auto create_cpu_sessions = [&](bool fallback, + std::optional fallback_reason) + -> std::optional { + auto cpu_detection_config = detection_config; + cpu_detection_config.provider = ExecutionProvider::cpu; + cpu_detection_config.session_fallback = SessionFallback::error; + cpu_detection_config.cpu_partition = CpuPartition::allow; + cpu_detection_config.precision = Precision::fp32; + cpu_detection_config.model_id = bundle.data_->detection_model_id; + cpu_detection_config.model_sha256 = bundle.data_->detection_model_sha256; + cpu_detection_config.shape_policy = "dynamic"; + cpu_detection_config.apple_package.reset(); + cpu_detection_config.requested_provider_override = fallback ? "apple" : ""; + cpu_detection_config.session_fallback_used = fallback; + cpu_detection_config.fallback_reason = fallback_reason; + auto cpu_recognition_config = cpu_detection_config; + cpu_recognition_config.model_id = bundle.data_->recognition_model_id; + cpu_recognition_config.model_sha256 = bundle.data_->recognition_model_sha256; + auto cpu_detection = internal::OnnxSession::create( + detection_bytes, cpu_detection_config, internal::ModelKind::detection); + if (!cpu_detection) return cpu_detection.error(); + auto cpu_recognition = internal::OnnxSession::create( + recognition_bytes, cpu_recognition_config, + internal::ModelKind::recognition, + bundle.data_->recognition.characters.size() + 1); + if (!cpu_recognition) return cpu_recognition.error(); + detection = std::move(cpu_detection).value(); + recognition = std::move(cpu_recognition).value(); + recognition_width_multiple = 1; + recognition_width_buckets.clear(); + maximum_backend_batch_size = + bundle.data_->recognition.maximum_batch_size; + return std::nullopt; + }; + + if (options.execution.provider == ExecutionProvider::cpu) { + const auto error = create_cpu_sessions(false, std::nullopt); + if (error) return Result>::failure(*error); + } else { +#if defined(LIGHT_OCR_HAS_COREML) + std::optional apple_error; + if (apple_device_qualified) { + const auto& apple = *bundle.data_->apple_provider; + detection_config.model_id = apple.detection.model_id; + detection_config.model_sha256 = apple.detection.package_sha256; + detection_config.shape_policy = apple.detection.shape_policy; + detection_config.apple_package = make_apple_package( + *bundle.data_, apple.detection, apple, false); + recognition_config.model_id = apple.recognition.model_id; + recognition_config.model_sha256 = apple.recognition.package_sha256; + recognition_config.shape_policy = apple.recognition.shape_policy; + recognition_config.apple_package = make_apple_package( + *bundle.data_, apple.recognition, apple, true); + auto apple_detection = internal::CoreMlSession::create( + detection_config, internal::ModelKind::detection); + if (!apple_detection) { + apple_error = apple_detection.error(); + } else { + auto apple_recognition = internal::CoreMlSession::create( + recognition_config, internal::ModelKind::recognition); + if (!apple_recognition) { + apple_error = apple_recognition.error(); + } else { + detection = std::move(apple_detection).value(); + recognition = std::move(apple_recognition).value(); + recognition_width_multiple = apple.recognition_width_multiple; + recognition_width_buckets = + apple.recognition_runtime_width_buckets; + maximum_backend_batch_size = 1; + } + } + } else { + apple_error = Error{ErrorCode::unsupported_capability, + "The Apple provider is unavailable on this device", + {}}; + } + if (apple_error) { + if (options.execution.session_fallback != SessionFallback::cpu) { + return Result>::failure(*apple_error); + } + const auto fallback_reason = + apple_device_qualified + ? "apple_initialization_failed" + : apple_device_available ? "apple_device_unqualified" + : "apple_device_unavailable"; + const auto fallback_error = create_cpu_sessions(true, fallback_reason); + if (fallback_error) { + return Result>::failure(*fallback_error); + } + } +#else + if (options.execution.session_fallback != SessionFallback::cpu) { + return failure>( + ErrorCode::unsupported_capability, + "The Apple provider is unavailable in this build"); + } + const auto fallback_error = create_cpu_sessions( + true, "apple_provider_not_built"); + if (fallback_error) { + return Result>::failure(*fallback_error); + } +#endif + } EngineInfo info; info.core_version = LIGHT_OCR_VERSION; @@ -602,9 +807,12 @@ Result> Engine::create(ModelBundle bundle, info.model_bundle_schema_version = bundle.data_->schema_version; info.normalized_config_schema_version = bundle.data_->normalized_config_schema_version; - info.backend = detection.value()->execution_info().runtime + " " + - detection.value()->execution_info().runtime_version; - info.execution_provider = "CPUExecutionProvider"; + info.backend = detection->execution_info().runtime + " " + + detection->execution_info().runtime_version; + info.execution_provider = + detection->execution_info().runtime == "Core ML" + ? "CoreML" + : "CPUExecutionProvider"; info.execution.requested_provider = options.execution.provider; info.execution.session_fallback = options.execution.session_fallback; info.execution.cpu_partition = options.execution.cpu_partition; @@ -613,8 +821,12 @@ Result> Engine::create(ModelBundle bundle, info.execution.requested_precision = options.execution.precision; info.execution.provider_capabilities = { ProviderCapabilityInfo{"cpu", true, true}}; - info.execution.detection = detection.value()->execution_info(); - info.execution.recognition = recognition.value()->execution_info(); + if (bundle.data_->apple_provider) { + info.execution.provider_capabilities.push_back( + ProviderCapabilityInfo{"apple", true, apple_device_qualified}); + } + info.execution.detection = detection->execution_info(); + info.execution.recognition = recognition->execution_info(); info.capabilities = bundle.data_->capabilities; info.limits = limits; info.intra_op_threads = options.intra_op_threads; @@ -631,9 +843,14 @@ Result> Engine::create(ModelBundle bundle, } info.default_recognition_score_threshold = score_threshold; info.default_recognition_batch_size = batch_size; + auto runtime_bundle = + std::make_shared(*bundle.data_); + runtime_bundle->files.clear(); return Result>::success(std::unique_ptr(new EngineImpl( - std::move(bundle.data_), std::move(detection).value(), std::move(recognition).value(), - std::move(info)))); + std::move(runtime_bundle), std::move(detection), std::move(recognition), + std::move(info), recognition_width_multiple, + std::move(recognition_width_buckets), + maximum_backend_batch_size))); } catch (const std::exception& exception) { return failure>(ErrorCode::runtime_initialization_failed, "Unexpected engine initialization failure", diff --git a/src/inference/backend.hpp b/src/inference/backend.hpp index 7e2fcab..fbaae6a 100644 --- a/src/inference/backend.hpp +++ b/src/inference/backend.hpp @@ -8,11 +8,32 @@ #include #include +#include "light_ocr/core.hpp" #include "light_ocr/error.hpp" #include "light_ocr/types.hpp" namespace light_ocr::internal { +enum class ModelKind { detection, recognition }; + +struct ModelPackageFile { + std::string path; + SharedBytes bytes; +}; + +struct AppleModelPackage { + std::string root_path; + std::string package_sha256; + std::string input_name; + std::string output_name; + std::string qualification_id; + std::vector qualified_device_families; + std::vector files; + std::uint32_t recognition_width_multiple = 1; + std::uint32_t recognition_ane_maximum_width = 0; + std::uint32_t maximum_cached_functions = 1; +}; + struct InferenceSessionConfig { std::uint32_t intra_op_threads = 1; std::uint32_t inter_op_threads = 1; @@ -25,6 +46,10 @@ struct InferenceSessionConfig { std::string model_id; std::string model_sha256; std::string shape_policy; + std::optional apple_package; + std::string requested_provider_override; + bool session_fallback_used = false; + std::optional fallback_reason; }; class TensorOutput { diff --git a/src/inference/coreml/backend.hpp b/src/inference/coreml/backend.hpp new file mode 100644 index 0000000..e12d64e --- /dev/null +++ b/src/inference/coreml/backend.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include + +#include "inference/backend.hpp" + +namespace light_ocr::internal { + +bool coreml_device_available() noexcept; +bool coreml_device_is_qualified( + const std::vector& device_families) noexcept; +std::string coreml_device_description() noexcept; + +class CoreMlSession final : public InferenceSession { + public: + static Result> create( + const InferenceSessionConfig& config, ModelKind kind); + + ~CoreMlSession() noexcept override; + + Result run(const std::vector& values, + const std::vector& shape) noexcept override; + + const SessionExecutionInfo& execution_info() const noexcept override { + return execution_info_; + } + + private: + class Impl; + + CoreMlSession(std::unique_ptr impl, + SessionExecutionInfo execution_info); + + std::unique_ptr impl_; + SessionExecutionInfo execution_info_; +}; + +} // namespace light_ocr::internal diff --git a/src/inference/coreml/backend.mm b/src/inference/coreml/backend.mm new file mode 100644 index 0000000..81e48ef --- /dev/null +++ b/src/inference/coreml/backend.mm @@ -0,0 +1,706 @@ +#include "inference/coreml/backend.hpp" + +#import +#import + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "util/checked_math.hpp" + +namespace light_ocr::internal { +namespace { + +namespace fs = std::filesystem; + +struct PreparedPackage { + fs::path compiled_path; + bool cache_hit = false; +}; + +class AdvisoryLock { + public: + explicit AdvisoryLock(const fs::path& path) { + descriptor_ = ::open(path.c_str(), O_CREAT | O_RDWR, 0600); + if (descriptor_ < 0 || ::flock(descriptor_, LOCK_EX) != 0) { + const auto error = errno; + if (descriptor_ >= 0) ::close(descriptor_); + throw std::system_error(error, std::generic_category(), + "Cannot lock the Core ML cache"); + } + } + + AdvisoryLock(const AdvisoryLock&) = delete; + AdvisoryLock& operator=(const AdvisoryLock&) = delete; + + ~AdvisoryLock() noexcept { + if (descriptor_ >= 0) { + static_cast(::flock(descriptor_, LOCK_UN)); + static_cast(::close(descriptor_)); + } + } + + private: + int descriptor_ = -1; +}; + +std::mutex& package_mutex() { + static std::mutex value; + return value; +} + +template +Result failure(ErrorCode code, const char* message, + std::string detail = {}) { + return Result::failure(Error{code, message, std::move(detail)}); +} + +std::string ns_string(NSString* value) { + if (value == nil) return {}; + const char* utf8 = value.UTF8String; + return utf8 == nullptr ? std::string{} : std::string(utf8); +} + +NSString* to_ns_string(const std::string& value) { + return [NSString stringWithUTF8String:value.c_str()]; +} + +std::string error_detail(NSError* error) { + if (error == nil) return {}; + auto description = ns_string(error.localizedDescription); + const auto reason = ns_string(error.localizedFailureReason); + if (!reason.empty() && reason != description) { + description += description.empty() ? reason : ": " + reason; + } + return description; +} + +std::string sysctl_string(const char* name) { + std::size_t size = 0; + if (::sysctlbyname(name, nullptr, &size, nullptr, 0) != 0 || size <= 1) { + return {}; + } + std::string value(size, '\0'); + if (::sysctlbyname(name, value.data(), &size, nullptr, 0) != 0) return {}; + while (!value.empty() && value.back() == '\0') value.pop_back(); + return value; +} + +fs::path cache_root() { + @autoreleasepool { + NSArray* urls = [[NSFileManager defaultManager] + URLsForDirectory:NSCachesDirectory + inDomains:NSUserDomainMask]; + NSURL* base = urls.firstObject; + if (base == nil) { + base = [NSURL fileURLWithPath:NSTemporaryDirectory() isDirectory:YES]; + } + const auto path = ns_string(base.path); + if (path.empty()) throw std::runtime_error("Core ML cache root is unavailable"); + return fs::path(path) / "com.arcships.light-ocr" / "coreml-v1"; + } +} + +std::string read_text(const fs::path& path) { + std::ifstream stream(path, std::ios::binary); + if (!stream) return {}; + return std::string(std::istreambuf_iterator(stream), + std::istreambuf_iterator()); +} + +void write_bytes(const fs::path& path, const std::uint8_t* data, + std::size_t size) { + fs::create_directories(path.parent_path()); + std::ofstream stream(path, std::ios::binary | std::ios::trunc); + if (!stream) throw std::runtime_error("Cannot create Core ML cache file: " + path.string()); + if (size != 0) { + stream.write(reinterpret_cast(data), + static_cast(size)); + } + stream.close(); + if (!stream) throw std::runtime_error("Cannot write Core ML cache file: " + path.string()); +} + +void write_text(const fs::path& path, const std::string& value) { + write_bytes(path, reinterpret_cast(value.data()), + value.size()); +} + +std::string operating_system_identity() { + @autoreleasepool { + NSProcessInfo* process = [NSProcessInfo processInfo]; + return ns_string(process.operatingSystemVersionString) + "|" + + sysctl_string("kern.osversion") + "|" + sysctl_string("hw.model") + + "|" + sysctl_string("machdep.cpu.brand_string"); + } +} + +PreparedPackage prepare_package(const AppleModelPackage& package) { + std::lock_guard lock(package_mutex()); + const auto root = cache_root(); + fs::create_directories(root); + const AdvisoryLock cross_process_lock( + root / (package.package_sha256 + ".lock")); + const auto package_cache = root / package.package_sha256; + const auto source_path = package_cache / "source.mlpackage"; + const auto source_marker = package_cache / "source.sha256"; + const auto compiled_path = package_cache / "compiled.mlmodelc"; + const auto compiled_marker = package_cache / "compiled.identity"; + const auto compilation_identity = + package.package_sha256 + "\n" + operating_system_identity() + "\n"; + + bool source_hit = fs::is_directory(source_path) && + read_text(source_marker) == package.package_sha256 + "\n"; + if (!source_hit) { + std::error_code ignored; + fs::remove_all(package_cache, ignored); + const auto temporary = root / + (package.package_sha256 + ".tmp." + std::to_string(::getpid())); + fs::remove_all(temporary, ignored); + const auto temporary_source = temporary / "source.mlpackage"; + for (const auto& file : package.files) { + if (!file.bytes || file.path.empty()) { + throw std::runtime_error("Core ML package contains an empty file"); + } + write_bytes(temporary_source / fs::path(file.path), file.bytes->data(), + file.bytes->size()); + } + write_text(temporary / "source.sha256", package.package_sha256 + "\n"); + fs::rename(temporary, package_cache); + } + + if (fs::is_directory(compiled_path) && + read_text(compiled_marker) == compilation_identity) { + return PreparedPackage{compiled_path, true}; + } + + @autoreleasepool { + NSError* error = nil; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + NSURL* compiled = [MLModel + compileModelAtURL:[NSURL fileURLWithPath:to_ns_string(source_path.string()) + isDirectory:YES] + error:&error]; +#pragma clang diagnostic pop + if (compiled == nil) { + throw std::runtime_error("Core ML model compilation failed: " + + error_detail(error)); + } + std::error_code ignored; + const auto temporary_compiled = package_cache / "compiled.tmp.mlmodelc"; + fs::remove_all(temporary_compiled, ignored); + error = nil; + const BOOL copied = [[NSFileManager defaultManager] + copyItemAtURL:compiled + toURL:[NSURL fileURLWithPath:to_ns_string(temporary_compiled.string()) + isDirectory:YES] + error:&error]; + if (!copied) { + throw std::runtime_error("Cannot persist compiled Core ML model: " + + error_detail(error)); + } + fs::remove_all(compiled_path, ignored); + fs::rename(temporary_compiled, compiled_path); + write_text(compiled_marker, compilation_identity); + } + return PreparedPackage{compiled_path, false}; +} + +float half_to_float(std::uint16_t value) { + const std::uint32_t sign = + static_cast(value & 0x8000u) << 16; + std::uint32_t mantissa = value & 0x03ffu; + const std::uint32_t encoded_exponent = (value >> 10) & 0x1fu; + std::uint32_t bits = 0; + if (encoded_exponent == 0) { + if (mantissa == 0) { + bits = sign; + } else { + std::int32_t exponent = 1; + while ((mantissa & 0x0400u) == 0) { + mantissa <<= 1; + --exponent; + } + mantissa &= 0x03ffu; + bits = sign | + (static_cast(exponent + 127 - 15) << 23) | + (mantissa << 13); + } + } else if (encoded_exponent == 0x1fu) { + bits = sign | 0x7f800000u | (mantissa << 13); + } else { + bits = sign | ((encoded_exponent + 127 - 15) << 23) | + (mantissa << 13); + } + float result = 0; + static_assert(sizeof(result) == sizeof(bits), "float must be IEEE-754 binary32"); + std::memcpy(&result, &bits, sizeof(result)); + return result; +} + +NSArray* number_array( + const std::vector& values) { + auto* result = [NSMutableArray arrayWithCapacity:values.size()]; + for (const auto value : values) [result addObject:@(value)]; + return result; +} + +std::vector row_major_strides( + const std::vector& shape) { + std::vector strides(shape.size(), 1); + for (std::size_t index = shape.size(); index > 1; --index) { + if (strides[index - 1] <= 0 || shape[index - 1] <= 0) { + throw std::runtime_error("Core ML input strides overflow"); + } + std::uint64_t next = 0; + if (!checked_mul( + static_cast(strides[index - 1]), + static_cast(shape[index - 1]), &next) || + next > static_cast( + std::numeric_limits::max())) { + throw std::runtime_error("Core ML input strides overflow"); + } + strides[index - 2] = static_cast(next); + } + return strides; +} + +std::string compute_unit_name(MLComputeUnits units) { + return units == MLComputeUnitsCPUAndNeuralEngine ? "ane" : "gpu"; +} + +SessionExecutionInfo make_execution_info( + const InferenceSessionConfig& config, ModelKind kind, + const PreparedPackage& prepared) { + SessionExecutionInfo info; + info.requested_provider = "apple"; + if (kind == ModelKind::detection) { + info.actual_provider_chain = { + config.cpu_partition == CpuPartition::forbid + ? "CoreML(MLGPU)" + : "CoreML(MLNeuralEngine,qualified-MLCPU)"}; + info.device = config.cpu_partition == CpuPartition::forbid ? "gpu" : "ane"; + } else { + info.actual_provider_chain = config.cpu_partition == CpuPartition::forbid + ? std::vector{"CoreML(MLGPU)"} + : std::vector{ + "CoreML(MLNeuralEngine,qualified-MLCPU)", + "CoreML(MLGPU)"}; + info.device = config.cpu_partition == CpuPartition::forbid ? "gpu" : "ane+gpu"; + } + info.precision = "fp16"; + info.device_family = coreml_device_description(); + info.shape_policy = config.shape_policy; + info.model_id = config.model_id; + info.model_sha256 = config.model_sha256; + info.runtime = "Core ML"; + info.runtime_version = ns_string([NSProcessInfo processInfo].operatingSystemVersionString); + info.operating_system = info.runtime_version; + info.provider_version = info.runtime_version; + info.model_cache_status = prepared.cache_hit ? "compiled_cache_hit" : "compiled_cache_miss"; + info.qualification_id = config.apple_package->qualification_id; + return info; +} + +} // namespace + +class CoreMlSession::Impl { + public: + Impl(InferenceSessionConfig config, ModelKind kind, PreparedPackage prepared) + : config_(std::move(config)), + kind_(kind), + compiled_path_(std::move(prepared.compiled_path)), + models_([NSMutableDictionary dictionary]) { + NSError* error = nil; + NSURL* url = [NSURL fileURLWithPath:to_ns_string(compiled_path_.string()) + isDirectory:YES]; + if (@available(macOS 15.0, *)) { + asset_ = [MLModelAsset modelAssetWithURL:url error:&error]; + } + if (asset_ == nil) { + throw std::runtime_error("Core ML failed to open its compiled model asset: " + + error_detail(error)); + } + } + + Result run(const std::vector& values, + const std::vector& shape) { + if (shape.size() != 4 || shape[0] != 1 || shape[1] != 3) { + return failure(ErrorCode::inference_failed, + "Core ML input must be rank-4 NCHW batch 1"); + } + std::uint64_t element_count = 1; + for (const auto dimension : shape) { + if (dimension <= 0 || + !checked_mul( + element_count, static_cast(dimension), + &element_count)) { + return failure(ErrorCode::inference_failed, + "Core ML input shape is invalid"); + } + } + if (element_count != values.size()) { + return failure( + ErrorCode::inference_failed, + "Core ML input size does not match its shape"); + } + if (kind_ == ModelKind::detection) { + if (shape[2] < 32 || shape[2] > 960 || shape[3] < 32 || + shape[3] > 960) { + return failure( + ErrorCode::inference_failed, + "Core ML detection shape is outside the qualified range"); + } + } else { + const auto& package = *config_.apple_package; + if (shape[2] != 48 || shape[3] < 320 || shape[3] > 3200 || + shape[3] % package.recognition_width_multiple != 0) { + return failure( + ErrorCode::inference_failed, + "Core ML recognition width is not a qualified multiple of 32"); + } + } + + @autoreleasepool { + @try { + const auto function_name = + kind_ == ModelKind::detection + ? std::string("main") + : "w" + std::string(4 - std::to_string(shape[3]).size(), '0') + + std::to_string(shape[3]); + MLComputeUnits compute_units = MLComputeUnitsCPUAndGPU; + if (config_.cpu_partition == CpuPartition::allow && + (kind_ == ModelKind::detection || + shape[3] <= config_.apple_package->recognition_ane_maximum_width)) { + compute_units = MLComputeUnitsCPUAndNeuralEngine; + } + auto model_result = model(function_name, compute_units); + if (!model_result) return Result::failure(model_result.error()); + MLModel* selected_model = model_result.value(); + + NSError* error = nil; + const auto strides = row_major_strides(shape); + MLMultiArray* input = [[MLMultiArray alloc] + initWithDataPointer:const_cast(values.data()) + shape:number_array(shape) + dataType:MLMultiArrayDataTypeFloat32 + strides:number_array(strides) + deallocator:nil + error:&error]; + if (input == nil) { + return failure(ErrorCode::inference_failed, + "Core ML rejected the input tensor", + error_detail(error)); + } + NSString* input_name = to_ns_string(config_.apple_package->input_name); + MLDictionaryFeatureProvider* features = + [[MLDictionaryFeatureProvider alloc] + initWithDictionary:@{input_name : input} + error:&error]; + if (features == nil) { + return failure(ErrorCode::inference_failed, + "Core ML rejected the input features", + error_detail(error)); + } + id output = + [selected_model predictionFromFeatures:features error:&error]; + if (output == nil) { + return failure(ErrorCode::inference_failed, + "Core ML prediction failed", + error_detail(error)); + } + NSString* output_name = to_ns_string(config_.apple_package->output_name); + MLMultiArray* array = [output featureValueForName:output_name].multiArrayValue; + if (array == nil || + (array.dataType != MLMultiArrayDataTypeFloat32 && + array.dataType != MLMultiArrayDataTypeFloat16) || + array.count <= 0 || array.shape.count == 0 || + array.shape.count != array.strides.count) { + std::string detail = "nil=" + std::to_string(array == nil) + + ",type=" + std::to_string(array.dataType) + + ",count=" + std::to_string(array.count) + ",shape="; + for (NSNumber* value in array.shape) { + detail += std::to_string(value.longLongValue) + ","; + } + detail += "strides="; + for (NSNumber* value in array.strides) { + detail += std::to_string(value.longLongValue) + ","; + } + return failure( + ErrorCode::inference_failed, + "Core ML output is not a supported tensor", detail); + } + std::vector output_dimensions; + std::vector output_strides; + std::vector output_shape; + output_dimensions.reserve(array.shape.count); + output_strides.reserve(array.strides.count); + output_shape.reserve(array.shape.count); + std::uint64_t physical_elements = 1; + for (NSUInteger index = 0; index < array.shape.count; ++index) { + const auto dimension = array.shape[index].longLongValue; + const auto stride = array.strides[index].longLongValue; + std::uint64_t extent = 0; + if (dimension <= 0 || stride < 0 || + !checked_mul( + static_cast(dimension - 1), + static_cast(stride), &extent) || + !checked_add(physical_elements, extent, + &physical_elements)) { + return failure( + ErrorCode::inference_failed, + "Core ML output shape or strides overflow"); + } + output_dimensions.push_back(static_cast(dimension)); + output_strides.push_back(static_cast(stride)); + output_shape.push_back(dimension); + } + auto storage = std::make_shared>( + static_cast(array.count)); + __block bool copied = false; + [array getBytesWithHandler:^(const void* bytes, NSInteger size) { + const auto element_size = + array.dataType == MLMultiArrayDataTypeFloat32 + ? sizeof(float) + : sizeof(std::uint16_t); + std::uint64_t required = 0; + if (bytes != nullptr && size >= 0 && + checked_mul(physical_elements, element_size, + &required) && + required <= static_cast(size)) { + const bool last_dimension_contiguous = output_strides.back() == 1; + const auto inner = last_dimension_contiguous + ? output_dimensions.back() + : std::uint64_t{1}; + const auto outer = storage->size() / inner; + for (std::uint64_t outer_index = 0; outer_index < outer; + ++outer_index) { + auto coordinates = outer_index; + std::uint64_t source_offset = 0; + const auto prefix = last_dimension_contiguous + ? output_dimensions.size() - 1 + : output_dimensions.size(); + for (std::size_t index = prefix; index > 0; --index) { + const auto dimension = output_dimensions[index - 1]; + source_offset += + (coordinates % dimension) * output_strides[index - 1]; + coordinates /= dimension; + } + auto* destination = storage->data() + outer_index * inner; + if (array.dataType == MLMultiArrayDataTypeFloat32) { + const auto* source = static_cast(bytes) + + source_offset; + std::copy_n(source, inner, destination); + } else { + const auto* source = + static_cast(bytes) + source_offset; + std::transform(source, source + inner, destination, + half_to_float); + } + } + copied = true; + } + }]; + if (!copied) { + return failure(ErrorCode::inference_failed, + "Core ML output storage is truncated"); + } + const auto* data = storage->data(); + const auto size = storage->size(); + return Result::success(TensorOutput( + std::move(storage), data, std::move(output_shape), size)); + } @catch (NSException* exception) { + return failure(ErrorCode::inference_failed, + "Core ML raised an Objective-C exception", + ns_string(exception.reason)); + } + } + } + + private: + Result model(const std::string& function_name, + MLComputeUnits compute_units) { + const auto key = function_name + ":" + compute_unit_name(compute_units); + NSString* ns_key = to_ns_string(key); + MLModel* cached = models_[ns_key]; + if (cached != nil) { + touch(key); + return Result::success(cached); + } + MLModelConfiguration* configuration = [[MLModelConfiguration alloc] init]; + configuration.computeUnits = compute_units; + if (kind_ == ModelKind::recognition) { + if (@available(macOS 15.0, *)) { + configuration.functionName = to_ns_string(function_name); + } else { + return failure(ErrorCode::unsupported_capability, + "Core ML multifunction models require macOS 15"); + } + } + __block MLModel* loaded = nil; + __block NSError* error = nil; + dispatch_semaphore_t completed = dispatch_semaphore_create(0); + [MLModel loadModelAsset:asset_ + configuration:configuration + completionHandler:^(MLModel* model, NSError* model_error) { + loaded = model; + error = model_error; + dispatch_semaphore_signal(completed); + }]; + dispatch_semaphore_wait(completed, DISPATCH_TIME_FOREVER); + if (loaded == nil) { + return failure(ErrorCode::runtime_initialization_failed, + "Core ML failed to load a model function", + key + ": " + error_detail(error)); + } + const auto maximum = std::max( + 1, config_.apple_package->maximum_cached_functions); + while (lru_.size() >= maximum) { + NSString* victim = to_ns_string(lru_.front()); + [models_ removeObjectForKey:victim]; + lru_.erase(lru_.begin()); + } + models_[ns_key] = loaded; + lru_.push_back(key); + return Result::success(loaded); + } + + void touch(const std::string& key) { + const auto found = std::find(lru_.begin(), lru_.end(), key); + if (found != lru_.end()) lru_.erase(found); + lru_.push_back(key); + } + + InferenceSessionConfig config_; + ModelKind kind_; + fs::path compiled_path_; + MLModelAsset* asset_; + NSMutableDictionary* models_; + std::vector lru_; +}; + +bool coreml_device_available() noexcept { + @autoreleasepool { +#if defined(__arm64__) + if (@available(macOS 15.0, *)) { + return true; + } +#endif + return false; + } +} + +std::string coreml_device_description() noexcept { + try { + auto description = sysctl_string("machdep.cpu.brand_string"); + return description.empty() ? "Apple Silicon" : description; + } catch (...) { + return "Apple Silicon"; + } +} + +bool coreml_device_is_qualified( + const std::vector& device_families) noexcept { + try { + const auto device = coreml_device_description(); + return std::any_of( + device_families.begin(), device_families.end(), + [&device](const std::string& family) { + return !family.empty() && device.compare(0, family.size(), family) == 0 && + (device.size() == family.size() || + device[family.size()] == ' '); + }); + } catch (...) { + return false; + } +} + +CoreMlSession::CoreMlSession(std::unique_ptr impl, + SessionExecutionInfo execution_info) + : impl_(std::move(impl)), execution_info_(std::move(execution_info)) {} + +CoreMlSession::~CoreMlSession() noexcept = default; + +Result> CoreMlSession::create( + const InferenceSessionConfig& config, ModelKind kind) { + try { + if (!coreml_device_available()) { + return failure>( + ErrorCode::unsupported_capability, + "The qualified Apple provider requires Apple Silicon and macOS 15"); + } + if (config.provider != ExecutionProvider::apple || !config.apple_package || + (config.precision != Precision::automatic && + config.precision != Precision::fp16) || + config.device_id || config.performance_hint != PerformanceHint::latency || + config.model_id.empty() || config.model_sha256.size() != 64 || + config.shape_policy.empty()) { + return failure>( + ErrorCode::invalid_argument, + "Apple Core ML session options are invalid"); + } + if (!coreml_device_is_qualified( + config.apple_package->qualified_device_families)) { + return failure>( + ErrorCode::unsupported_capability, + "The Apple device family has not passed this model qualification"); + } + const auto prepared = prepare_package(*config.apple_package); + auto info = make_execution_info(config, kind, prepared); + auto runtime_config = config; + runtime_config.apple_package->files.clear(); + auto impl = std::make_unique( + std::move(runtime_config), kind, prepared); + return Result>::success( + std::unique_ptr( + new CoreMlSession(std::move(impl), std::move(info)))); + } catch (const std::exception& exception) { + return failure>( + ErrorCode::runtime_initialization_failed, + "Core ML failed to prepare its model package", exception.what()); + } catch (...) { + return failure>( + ErrorCode::internal_error, + "Unknown Core ML initialization failure"); + } +} + +Result CoreMlSession::run( + const std::vector& values, + const std::vector& shape) noexcept { + try { + if (!impl_) { + return failure(ErrorCode::inference_failed, + "Core ML session is closed"); + } + return impl_->run(values, shape); + } catch (const std::exception& exception) { + return failure(ErrorCode::inference_failed, + "Unexpected Core ML inference failure", + exception.what()); + } catch (...) { + return failure(ErrorCode::internal_error, + "Unknown Core ML inference failure"); + } +} + +} // namespace light_ocr::internal diff --git a/src/inference/onnxruntime/backend.cpp b/src/inference/onnxruntime/backend.cpp index 32f7f20..910639c 100644 --- a/src/inference/onnxruntime/backend.cpp +++ b/src/inference/onnxruntime/backend.cpp @@ -100,7 +100,9 @@ void validate_session_config(const InferenceSessionConfig& config) { SessionExecutionInfo make_execution_info(const InferenceSessionConfig& config) { SessionExecutionInfo info; - info.requested_provider = "cpu"; + info.requested_provider = config.requested_provider_override.empty() + ? "cpu" + : config.requested_provider_override; info.actual_provider_chain = {"CPUExecutionProvider"}; info.device = "cpu"; info.precision = "fp32"; @@ -111,6 +113,8 @@ SessionExecutionInfo make_execution_info(const InferenceSessionConfig& config) { info.runtime_version = Ort::GetVersionString(); info.provider_version = info.runtime_version; info.model_cache_status = "not_applicable"; + info.session_fallback = config.session_fallback_used; + info.fallback_reason = config.fallback_reason; return info; } diff --git a/src/inference/onnxruntime/backend.hpp b/src/inference/onnxruntime/backend.hpp index 7eabf7b..e698c07 100644 --- a/src/inference/onnxruntime/backend.hpp +++ b/src/inference/onnxruntime/backend.hpp @@ -12,8 +12,6 @@ namespace light_ocr::internal { -enum class ModelKind { detection, recognition }; - class OnnxSession final : public InferenceSession { public: static Result> create( diff --git a/src/model/bundle_data.hpp b/src/model/bundle_data.hpp index c93972b..ac36297 100644 --- a/src/model/bundle_data.hpp +++ b/src/model/bundle_data.hpp @@ -63,6 +63,28 @@ struct RecognitionConfig { std::vector characters; }; +struct AppleModelConfig { + std::string model_id; + std::string package_path; + std::string package_sha256; + std::string input_name; + std::string output_name; + std::string shape_policy; +}; + +struct AppleProviderConfig { + std::string minimum_macos; + std::string architecture; + std::vector qualified_device_families; + std::string qualification_id; + AppleModelConfig detection; + AppleModelConfig recognition; + std::uint32_t recognition_width_multiple = 1; + std::uint32_t recognition_ane_maximum_width = 0; + std::vector recognition_runtime_width_buckets; + std::uint32_t maximum_cached_functions = 1; +}; + struct BundleData { std::string id; std::string schema_version; @@ -80,6 +102,7 @@ struct BundleData { std::uint32_t default_detection_max_side = 4'000; GeometryConfig geometry; RecognitionConfig recognition; + std::optional apple_provider; ResourceLimits limits; Capabilities capabilities; }; diff --git a/src/model/model_bundle.cpp b/src/model/model_bundle.cpp index 1557a1a..f5dd3b4 100644 --- a/src/model/model_bundle.cpp +++ b/src/model/model_bundle.cpp @@ -264,6 +264,160 @@ void validate_checksum_inventory(const std::unordered_map& files, + const std::string& root_path) { + const auto prefix = root_path + "/"; + std::vector paths; + for (const auto& file : files) { + if (file.first.compare(0, prefix.size(), prefix) == 0) { + paths.push_back(file.first); + } + } + require(!paths.empty(), "Core ML package contains no files", root_path); + std::sort(paths.begin(), paths.end()); + std::string inventory; + for (const auto& path : paths) { + const auto relative = path.substr(prefix.size()); + require(!relative.empty(), "Core ML package contains an invalid file path", path); + inventory.append(relative); + inventory.push_back('\0'); + const auto& bytes = file_at(files, path); + inventory.append(internal::sha256_hex(bytes->data(), bytes->size())); + inventory.push_back('\n'); + } + return internal::sha256_hex( + reinterpret_cast(inventory.data()), inventory.size()); +} + +internal::AppleModelConfig parse_apple_model( + const Json& model, const std::string& context, + const std::unordered_map& files) { + internal::AppleModelConfig result; + result.model_id = required(model, "modelId", context); + result.package_path = required(model, "packagePath", context); + result.package_sha256 = required(model, "packageSha256", context); + result.input_name = required(model, "inputName", context); + result.output_name = required(model, "outputName", context); + result.shape_policy = required(model, "shapePolicy", context); + require(!result.model_id.empty() && result.model_id.size() <= 128, + "Core ML model ID is invalid", context); + require(is_normalized_path(result.package_path) && + result.package_path.size() > std::string(".mlpackage").size() && + result.package_path.compare( + result.package_path.size() - std::string(".mlpackage").size(), + std::string(".mlpackage").size(), ".mlpackage") == 0, + "Core ML package path is invalid", result.package_path); + require(is_sha256(result.package_sha256), + "Core ML package SHA-256 is invalid", result.package_path); + require(!result.input_name.empty() && !result.output_name.empty() && + !result.shape_policy.empty(), + "Core ML tensor contract is incomplete", context); + file_at(files, result.package_path + "/Manifest.json"); + file_at(files, + result.package_path + "/Data/com.apple.CoreML/model.mlmodel"); + file_at(files, + result.package_path + "/Data/com.apple.CoreML/weights/weight.bin"); + require(package_inventory_sha256(files, result.package_path) == + result.package_sha256, + "Core ML package inventory hash does not match its declaration", + result.package_path); + return result; +} + +std::optional parse_apple_provider( + const Json& manifest, const std::string& schema_version, + const std::unordered_map& files) { + if (!manifest.contains("providers")) return std::nullopt; + require(schema_version == "1.1", + "Provider payload requires manifest schema 1.1", schema_version); + const auto& providers = manifest.at("providers"); + if (!providers.contains("apple")) return std::nullopt; + const auto& apple = providers.at("apple"); + require_string(apple, "schemaVersion", "1.0", "providers.apple"); + internal::AppleProviderConfig result; + result.minimum_macos = + required(apple, "minimumMacOS", "providers.apple"); + result.architecture = + required(apple, "architecture", "providers.apple"); + result.qualified_device_families = required>( + apple, "qualifiedDeviceFamilies", "providers.apple"); + result.qualification_id = + required(apple, "qualificationId", "providers.apple"); + const std::unordered_set supported_device_families = { + "Apple M1", "Apple M2", "Apple M3", "Apple M4"}; + std::unordered_set declared_device_families; + for (const auto& family : result.qualified_device_families) { + require(supported_device_families.count(family) == 1 && + declared_device_families.insert(family).second, + "Apple provider contains an unsupported or duplicate device family", + family); + } + require(result.minimum_macos == "15.0" && result.architecture == "arm64" && + !result.qualified_device_families.empty() && + result.qualified_device_families.size() <= 4 && + !result.qualification_id.empty() && + result.qualification_id.size() <= 128, + "Apple provider platform contract is unsupported"); + result.detection = parse_apple_model( + apple.at("detection"), "providers.apple.detection", files); + result.recognition = parse_apple_model( + apple.at("recognition"), "providers.apple.recognition", files); + const auto& detection = apple.at("detection"); + require(required(detection, "preferredComputeUnit", + "providers.apple.detection") == "ane" && + required(detection, "strictComputeUnit", + "providers.apple.detection") == "gpu" && + required>( + detection, "qualifiedMLCPUOperations", + "providers.apple.detection") == + std::unordered_map{ + {"ios18.relu", 1}, {"pad", 1}} && + result.detection.shape_policy == + "nchw-bounded-range-32-960-v1", + "Apple detection routing contract is unsupported"); + const auto& recognition = apple.at("recognition"); + result.recognition_width_multiple = required_u32( + recognition, "widthMultiple", "providers.apple.recognition"); + result.recognition_ane_maximum_width = required_u32( + recognition, "aneMaximumWidth", "providers.apple.recognition"); + result.recognition_runtime_width_buckets = + required>( + recognition, "runtimeWidthBuckets", "providers.apple.recognition"); + result.maximum_cached_functions = required_u32( + recognition, "maximumCachedFunctions", "providers.apple.recognition"); + const std::vector expected_runtime_width_buckets = { + 320, 384, 480, 544, 576, 608, 704, + 736, 832, 960, 1056, 1184, 1248, 1376, + 1600, 1984, 2240, 2560, 2880, 3200}; + require(result.recognition_width_multiple == 32 && + result.recognition_ane_maximum_width == 1600 && + result.recognition_runtime_width_buckets == + expected_runtime_width_buckets && + result.maximum_cached_functions == + expected_runtime_width_buckets.size() && + required>( + recognition, "qualifiedMLCPUOperations", + "providers.apple.recognition") == + std::unordered_map{ + {"ios18.cast", 1}, {"ios18.conv", 3}, + {"ios18.relu", 3}, {"pad", 3}} && + required(recognition, "functionFormat", + "providers.apple.recognition") == "w%04u" && + result.recognition.shape_policy == + "nchw-static-width-multiple-32-v1", + "Apple recognition routing contract is unsupported"); + const auto widths = required>( + recognition, "widths", "providers.apple.recognition"); + std::vector expected_widths; + for (std::uint32_t width = 320; width <= 3200; width += 32) { + expected_widths.push_back(width); + } + require(widths == expected_widths, + "Apple recognition function inventory is unsupported"); + return result; +} + internal::DetectionConfig parse_detection(const Json& root, const std::string& schema_version) { const auto& detection = root.at("detection"); @@ -566,7 +720,8 @@ std::shared_ptr parse_bundle(std::vector validate_checksum_inventory(files); const auto manifest = parse_json_file(files, "manifest.json", 1024 * 1024); const auto schema_version = required(manifest, "schemaVersion", "manifest"); - require(schema_version == "1.0", "Unsupported manifest schema version", schema_version); + require(schema_version == "1.0" || schema_version == "1.1", + "Unsupported manifest schema version", schema_version); const auto bundle_id = required(manifest, "bundleId", "manifest"); require(!bundle_id.empty() && bundle_id.size() <= 128, "Bundle ID is invalid"); require(required(manifest, "family", "manifest") == "PP-OCRv6", @@ -694,6 +849,10 @@ std::shared_ptr parse_bundle(std::vector data->recognition = parse_recognition(normalized, data->files, recognition_dictionary_path, runtime_defaults); + data->apple_provider = + parse_apple_provider(manifest, schema_version, data->files); + require((schema_version == "1.1") == data->apple_provider.has_value(), + "Manifest schema 1.1 requires exactly one Apple provider payload"); data->limits = parse_limits(normalized, normalized_schema); data->capabilities = Capabilities{true, true, false, data->tiled_detection.has_value()}; diff --git a/src/preprocess/tensor.cpp b/src/preprocess/tensor.cpp index b3a7484..a9ba76e 100644 --- a/src/preprocess/tensor.cpp +++ b/src/preprocess/tensor.cpp @@ -42,7 +42,8 @@ bool tensor_bytes(std::uint64_t elements, std::uint64_t* bytes) { Result make_recognition_sample( std::size_t input_index, std::uint32_t crop_width, std::uint32_t crop_height, const RecognitionConfig& config, - const ResourceLimits& limits) { + const ResourceLimits& limits, std::uint32_t tensor_width_multiple, + const std::vector& tensor_width_buckets) { if (crop_width == 0 || crop_height == 0) { return failure(ErrorCode::postprocess_failed, "Recognition crop is empty"); @@ -53,6 +54,42 @@ Result make_recognition_sample( static_cast(config.height) * std::max(base_ratio, ratio)); tensor_width = std::max(config.minimum_tensor_width, std::min(config.maximum_tensor_width, tensor_width)); + if (tensor_width_multiple == 0 || + tensor_width_multiple > config.maximum_tensor_width || + config.maximum_tensor_width % tensor_width_multiple != 0) { + return failure(ErrorCode::invalid_argument, + "Recognition width multiple is invalid"); + } + tensor_width = std::min( + config.maximum_tensor_width, + round_multiple_up(tensor_width, tensor_width_multiple)); + if (!tensor_width_buckets.empty()) { + const bool valid_buckets = + std::is_sorted(tensor_width_buckets.begin(), tensor_width_buckets.end()) && + std::adjacent_find(tensor_width_buckets.begin(), + tensor_width_buckets.end()) == + tensor_width_buckets.end() && + tensor_width_buckets.front() >= config.minimum_tensor_width && + tensor_width_buckets.back() == config.maximum_tensor_width && + std::all_of(tensor_width_buckets.begin(), tensor_width_buckets.end(), + [&](std::uint32_t width) { + return width <= config.maximum_tensor_width && + width % tensor_width_multiple == 0; + }); + if (!valid_buckets) { + return failure( + ErrorCode::invalid_argument, + "Recognition width bucket contract is invalid"); + } + const auto bucket = std::lower_bound( + tensor_width_buckets.begin(), tensor_width_buckets.end(), tensor_width); + if (bucket == tensor_width_buckets.end()) { + return failure( + ErrorCode::resource_limit_exceeded, + "Recognition width has no qualified bucket"); + } + tensor_width = *bucket; + } const auto content_width = std::min( tensor_width, static_cast(std::ceil(config.height * ratio))); if (tensor_width > limits.max_recognition_width || content_width == 0) { @@ -202,7 +239,8 @@ Result make_detection_input(const cv::Mat& bgr, Result> plan_recognition_batches( const std::vector& boxes, const GeometryConfig& geometry, const RecognitionConfig& config, std::uint32_t batch_size, - const ResourceLimits& limits) { + const ResourceLimits& limits, std::uint32_t tensor_width_multiple, + const std::vector& tensor_width_buckets) { try { if (batch_size == 0 || batch_size > config.maximum_batch_size || batch_size > limits.max_recognition_batch_size) { @@ -220,7 +258,8 @@ Result> plan_recognition_batches( } const auto shape = std::move(shape_result).value(); auto sample_result = make_recognition_sample( - index, shape.output_width(), shape.output_height(), config, limits); + index, shape.output_width(), shape.output_height(), config, limits, + tensor_width_multiple, tensor_width_buckets); if (!sample_result) { return Result>::failure( sample_result.error()); @@ -253,7 +292,9 @@ Result> plan_recognition_batches( Result make_recognition_batch( const std::vector& crops, const RecognitionBatchPlan& plan, - const RecognitionConfig& config, const ResourceLimits& limits) { + const RecognitionConfig& config, const ResourceLimits& limits, + std::uint32_t tensor_width_multiple, + const std::vector& tensor_width_buckets) { try { const auto count = plan.samples.size(); if (count == 0 || count != crops.size() || @@ -275,7 +316,8 @@ Result make_recognition_batch( auto actual_result = make_recognition_sample( plan.samples[index].input_index, static_cast(crop.cols), - static_cast(crop.rows), config, limits); + static_cast(crop.rows), config, limits, + tensor_width_multiple, tensor_width_buckets); if (!actual_result) { return Result::failure(actual_result.error()); } diff --git a/src/preprocess/tensor.hpp b/src/preprocess/tensor.hpp index 06046b6..294c30f 100644 --- a/src/preprocess/tensor.hpp +++ b/src/preprocess/tensor.hpp @@ -46,10 +46,13 @@ Result make_detection_input(const cv::Mat& bgr, Result> plan_recognition_batches( const std::vector& boxes, const GeometryConfig& geometry, const RecognitionConfig& config, std::uint32_t batch_size, - const ResourceLimits& limits); + const ResourceLimits& limits, std::uint32_t tensor_width_multiple = 1, + const std::vector& tensor_width_buckets = {}); Result make_recognition_batch( const std::vector& crops, const RecognitionBatchPlan& plan, - const RecognitionConfig& config, const ResourceLimits& limits); + const RecognitionConfig& config, const ResourceLimits& limits, + std::uint32_t tensor_width_multiple = 1, + const std::vector& tensor_width_buckets = {}); } // namespace light_ocr::internal diff --git a/tests/integration/apple.cpp b/tests/integration/apple.cpp new file mode 100644 index 0000000..09c4f89 --- /dev/null +++ b/tests/integration/apple.cpp @@ -0,0 +1,217 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "common/bundle_files.hpp" +#include "inference/coreml/backend.hpp" +#include "light_ocr/core.hpp" + +namespace { + +using light_ocr::CpuPartition; +using light_ocr::Engine; +using light_ocr::EngineOptions; +using light_ocr::ExecutionProvider; +using light_ocr::ImageView; +using light_ocr::ModelBundle; +using light_ocr::PixelFormat; +using light_ocr::Precision; +using light_ocr::SessionFallback; +namespace fs = std::filesystem; + +std::unique_ptr create_engine(const std::string& bundle_path, + CpuPartition partition, + SessionFallback fallback) { + auto bundle = ModelBundle::create( + light_ocr::tools::load_bundle_directory(bundle_path)); + if (!bundle) { + throw std::runtime_error(bundle.error().message + ": " + + bundle.error().detail); + } + EngineOptions options; + options.execution.provider = ExecutionProvider::apple; + options.execution.session_fallback = fallback; + options.execution.cpu_partition = partition; + options.execution.precision = Precision::fp16; + options.detection.strategy = light_ocr::DetectionStrategy::bounded; + options.recognition_batch_size = 1; + auto engine = Engine::create(std::move(bundle).value(), options); + if (!engine) { + throw std::runtime_error(engine.error().message + ": " + + engine.error().detail); + } + return std::move(engine).value(); +} + +void require(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +void require_hello(Engine* engine, const std::string& pixels_path, + const std::string& compute_unit) { + auto pixels = light_ocr::tools::read_binary_file(pixels_path); + const ImageView image{pixels.data(), pixels.size(), 800, 180, 2400, + PixelFormat::bgr8}; + light_ocr::RecognizeOptions options; + options.include_diagnostics = true; + auto result = engine->recognize(image, options); + if (!result) { + throw std::runtime_error(result.error().message + ": " + + result.error().detail); + } + require(result.value().lines.size() == 1, + "Apple provider did not return one golden line"); + require(result.value().lines.front().text == "HELLO 123", + "Apple provider changed the golden text"); + require(result.value().diagnostics.has_value() && + result.value().diagnostics->recognition_batch_shapes.size() == 1 && + result.value().diagnostics->recognition_batch_shapes.front() + .compute_unit == compute_unit && + !result.value().diagnostics->recognition_batch_shapes.front() + .model_id.empty() && + !result.value().diagnostics->recognition_batch_shapes.front() + .shape_bucket.empty(), + "Apple recognition route diagnostics are invalid"); +} + +void require_wide_recognizer(const fs::path& bundle_path) { + std::ifstream manifest_stream(bundle_path / "manifest.json", std::ios::binary); + if (!manifest_stream) throw std::runtime_error("Apple manifest is unavailable"); + nlohmann::json manifest; + manifest_stream >> manifest; + const auto& provider = manifest.at("providers").at("apple"); + const auto& recognition = provider.at("recognition"); + const auto package_relative = recognition.at("packagePath").get(); + const auto package_root = bundle_path / package_relative; + + light_ocr::internal::AppleModelPackage package; + package.root_path = package_relative; + package.package_sha256 = recognition.at("packageSha256").get(); + package.input_name = recognition.at("inputName").get(); + package.output_name = recognition.at("outputName").get(); + package.qualification_id = provider.at("qualificationId").get(); + package.qualified_device_families = + provider.at("qualifiedDeviceFamilies").get>(); + package.recognition_width_multiple = + recognition.at("widthMultiple").get(); + package.recognition_ane_maximum_width = + recognition.at("aneMaximumWidth").get(); + package.maximum_cached_functions = + recognition.at("maximumCachedFunctions").get(); + for (const auto& entry : fs::recursive_directory_iterator(package_root)) { + if (!entry.is_regular_file()) continue; + package.files.push_back(light_ocr::internal::ModelPackageFile{ + fs::relative(entry.path(), package_root).generic_string(), + std::make_shared>( + light_ocr::tools::read_binary_file(entry.path()))}); + } + + light_ocr::internal::InferenceSessionConfig config; + config.provider = ExecutionProvider::apple; + config.cpu_partition = CpuPartition::allow; + config.precision = Precision::fp16; + config.model_id = recognition.at("modelId").get(); + config.model_sha256 = package.package_sha256; + config.shape_policy = recognition.at("shapePolicy").get(); + config.apple_package = std::move(package); + auto session = light_ocr::internal::CoreMlSession::create( + config, light_ocr::internal::ModelKind::recognition); + if (!session) { + throw std::runtime_error(session.error().message + ": " + + session.error().detail); + } + constexpr std::int64_t width = 3200; + const std::vector shape = {1, 3, 48, width}; + std::vector values(static_cast(3 * 48 * width)); + auto output = session.value()->run(values, shape); + if (!output) { + throw std::runtime_error(output.error().message + ": " + + output.error().detail); + } + require(output.value().shape().size() == 3 && + output.value().shape()[0] == 1 && + output.value().shape()[1] == 400 && + output.value().shape()[2] > 1 && output.value().size() > 400, + "Wide Core ML recognizer output is invalid"); +} + +} // namespace + +int main() { + const char* bundle_path = std::getenv("LIGHT_OCR_APPLE_MODEL_BUNDLE"); + const char* pixels_path = std::getenv("LIGHT_OCR_APPLE_TEST_PIXELS"); + if (bundle_path == nullptr || bundle_path[0] == '\0' || + pixels_path == nullptr || pixels_path[0] == '\0') { + std::cout << "SKIP Apple model bundle is not available\n"; + return 77; + } + try { + auto availability_probe = create_engine( + bundle_path, CpuPartition::allow, SessionFallback::cpu); + if (availability_probe->info().execution.detection.session_fallback) { + const auto& execution = availability_probe->info().execution; + require(execution.detection.session_fallback, + "Unavailable Apple device did not report CPU fallback"); + require(execution.detection.fallback_reason.has_value(), + "Unavailable Apple device reported the wrong fallback reason"); + require_hello(availability_probe.get(), pixels_path, "cpu"); + return 0; + } + availability_probe->close(); + + require_wide_recognizer(bundle_path); + + auto interactive = create_engine(bundle_path, CpuPartition::allow, + SessionFallback::error); + const auto& interactive_info = interactive->info(); + require(interactive_info.execution_provider == "CoreML", + "Interactive engine did not select Core ML"); + require(interactive_info.execution.provider_capabilities.size() == 2 && + interactive_info.execution.provider_capabilities[1].provider == + "apple" && + interactive_info.execution.provider_capabilities[1] + .package_included && + interactive_info.execution.provider_capabilities[1] + .device_available, + "Apple capability report is invalid"); + require(interactive_info.execution.detection.actual_provider_chain == + std::vector{ + "CoreML(MLNeuralEngine,qualified-MLCPU)"}, + "Interactive detector routing is invalid"); + require(interactive_info.execution.recognition.actual_provider_chain == + std::vector{ + "CoreML(MLNeuralEngine,qualified-MLCPU)", + "CoreML(MLGPU)"}, + "Interactive recognizer routing is invalid"); + require(!interactive_info.execution.detection.qualification_id.empty() && + interactive_info.execution.detection.qualification_id == + interactive_info.execution.recognition.qualification_id, + "Apple qualification identity is missing or inconsistent"); + require(interactive_info.execution.detection.device_family.find("Apple M") == 0 && + !interactive_info.execution.detection.operating_system.empty(), + "Apple device family or operating system is not observable"); + require_hello(interactive.get(), pixels_path, "ane"); + interactive->close(); + + auto strict = create_engine(bundle_path, CpuPartition::forbid, + SessionFallback::error); + require(strict->info().execution.detection.actual_provider_chain == + std::vector{"CoreML(MLGPU)"} && + strict->info().execution.recognition.actual_provider_chain == + std::vector{"CoreML(MLGPU)"}, + "Strict Apple profile did not select full GPU routing"); + require_hello(strict.get(), pixels_path, "gpu"); + return 0; + } catch (const std::exception& exception) { + std::cerr << exception.what() << '\n'; + return 1; + } +} diff --git a/tests/python/test_apple_qualification.py b/tests/python/test_apple_qualification.py new file mode 100644 index 0000000..094ec88 --- /dev/null +++ b/tests/python/test_apple_qualification.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +import json +from pathlib import Path +import tempfile +import unittest +from unittest import mock + +from tools.apple import ( + accept_qualification, + collect_qualification, + package_bundle, + performance_gate, +) + + +class AppleQualificationCollectorTests(unittest.TestCase): + @staticmethod + def write_json(path: Path, value: dict[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value), encoding="utf-8") + + @staticmethod + def hashed(value: dict[str, object]) -> dict[str, object]: + result = dict(value) + result["reportSha256"] = collect_qualification.report_hash(result) + return result + + @staticmethod + def apple_execution() -> dict[str, object]: + return { + "requestedProvider": "apple", + "detection": { + "modelSha256": "det", + "qualificationId": "apple-test", + "sessionFallback": False, + }, + "recognition": { + "modelSha256": "rec", + "qualificationId": "apple-test", + "sessionFallback": False, + }, + } + + def test_collects_two_distinct_devices_with_identical_models(self) -> None: + with tempfile.TemporaryDirectory() as work: + root = Path(work) + acceptance = { + "qualificationId": "apple-test", + "models": { + "artifactId": "artifact", + "detectionPackageSha256": "det", + "recognitionPackageSha256": "rec", + }, + "routing": { + "recognitionWidthMultiple": 32, + "recognitionAneMaximumWidth": 1600, + "recognitionRuntimeWidthBuckets": [320, 3200], + "maximumCachedFunctions": 2, + }, + "performance": { + "workloadIds": [ + "generated-hello-123", "paddleocr-xfund-form" + ], + "coldStartWorkloadId": "generated-hello-123", + "maximumResidentGrowthAfter100PagesBytes": 64, + }, + "compatibility": {"minimumQualifiedDevices": 2}, + } + acceptance_path = root / "acceptance.json" + self.write_json(acceptance_path, acceptance) + acceptance_hash = collect_qualification.hashlib.sha256( + acceptance_path.read_bytes() + ).hexdigest() + for identifier, family in (("m1", "Apple M1"), ("m2", "Apple M2")): + directory = root / "reports" / identifier + self.write_json(directory / "identity.json", { + "expectedDeviceFamily": family, + "deviceBrand": family + " Pro", + "operatingSystem": "macOS", + "runnerLabel": "runner", + }) + self.write_json(directory / "model-qualification.json", self.hashed({ + "qualificationId": "apple-test", + "gate": {"passed": True, "coverageComplete": True}, + "routing": { + "recognitionWidthMultiple": 32, + "aneMaximumWidth": 1600, + "runtimeWidthBuckets": [320, 3200], + "maximumCachedFunctions": 2, + }, + "models": { + "artifactId": "artifact", + "detection": {"packageSha256": "det"}, + "recognition": {"packageSha256": "rec"}, + }, + })) + self.write_json(directory / "quality.json", self.hashed({ + "qualificationId": "apple-test", + "acceptanceSha256": acceptance_hash, + "models": { + "detectionPackageSha256": "det", + "recognitionPackageSha256": "rec", + "qualificationId": "apple-test", + }, + "passed": True, + })) + self.write_json(directory / "performance.json", self.hashed({ + "qualificationId": "apple-test", + "acceptanceSha256": acceptance_hash, + "passed": True, + "coldStartWorkloadId": "generated-hello-123", + "workloads": [ + {"fixtureId": "generated-hello-123", "appleRuns": [{ + "execution": self.apple_execution() + }]}, + {"fixtureId": "paddleocr-xfund-form", "appleRuns": [{ + "execution": self.apple_execution() + }]}, + ], + })) + self.write_json(directory / "cache-concurrency.json", self.hashed({ + "passed": True, + "processes": 4, + "records": [{"execution": self.apple_execution()}], + })) + self.write_json(directory / "lifecycle.json", self.hashed({ + "passed": True, + "lifecycleMode": "pages", + "measuredCycles": 100, + "residentBytes": {"growth": 32}, + "execution": self.apple_execution(), + })) + output = root / "candidate.json" + with mock.patch("sys.argv", [ + "collect_qualification.py", + "--reports-root", str(root / "reports"), + "--acceptance", str(acceptance_path), + "--git-commit", "abc123", + "--output", str(output), + ]): + self.assertEqual(collect_qualification.main(), 0) + candidate = json.loads(output.read_text("utf-8")) + self.assertEqual(candidate["status"], "candidate") + self.assertEqual(candidate["qualifiedDeviceFamilies"], ["Apple M1", "Apple M2"]) + self.assertEqual(candidate["modelPackageSha256"], { + "detection": "det", "recognition": "rec" + }) + + def test_rejects_tampered_hashed_report(self) -> None: + report = self.hashed({"qualificationId": "apple-test", "passed": True}) + report["passed"] = False + with tempfile.TemporaryDirectory() as work: + path = Path(work) / "report.json" + self.write_json(path, report) + with self.assertRaisesRegex(RuntimeError, "report hash mismatch"): + collect_qualification.validate_hashed_report(path, "apple-test") + + def test_rejects_execution_from_a_different_model(self) -> None: + execution = self.apple_execution() + execution["recognition"]["modelSha256"] = "different" + with self.assertRaisesRegex(RuntimeError, "not bound to the locked model"): + collect_qualification.validate_execution_models( + [{"execution": execution}], "apple-test", ("det", "rec"), "test" + ) + + def test_accepts_and_validates_a_reviewed_provider_baseline(self) -> None: + acceptance = { + "qualificationId": "apple-test", + "models": { + "artifactId": "artifact", + "detectionPackageSha256": "d" * 64, + "recognitionPackageSha256": "r" * 64, + }, + "compatibility": {"minimumQualifiedDevices": 2}, + } + acceptance_bytes = json.dumps(acceptance).encode("utf-8") + acceptance_sha256 = collect_qualification.hashlib.sha256( + acceptance_bytes + ).hexdigest() + candidate = { + "schema": "light-ocr-apple-provider-baselines/1.0", + "status": "candidate", + "qualificationId": "apple-test", + "generatedFromCommit": "a" * 40, + "acceptanceSha256": acceptance_sha256, + "modelArtifactId": "artifact", + "modelPackageSha256": { + "detection": "d" * 64, + "recognition": "r" * 64, + }, + "qualifiedDeviceFamilies": ["Apple M1", "Apple M2"], + "devices": [ + {"deviceFamily": "Apple M1"}, + {"deviceFamily": "Apple M2"}, + ], + } + candidate["reportSha256"] = collect_qualification.report_hash(candidate) + with tempfile.TemporaryDirectory() as work: + root = Path(work) + candidate_path = root / "candidate.json" + accepted_path = root / "accepted.json" + self.write_json(candidate_path, candidate) + with mock.patch("sys.argv", [ + "accept_qualification.py", + "--candidate", str(candidate_path), + "--approved-by-commit", "b" * 40, + "--output", str(accepted_path), + ]): + self.assertEqual(accept_qualification.main(), 0) + accepted = json.loads(accepted_path.read_text("utf-8")) + self.assertEqual(accepted["status"], "accepted") + self.assertEqual( + package_bundle.accepted_device_families( + accepted_path, acceptance, acceptance_sha256 + ), + ["Apple M1", "Apple M2"], + ) + + def test_rejects_tampered_accepted_provider_baseline(self) -> None: + report = { + "schema": "light-ocr-apple-provider-baselines/1.0", + "status": "accepted", + "reportSha256": "0" * 64, + } + with tempfile.TemporaryDirectory() as work: + path = Path(work) / "accepted.json" + self.write_json(path, report) + with self.assertRaisesRegex(RuntimeError, "not an accepted intact"): + package_bundle.accepted_device_families(path, {}, "unused") + + +class ApplePerformanceGateTests(unittest.TestCase): + @staticmethod + def sample_run( + cache_status: str, resident: int, lifetime_peak: int + ) -> dict[str, object]: + return { + "execution": { + "detection": {"modelCacheStatus": cache_status}, + "recognition": {"modelCacheStatus": cache_status}, + }, + "memoryBytes": { + "residentMaximum": resident, + "peakResident": lifetime_peak, + }, + } + + def test_warm_rss_uses_only_cache_hit_measurement_window(self) -> None: + runs = [ + self.sample_run("compiled_cache_miss", 1_000, 2_000), + self.sample_run("compiled_cache_hit", 700, 2_100), + self.sample_run("compiled_cache_hit", 750, 2_200), + ] + self.assertEqual(performance_gate.warm_peak_resident_bytes(runs), 750) + + def test_warm_rss_requires_a_cache_hit_run(self) -> None: + runs = [self.sample_run("compiled_cache_miss", 1_000, 2_000)] + self.assertIsNone(performance_gate.warm_peak_resident_bytes(runs)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/python/test_npm_release.py b/tests/python/test_npm_release.py index ad0253e..11bcfca 100644 --- a/tests/python/test_npm_release.py +++ b/tests/python/test_npm_release.py @@ -29,7 +29,7 @@ def test_rejects_a_version_that_does_not_match_the_source(self) -> None: with self.assertRaisesRegex(RuntimeError, "does not match source version"): npm_release.assemble( argparse.Namespace( - version="0.2.1", + version="0.2.2", bundle=Path("unused"), native_root=Path("unused"), output_dir=Path("unused"), @@ -100,9 +100,16 @@ def test_stages_and_deterministically_packs_six_packages(self) -> None: bundle.mkdir() (bundle / "manifest.json").write_text( json.dumps({ - "schemaVersion": "1.0", + "schemaVersion": "1.1", "bundleId": npm_release.BUNDLE_ID, "normalizedConfigPath": "normalized-config.json", + "providers": { + "apple": { + "schemaVersion": "1.0", + "architecture": "arm64", + "qualifiedDeviceFamilies": ["Apple M1", "Apple M2"], + } + }, }) + "\n", "utf-8" ) (bundle / "normalized-config.json").write_text( @@ -117,19 +124,19 @@ def test_stages_and_deterministically_packs_six_packages(self) -> None: staging = root / "staging" npm_release.assemble( argparse.Namespace( - version="0.2.0", + version="0.2.1", bundle=bundle, native_root=native_root, output_dir=staging, ) ) facade = json.loads((staging / "facade" / "package.json").read_text("utf-8")) - self.assertEqual(facade["dependencies"][npm_release.MODEL_PACKAGE], "0.2.0") + self.assertEqual(facade["dependencies"][npm_release.MODEL_PACKAGE], "0.2.1") self.assertEqual(len(facade["optionalDependencies"]), 4) model = json.loads( (staging / "model-ppocrv6-small" / "package.json").read_text("utf-8") ) - self.assertEqual(model["lightOcr"]["manifestSchemaVersion"], "1.0") + self.assertEqual(model["lightOcr"]["manifestSchemaVersion"], "1.1") self.assertEqual( model["lightOcr"]["normalizedConfigSchemaVersion"], "1.2" ) @@ -140,7 +147,7 @@ def test_stages_and_deterministically_packs_six_packages(self) -> None: argparse.Namespace(staging_dir=staging, output_dir=tarballs, npm=npm) ) release = json.loads((tarballs / "release-manifest.json").read_text("utf-8")) - self.assertEqual(release["version"], "0.2.0") + self.assertEqual(release["version"], "0.2.1") self.assertEqual(len(release["packages"]), 6) self.assertEqual(len(list(tarballs.glob("*.tgz"))), 6) diff --git a/tests/unit/test_image.cpp b/tests/unit/test_image.cpp index 78c16b2..4d963d4 100644 --- a/tests/unit/test_image.cpp +++ b/tests/unit/test_image.cpp @@ -209,6 +209,30 @@ LIGHT_OCR_TEST(recognition_batches_restore_input_indices) { EXPECT_EQ(batch.value().shape[3], 480); } +LIGHT_OCR_TEST(recognition_batches_apply_and_validate_runtime_width_buckets) { + const auto config = recognition_config(); + internal::GeometryConfig geometry; + geometry.tall_line_ratio = 1.5f; + const std::vector boxes{rectangle(416, 48)}; + const std::vector buckets{320, 480, 3200}; + auto plans = internal::plan_recognition_batches( + boxes, geometry, config, 1, ResourceLimits{}, 32, buckets); + EXPECT_TRUE(plans); + EXPECT_EQ(plans.value()[0].samples[0].tensor_width, 480u); + std::vector crops{ + cv::Mat(48, 416, CV_8UC3, cv::Scalar(0, 0, 0))}; + auto batch = internal::make_recognition_batch( + crops, plans.value()[0], config, ResourceLimits{}, 32, buckets); + EXPECT_TRUE(batch); + EXPECT_EQ(batch.value().shape[3], 480); + + const std::vector unsorted{480, 320, 3200}; + auto invalid = internal::plan_recognition_batches( + boxes, geometry, config, 1, ResourceLimits{}, 32, unsorted); + EXPECT_FALSE(invalid); + EXPECT_EQ(invalid.error().code, ErrorCode::invalid_argument); +} + LIGHT_OCR_TEST(recognition_batches_reject_invalid_batch_and_memory_limit) { std::vector crops{cv::Mat(48, 320, CV_8UC3, cv::Scalar(0, 0, 0))}; const auto config = recognition_config(); diff --git a/tests/unit/test_model_bundle.cpp b/tests/unit/test_model_bundle.cpp index 4dc3a7f..de8e4e9 100644 --- a/tests/unit/test_model_bundle.cpp +++ b/tests/unit/test_model_bundle.cpp @@ -22,6 +22,29 @@ SharedBytes bytes(const std::string& value) { return std::make_shared>(value.begin(), value.end()); } +std::string package_hash(const std::vector& files, + const std::string& prefix) { + std::vector package_files; + for (const auto& file : files) { + if (file.path.compare(0, prefix.size(), prefix) == 0) { + package_files.push_back(&file); + } + } + std::sort(package_files.begin(), package_files.end(), + [](const auto* left, const auto* right) { + return left->path < right->path; + }); + std::string inventory; + for (const auto* file : package_files) { + inventory += file->path.substr(prefix.size()); + inventory.push_back('\0'); + inventory += internal::sha256_hex(file->bytes->data(), file->bytes->size()); + inventory.push_back('\n'); + } + return internal::sha256_hex( + reinterpret_cast(inventory.data()), inventory.size()); +} + void refresh_checksums(std::vector* files) { std::string sums; for (const auto& file : *files) { @@ -196,6 +219,81 @@ std::vector valid_bundle_files(bool tiled = false) { return files; } +std::vector valid_apple_bundle_files() { + auto files = valid_bundle_files(true); + files.erase(std::remove_if(files.begin(), files.end(), [](const BundleFile& file) { + return file.path == "SHA256SUMS"; + }), + files.end()); + const std::string detection_root = "apple/detector.mlpackage/"; + const std::string recognition_root = "apple/recognizer.mlpackage/"; + for (const auto& root : {detection_root, recognition_root}) { + files.push_back(BundleFile{root + "Manifest.json", bytes("manifest")}); + files.push_back(BundleFile{root + "Data/com.apple.CoreML/model.mlmodel", + bytes("model")}); + files.push_back(BundleFile{ + root + "Data/com.apple.CoreML/weights/weight.bin", bytes("weights")}); + } + for (auto& file : files) { + if (file.path != "manifest.json") continue; + auto manifest = Json::parse(std::string(file.bytes->begin(), file.bytes->end())); + manifest["schemaVersion"] = "1.1"; + manifest["coreCompatibility"]["minimum"] = "0.2.1"; + for (const auto& payload : files) { + if (payload.path == "manifest.json") continue; + manifest["files"][payload.path] = { + {"bytes", payload.bytes->size()}, + {"sha256", internal::sha256_hex(payload.bytes->data(), + payload.bytes->size())}, + }; + } + std::vector widths; + for (std::uint32_t width = 320; width <= 3200; width += 32) { + widths.push_back(width); + } + manifest["providers"]["apple"] = { + {"schemaVersion", "1.0"}, + {"minimumMacOS", "15.0"}, + {"architecture", "arm64"}, + {"qualifiedDeviceFamilies", {"Apple M4"}}, + {"qualificationId", "apple-test-qualification"}, + {"detection", + {{"modelId", "detector-fp16"}, + {"packagePath", detection_root.substr(0, detection_root.size() - 1)}, + {"packageSha256", package_hash(files, detection_root)}, + {"inputName", "x"}, + {"outputName", "output"}, + {"shapePolicy", "nchw-bounded-range-32-960-v1"}, + {"preferredComputeUnit", "ane"}, + {"strictComputeUnit", "gpu"}, + {"qualifiedMLCPUOperations", {{"ios18.relu", 1}, {"pad", 1}}}}}, + {"recognition", + {{"modelId", "recognizer-fp16"}, + {"packagePath", recognition_root.substr(0, recognition_root.size() - 1)}, + {"packageSha256", package_hash(files, recognition_root)}, + {"inputName", "x"}, + {"outputName", "output"}, + {"shapePolicy", "nchw-static-width-multiple-32-v1"}, + {"functionFormat", "w%04u"}, + {"widths", widths}, + {"widthMultiple", 32}, + {"aneMaximumWidth", 1600}, + {"runtimeWidthBuckets", + {320, 384, 480, 544, 576, 608, 704, 736, 832, 960, + 1056, 1184, 1248, 1376, 1600, 1984, 2240, 2560, 2880, + 3200}}, + {"maximumCachedFunctions", 20}, + {"qualifiedMLCPUOperations", + {{"ios18.cast", 1}, {"ios18.conv", 3}, + {"ios18.relu", 3}, {"pad", 3}}}}}, + }; + file.bytes = bytes(manifest.dump()); + break; + } + refresh_checksums(&files); + return files; +} + } // namespace LIGHT_OCR_TEST(model_bundle_accepts_complete_hashed_contract) { @@ -213,6 +311,62 @@ LIGHT_OCR_TEST(model_bundle_accepts_tiled_v1_normalized_contract) { } } +LIGHT_OCR_TEST(model_bundle_accepts_locked_apple_provider_contract) { + auto result = ModelBundle::create(valid_apple_bundle_files()); + if (!result) { + light_ocr::test::fail("result", __FILE__, __LINE__, + result.error().message + ": " + result.error().detail); + } + EXPECT_EQ(result.value().schema_version(), "1.1"); +} + +LIGHT_OCR_TEST(model_bundle_rejects_schema_1_1_without_apple_provider) { + auto files = valid_bundle_files(true); + for (auto& file : files) { + if (file.path != "manifest.json") continue; + auto manifest = Json::parse(std::string(file.bytes->begin(), file.bytes->end())); + manifest["schemaVersion"] = "1.1"; + manifest["coreCompatibility"]["minimum"] = "0.2.1"; + file.bytes = bytes(manifest.dump()); + break; + } + refresh_checksums(&files); + auto result = ModelBundle::create(std::move(files)); + EXPECT_FALSE(result); + EXPECT_EQ(result.error().code, ErrorCode::invalid_model_bundle); +} + +LIGHT_OCR_TEST(model_bundle_rejects_unknown_apple_device_family) { + auto files = valid_apple_bundle_files(); + for (auto& file : files) { + if (file.path != "manifest.json") continue; + auto manifest = Json::parse(std::string(file.bytes->begin(), file.bytes->end())); + manifest["providers"]["apple"]["qualifiedDeviceFamilies"] = {"Apple M9"}; + file.bytes = bytes(manifest.dump()); + break; + } + refresh_checksums(&files); + auto result = ModelBundle::create(std::move(files)); + EXPECT_FALSE(result); + EXPECT_EQ(result.error().code, ErrorCode::invalid_model_bundle); +} + +LIGHT_OCR_TEST(model_bundle_rejects_mutated_apple_runtime_width_buckets) { + auto files = valid_apple_bundle_files(); + for (auto& file : files) { + if (file.path != "manifest.json") continue; + auto manifest = Json::parse(std::string(file.bytes->begin(), file.bytes->end())); + manifest["providers"]["apple"]["recognition"]["runtimeWidthBuckets"][0] = + 352; + file.bytes = bytes(manifest.dump()); + break; + } + refresh_checksums(&files); + auto result = ModelBundle::create(std::move(files)); + EXPECT_FALSE(result); + EXPECT_EQ(result.error().code, ErrorCode::invalid_model_bundle); +} + LIGHT_OCR_TEST(old_normalized_bundle_rejects_tiled_engine_before_session_load) { auto bundle = ModelBundle::create(valid_bundle_files()); EXPECT_TRUE(bundle); diff --git a/tools/apple/accept_qualification.py b/tools/apple/accept_qualification.py new file mode 100644 index 0000000..fdfd391 --- /dev/null +++ b/tools/apple/accept_qualification.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Promote a reviewed Apple provider qualification candidate to a source contract.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +try: + from .collect_qualification import read_json, report_hash +except ImportError: # Direct script execution. + from collect_qualification import read_json, report_hash + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--candidate", type=Path, required=True) + parser.add_argument("--approved-by-commit", required=True) + parser.add_argument("--output", type=Path, required=True) + arguments = parser.parse_args() + + candidate = read_json(arguments.candidate.resolve()) + if candidate.get("schema") != "light-ocr-apple-provider-baselines/1.0": + parser.error("candidate has an unsupported schema") + if candidate.get("status") != "candidate": + parser.error("input must be a qualification candidate") + candidate_hash = str(candidate.get("reportSha256", "")) + if candidate_hash != report_hash(candidate): + parser.error("candidate report hash does not match its contents") + approval = arguments.approved_by_commit.lower() + if len(approval) != 40 or any(value not in "0123456789abcdef" for value in approval): + parser.error("--approved-by-commit must be a full Git SHA-1") + + accepted = dict(candidate) + accepted["status"] = "accepted" + accepted["approvedByCommit"] = approval + accepted["candidateReportSha256"] = candidate_hash + accepted["reportSha256"] = report_hash(accepted) + arguments.output.parent.mkdir(parents=True, exist_ok=True) + arguments.output.write_text( + json.dumps(accepted, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(json.dumps({ + "accepted": True, + "families": accepted.get("qualifiedDeviceFamilies", []), + "output": str(arguments.output), + "reportSha256": accepted["reportSha256"], + }, ensure_ascii=False, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/apple/acceptance.json b/tools/apple/acceptance.json new file mode 100644 index 0000000..c5483f7 --- /dev/null +++ b/tools/apple/acceptance.json @@ -0,0 +1,54 @@ +{ + "schemaVersion": "1.0", + "qualificationId": "apple-fp16-mixed-20260715.1", + "models": { + "artifactId": "apple-fp16-20260715.1", + "detectionPackageSha256": "2097bd785947c6bc239bfcb27599362c48ec78bab72f439583e41a585b727f76", + "recognitionPackageSha256": "c54a0719cbde2d93e65eb40dd01fff5b78373b5aaaaa648fee614d3ef3615f4b" + }, + "routing": { + "recognitionWidthMultiple": 32, + "recognitionAneMaximumWidth": 1600, + "recognitionRuntimeWidthBuckets": [ + 320, 384, 480, 544, 576, 608, 704, 736, 832, 960, + 1056, 1184, 1248, 1376, 1600, 1984, 2240, 2560, 2880, 3200 + ], + "maximumCachedFunctions": 20 + }, + "quality": { + "minimumCharacterSimilarity": 0.995, + "minimumDetectionRecallAgainstCpu": 0.995, + "minimumMeanMatchedIoU": 0.98, + "maximumMeanMatchedConfidenceDifference": 0.01, + "criticalFixtureIds": [ + "generated-hello-123", + "generated-japanese-horizontal", + "generated-traditional-horizontal", + "paddleocr-garden-sign", + "paddleocr-rec-phone", + "paddleocr-rec-simplified" + ] + }, + "performance": { + "workloadIds": [ + "generated-hello-123", + "paddleocr-xfund-form" + ], + "coldStartWorkloadId": "generated-hello-123", + "minimumTargetWorkloads": 2, + "minimumCpuP50Speedup": 1.5, + "minimumCpuTimeReduction": 0.8, + "coldStartRuns": 3, + "warmRuns": 30, + "maximumCompiledCacheMissColdStartMilliseconds": 30000, + "maximumCompiledCacheHitColdStartMilliseconds": 3000, + "maximumWarmPeakResidentBytes": 805306368, + "maximumResidentGrowthAfter100PagesBytes": 67108864, + "maximumAppleBundleIncrementBytes": 33554432 + }, + "compatibility": { + "minimumMacOS": "15.0", + "architectures": ["arm64"], + "minimumQualifiedDevices": 2 + } +} diff --git a/tools/apple/cache_concurrency_gate.py b/tools/apple/cache_concurrency_gate.py new file mode 100644 index 0000000..7806e3f --- /dev/null +++ b/tools/apple/cache_concurrency_gate.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Verify Core ML compilation cache integrity under concurrent processes.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +import shutil +import subprocess + + +ROOT = Path(__file__).resolve().parents[2] +DEFAULT_BUNDLE = ROOT / "models" / "generated" / "ppocrv6-small-apple-20260715.1" +DEFAULT_FIXTURE = ROOT / "corpus" / "fixtures" / "generated-hello-123" +DEFAULT_REPORT = ROOT / "reports" / "apple" / "cache-concurrency.json" + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--native-benchmark", type=Path, required=True) + parser.add_argument("--bundle", type=Path, default=DEFAULT_BUNDLE) + parser.add_argument("--fixture", type=Path, default=DEFAULT_FIXTURE) + parser.add_argument("--processes", type=int, default=4) + parser.add_argument("--report", type=Path, default=DEFAULT_REPORT) + arguments = parser.parse_args() + if arguments.processes < 2 or arguments.processes > 16: + parser.error("--processes must be between 2 and 16") + fixture = json.loads((arguments.fixture / "fixture.json").read_text("utf-8")) + cache_root = ( + Path.home() / "Library" / "Caches" / "com.arcships.light-ocr" / "coreml-v1" + ) + shutil.rmtree(cache_root, ignore_errors=True) + commands: list[list[str]] = [] + for index in range(arguments.processes): + commands.append([ + str(arguments.native_benchmark.resolve()), + "--bundle", str(arguments.bundle.resolve()), + "--pixels", str((arguments.fixture / "pixels.bin").resolve()), + "--width", str(fixture["width"]), + "--height", str(fixture["height"]), + "--stride", str(fixture["stride"]), + "--format", str(fixture["pixelFormat"]), + "--profile", "apple_interactive", + "--warmup", "0", + "--iterations", "1", + "--report", str(arguments.report.parent / f"cache-process-{index}.json"), + ]) + processes = [ + subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + for command in commands + ] + records: list[dict[str, object]] = [] + failures: list[str] = [] + for index, process in enumerate(processes): + try: + stdout, stderr = process.communicate(timeout=180) + except subprocess.TimeoutExpired: + process.kill() + stdout, stderr = process.communicate() + failures.append(f"process {index} timed out") + if process.returncode != 0: + failures.append( + f"process {index} exited {process.returncode}: {(stdout + stderr)[-1000:]}" + ) + continue + try: + records.append(json.loads(stdout)) + except json.JSONDecodeError as error: + failures.append(f"process {index} returned invalid JSON: {error}") + if len(records) == arguments.processes: + result_hashes = { + str(record["result"]["stableSha256"]) for record in records + } + if len(result_hashes) != 1: + failures.append("concurrent processes produced different OCR results") + for stage in ("detection", "recognition"): + statuses = [ + str(record["execution"][stage]["modelCacheStatus"]) + for record in records + ] + if statuses.count("compiled_cache_miss") != 1: + failures.append(f"{stage} did not produce exactly one cache miss") + if statuses.count("compiled_cache_hit") != arguments.processes - 1: + failures.append(f"{stage} cache hit count is invalid") + temporary_paths = sorted( + str(path.relative_to(cache_root)) + for path in cache_root.rglob("*.tmp.*") + ) if cache_root.is_dir() else [] + if temporary_paths: + failures.append("temporary cache directories remain after concurrent compilation") + report: dict[str, object] = { + "schemaVersion": "1.0", + "passed": not failures, + "failures": failures, + "processes": arguments.processes, + "records": records, + "remainingTemporaryPaths": temporary_paths, + } + encoded = json.dumps( + report, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ) + report["reportSha256"] = hashlib.sha256(encoded.encode("utf-8")).hexdigest() + arguments.report.parent.mkdir(parents=True, exist_ok=True) + arguments.report.write_text( + json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(json.dumps({ + "passed": not failures, + "failures": failures, + "report": str(arguments.report), + }, ensure_ascii=False, sort_keys=True)) + return 0 if not failures else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/apple/collect_qualification.py b/tools/apple/collect_qualification.py new file mode 100644 index 0000000..c9d0afe --- /dev/null +++ b/tools/apple/collect_qualification.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +"""Validate and collect independent Apple device qualification reports.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +DEFAULT_ACCEPTANCE = ROOT / "tools" / "apple" / "acceptance.json" + + +def read_json(path: Path) -> dict[str, object]: + if not path.is_file(): + raise RuntimeError(f"required qualification report is missing: {path}") + value = json.loads(path.read_text("utf-8")) + if not isinstance(value, dict): + raise RuntimeError(f"qualification report is not an object: {path}") + return value + + +def report_hash(report: dict[str, object]) -> str: + value = dict(report) + value.pop("reportSha256", None) + encoded = json.dumps( + value, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +def validate_hashed_report( + path: Path, qualification_id: str +) -> dict[str, object]: + report = read_json(path) + if report.get("qualificationId") != qualification_id: + raise RuntimeError(f"qualification ID mismatch: {path}") + if report.get("reportSha256") != report_hash(report): + raise RuntimeError(f"report hash mismatch: {path}") + return report + + +def validate_execution_models( + records: list[dict[str, object]], qualification_id: str, + expected_hashes: tuple[str, str], context: str, +) -> None: + if not records: + raise RuntimeError(f"{context} contains no execution records") + for record in records: + execution = record.get("execution", {}) + if execution.get("requestedProvider") != "apple": + raise RuntimeError(f"{context} did not request the Apple provider") + for stage, expected_hash in zip( + ("detection", "recognition"), expected_hashes, strict=True + ): + session = execution.get(stage, {}) + if ( + session.get("modelSha256") != expected_hash + or session.get("qualificationId") != qualification_id + or session.get("sessionFallback") is not False + ): + raise RuntimeError( + f"{context} {stage} execution is not bound to the locked model" + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--reports-root", type=Path, required=True) + parser.add_argument("--acceptance", type=Path, default=DEFAULT_ACCEPTANCE) + parser.add_argument("--git-commit", required=True) + parser.add_argument("--output", type=Path, required=True) + arguments = parser.parse_args() + acceptance_path = arguments.acceptance.resolve() + acceptance = read_json(acceptance_path) + qualification_id = str(acceptance["qualificationId"]) + minimum_devices = int(acceptance["compatibility"]["minimumQualifiedDevices"]) + acceptance_sha256 = hashlib.sha256(acceptance_path.read_bytes()).hexdigest() + expected_model_hashes = ( + str(acceptance["models"]["detectionPackageSha256"]), + str(acceptance["models"]["recognitionPackageSha256"]), + ) + + devices: list[dict[str, object]] = [] + families: set[str] = set() + model_artifact_id: str | None = None + model_hashes: tuple[str, str] | None = None + for directory in sorted(arguments.reports_root.resolve().iterdir()): + if not directory.is_dir(): + continue + identity = read_json(directory / "identity.json") + family = str(identity.get("expectedDeviceFamily", "")) + brand = str(identity.get("deviceBrand", "")) + if not family or not brand.startswith(family): + raise RuntimeError(f"device identity does not match {family}: {brand}") + if family in families: + raise RuntimeError(f"duplicate qualified device family: {family}") + + model = validate_hashed_report( + directory / "model-qualification.json", qualification_id + ) + quality = validate_hashed_report(directory / "quality.json", qualification_id) + performance = validate_hashed_report( + directory / "performance.json", qualification_id + ) + cache = read_json(directory / "cache-concurrency.json") + lifecycle = read_json(directory / "lifecycle.json") + if not model.get("gate", {}).get("passed"): + raise RuntimeError(f"model placement gate failed for {family}") + if not model.get("gate", {}).get("coverageComplete"): + raise RuntimeError(f"model shape coverage is incomplete for {family}") + expected_routing = acceptance["routing"] + observed_routing = model.get("routing", {}) + if ( + observed_routing.get("recognitionWidthMultiple") + != expected_routing["recognitionWidthMultiple"] + or observed_routing.get("aneMaximumWidth") + != expected_routing["recognitionAneMaximumWidth"] + or observed_routing.get("runtimeWidthBuckets") + != expected_routing["recognitionRuntimeWidthBuckets"] + or observed_routing.get("maximumCachedFunctions") + != expected_routing["maximumCachedFunctions"] + ): + raise RuntimeError(f"model routing contract mismatch for {family}") + if not quality.get("passed"): + raise RuntimeError(f"quality gate failed for {family}") + if quality.get("acceptanceSha256") != acceptance_sha256: + raise RuntimeError(f"quality acceptance hash mismatch for {family}") + if quality.get("models") != { + "detectionPackageSha256": expected_model_hashes[0], + "recognitionPackageSha256": expected_model_hashes[1], + "qualificationId": qualification_id, + }: + raise RuntimeError(f"quality model identity mismatch for {family}") + if not performance.get("passed"): + raise RuntimeError(f"performance gate failed for {family}") + if performance.get("acceptanceSha256") != acceptance_sha256: + raise RuntimeError(f"performance acceptance hash mismatch for {family}") + observed_workloads = [ + record.get("fixtureId") for record in performance.get("workloads", []) + ] + if observed_workloads != acceptance["performance"]["workloadIds"]: + raise RuntimeError(f"performance workload contract mismatch for {family}") + if ( + performance.get("coldStartWorkloadId") + != acceptance["performance"]["coldStartWorkloadId"] + ): + raise RuntimeError(f"cold-start workload contract mismatch for {family}") + if not cache.get("passed") or int(cache.get("processes", 0)) < 2: + raise RuntimeError(f"cache concurrency gate failed for {family}") + if cache.get("reportSha256") != report_hash(cache): + raise RuntimeError(f"cache concurrency report hash mismatch for {family}") + validate_execution_models( + list(cache.get("records", [])), qualification_id, + expected_model_hashes, f"cache concurrency report for {family}", + ) + performance_records = [ + run + for workload in performance.get("workloads", []) + for run in workload.get("appleRuns", []) + ] + validate_execution_models( + performance_records, qualification_id, expected_model_hashes, + f"performance report for {family}", + ) + if ( + not lifecycle.get("passed") + or lifecycle.get("lifecycleMode") != "pages" + or int(lifecycle.get("measuredCycles", 0)) < 100 + ): + raise RuntimeError(f"100-cycle lifecycle gate failed for {family}") + if lifecycle.get("reportSha256") != report_hash(lifecycle): + raise RuntimeError(f"lifecycle report hash mismatch for {family}") + validate_execution_models( + [lifecycle], qualification_id, expected_model_hashes, + f"lifecycle report for {family}", + ) + maximum_growth = int( + acceptance["performance"]["maximumResidentGrowthAfter100PagesBytes"] + ) + if int(lifecycle["residentBytes"]["growth"]) > maximum_growth: + raise RuntimeError(f"resident growth exceeds acceptance for {family}") + + provenance = model["models"] + artifact_id = str(provenance["artifactId"]) + hashes = ( + str(provenance["detection"]["packageSha256"]), + str(provenance["recognition"]["packageSha256"]), + ) + if model_artifact_id is None: + model_artifact_id = artifact_id + model_hashes = hashes + elif artifact_id != model_artifact_id or hashes != model_hashes: + raise RuntimeError("qualified devices did not use identical model artifacts") + + families.add(family) + devices.append({ + "deviceFamily": family, + "deviceBrand": brand, + "operatingSystem": identity.get("operatingSystem"), + "runnerLabel": identity.get("runnerLabel"), + "modelQualificationReportSha256": model["reportSha256"], + "qualityReportSha256": quality["reportSha256"], + "performanceReportSha256": performance["reportSha256"], + "cacheConcurrencyReportSha256": cache["reportSha256"], + "lifecycleReportSha256": lifecycle["reportSha256"], + "lifecycleGrowthBytes": lifecycle["residentBytes"]["growth"], + }) + + if len(devices) < minimum_devices: + raise RuntimeError( + f"qualification requires {minimum_devices} independent devices, got {len(devices)}" + ) + expected_models = acceptance["models"] + if (model_artifact_id != expected_models["artifactId"] or + model_hashes != ( + expected_models["detectionPackageSha256"], + expected_models["recognitionPackageSha256"], + )): + raise RuntimeError("qualified model artifacts differ from the locked acceptance") + result = { + "schema": "light-ocr-apple-provider-baselines/1.0", + "status": "candidate", + "qualificationId": qualification_id, + "generatedFromCommit": arguments.git_commit, + "acceptanceSha256": acceptance_sha256, + "modelArtifactId": model_artifact_id, + "modelPackageSha256": { + "detection": model_hashes[0] if model_hashes else None, + "recognition": model_hashes[1] if model_hashes else None, + }, + "qualifiedDeviceFamilies": sorted(families), + "devices": devices, + } + encoded = json.dumps( + result, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ) + result["reportSha256"] = hashlib.sha256(encoded.encode("utf-8")).hexdigest() + arguments.output.parent.mkdir(parents=True, exist_ok=True) + arguments.output.write_text( + json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(json.dumps({ + "passed": True, + "devices": len(devices), + "families": sorted(families), + "output": str(arguments.output), + }, ensure_ascii=False, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/apple/convert_models.py b/tools/apple/convert_models.py new file mode 100644 index 0000000..3921607 --- /dev/null +++ b/tools/apple/convert_models.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +"""Derive the locked FP16 Core ML programs from the PP-OCRv6 ONNX bundle.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import tempfile +import uuid + +import coremltools as ct +from coremltools.models.utils import MultiFunctionDescriptor, save_multifunction +from coremltools.proto import Model_pb2 +import numpy as np +import onnx +from onnx import helper +from onnx2torch import convert +import torch + + +ROOT = Path(__file__).resolve().parents[2] +DEFAULT_BUNDLE = ROOT / "models" / "generated" / "ppocrv6-small-onnx-20260714.2" +DEFAULT_OUTPUT = ROOT / "models" / "generated" / "apple-fp16-20260715.1" +DETECTION_SOURCE_SHA256 = "d73e0058b7a8086bbd57f3d10b8bcd4ff95363f67e06e2762b5e814fe9c9410e" +RECOGNITION_SOURCE_SHA256 = "5435fd747c9e0efe15a96d0b378d5bd157e9492ed8fd80edf08f30d02fa24634" +DETECTION_PACKAGE_SHA256 = "2097bd785947c6bc239bfcb27599362c48ec78bab72f439583e41a585b727f76" +RECOGNITION_PACKAGE_SHA256 = "c54a0719cbde2d93e65eb40dd01fff5b78373b5aaaaa648fee614d3ef3615f4b" +WIDTHS = tuple(range(320, 3201, 32)) +PACKAGE_MANIFEST_NAMESPACE = uuid.UUID("c6d6765d-4af4-50eb-9717-48bb41451b26") +COREMLTOOLS_CONVERSION_DATE_KEY = "com.github.apple.coremltools.conversion_date" + + +def file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def directory_sha256(root: Path) -> str: + digest = hashlib.sha256() + for path in sorted(item for item in root.rglob("*") if item.is_file()): + relative = path.relative_to(root).as_posix().encode("utf-8") + digest.update(relative) + digest.update(b"\0") + digest.update(file_sha256(path).encode("ascii")) + digest.update(b"\n") + return digest.hexdigest() + + +def require_source(path: Path, expected_sha256: str) -> None: + if not path.is_file(): + raise RuntimeError(f"source model is missing: {path}") + actual = file_sha256(path) + if actual != expected_sha256: + raise RuntimeError( + f"source model hash mismatch: expected {expected_sha256}, got {actual}: {path}" + ) + + +def canonicalize_package_manifest(package: Path) -> None: + """Replace Core ML's random package entry UUIDs with stable UUIDv5 values.""" + path = package / "Manifest.json" + manifest = json.loads(path.read_text("utf-8")) + entries = manifest.get("itemInfoEntries") + root_identifier = manifest.get("rootModelIdentifier") + if not isinstance(entries, dict) or root_identifier not in entries: + raise RuntimeError(f"Core ML package manifest is invalid: {path}") + canonical_entries: dict[str, object] = {} + canonical_root = "" + for identifier, entry in sorted( + entries.items(), key=lambda item: (item[1].get("path", ""), item[0]) + ): + entry_path = entry.get("path") + if not isinstance(entry_path, str) or not entry_path: + raise RuntimeError(f"Core ML package manifest entry has no path: {path}") + canonical_identifier = str( + uuid.uuid5(PACKAGE_MANIFEST_NAMESPACE, entry_path) + ).upper() + if canonical_identifier in canonical_entries: + raise RuntimeError(f"Core ML package manifest path is duplicated: {entry_path}") + canonical_entries[canonical_identifier] = entry + if identifier == root_identifier: + canonical_root = canonical_identifier + if not canonical_root: + raise RuntimeError(f"Core ML package root identifier is invalid: {path}") + manifest["itemInfoEntries"] = canonical_entries + manifest["rootModelIdentifier"] = canonical_root + path.write_text( + json.dumps(manifest, ensure_ascii=False, indent=4, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def canonicalize_package_models(package: Path) -> None: + """Remove volatile metadata and deterministically serialize model protobufs.""" + model_paths = sorted(package.rglob("*.mlmodel")) + if not model_paths: + raise RuntimeError(f"Core ML package contains no model protobuf: {package}") + for path in model_paths: + model = Model_pb2.Model() + model.ParseFromString(path.read_bytes()) + metadata = model.description.metadata.userDefined + if COREMLTOOLS_CONVERSION_DATE_KEY in metadata: + del metadata[COREMLTOOLS_CONVERSION_DATE_KEY] + path.write_bytes(model.SerializeToString(deterministic=True)) + + +def canonicalize_package(package: Path) -> None: + canonicalize_package_models(package) + canonicalize_package_manifest(package) + + +def normalized_onnx(path: Path) -> onnx.ModelProto: + model = onnx.load(path) + for node in model.graph.node: + attributes = { + attribute.name: helper.get_attribute_value(attribute) + for attribute in node.attribute + } + if attributes.get("auto_pad") != b"SAME_UPPER": + continue + kernel = attributes.get("kernel_shape") + strides = attributes.get("strides") + dilations = attributes.get("dilations", [1, 1]) + if kernel != [2, 2] or strides != [1, 1] or dilations != [1, 1]: + raise RuntimeError( + f"unsupported SAME_UPPER normalization at {node.name}: " + f"kernel={kernel}, strides={strides}, dilations={dilations}" + ) + del node.attribute[:] + for key, value in attributes.items(): + if key != "auto_pad": + node.attribute.append(helper.make_attribute(key, value)) + node.attribute.append(helper.make_attribute("pads", [0, 0, 1, 1])) + onnx.checker.check_model(model) + return model + + +def traced_model(model: onnx.ModelProto, shape: tuple[int, ...]) -> torch.jit.ScriptModule: + module = convert(model).eval() + with torch.inference_mode(): + return torch.jit.trace(module, torch.zeros(shape), strict=False).eval() + + +def convert_program( + model: onnx.ModelProto, + shape: tuple[object, ...], + trace_shape: tuple[int, ...], + destination: Path, +) -> None: + shutil.rmtree(destination, ignore_errors=True) + traced = traced_model(model, trace_shape) + program = ct.convert( + traced, + convert_to="mlprogram", + minimum_deployment_target=ct.target.macOS15, + compute_precision=ct.precision.FLOAT16, + inputs=[ct.TensorType(name="x", shape=shape, dtype=np.float32)], + ) + program.author = "Arcships" + program.license = "Apache-2.0" + program.user_defined_metadata["com.arcships.light-ocr.precision"] = "fp16" + program.save(str(destination)) + canonicalize_package(destination) + + +def generate(bundle: Path, output: Path, keep_intermediates: bool) -> dict[str, object]: + detection_source = bundle / "det" / "inference.onnx" + recognition_source = bundle / "rec" / "inference.onnx" + require_source(detection_source, DETECTION_SOURCE_SHA256) + require_source(recognition_source, RECOGNITION_SOURCE_SHA256) + detection_onnx = normalized_onnx(detection_source) + recognition_onnx = normalized_onnx(recognition_source) + + output.parent.mkdir(parents=True, exist_ok=True) + temporary_parent = output.parent + temporary = Path( + tempfile.mkdtemp(prefix=output.name + ".", suffix=".tmp", dir=temporary_parent) + ) + try: + detection_package = temporary / "detector-fp16.mlpackage" + detection_shape = ( + 1, + 3, + ct.RangeDim(32, 960, default=768), + ct.RangeDim(32, 960, default=960), + ) + convert_program( + detection_onnx, + detection_shape, + (1, 3, 768, 960), + detection_package, + ) + + functions = temporary / "recognizer-functions" + functions.mkdir() + descriptor = MultiFunctionDescriptor() + for width in WIDTHS: + static_package = functions / f"w{width:04d}.mlpackage" + shape = (1, 3, 48, width) + convert_program(recognition_onnx, shape, shape, static_package) + descriptor.add_function(str(static_package), "main", f"w{width:04d}") + descriptor.default_function_name = "w0320" + recognition_package = temporary / "recognizer-fp16.mlpackage" + save_multifunction(descriptor, str(recognition_package)) + canonicalize_package(recognition_package) + if not keep_intermediates: + shutil.rmtree(functions) + + detection_package_sha256 = directory_sha256(detection_package) + recognition_package_sha256 = directory_sha256(recognition_package) + if detection_package_sha256 != DETECTION_PACKAGE_SHA256: + raise RuntimeError( + "derived detector hash changed: " + detection_package_sha256 + ) + if recognition_package_sha256 != RECOGNITION_PACKAGE_SHA256: + raise RuntimeError( + "derived recognizer hash changed: " + recognition_package_sha256 + ) + provenance = { + "schemaVersion": "1.0", + "artifactId": output.name, + "conversion": { + "coremltools": ct.__version__, + "onnx": onnx.__version__, + "onnx2torch": "1.5.15", + "torch": torch.__version__, + "minimumMacOS": "15.0", + "precision": "fp16", + }, + "source": { + "bundleId": bundle.name, + "detectionSha256": DETECTION_SOURCE_SHA256, + "recognitionSha256": RECOGNITION_SOURCE_SHA256, + }, + "detection": { + "modelId": "PP-OCRv6_small_det_coreml_fp16_range_v1", + "package": detection_package.name, + "packageSha256": detection_package_sha256, + "inputName": "x", + "outputName": "var_1524", + "shapePolicy": "nchw-bounded-range-32-960-v1", + }, + "recognition": { + "modelId": "PP-OCRv6_small_rec_coreml_fp16_w32_v1", + "package": recognition_package.name, + "packageSha256": recognition_package_sha256, + "inputName": "x", + "outputName": "var_2113", + "shapePolicy": "nchw-static-width-multiple-32-v1", + "widths": list(WIDTHS), + "functionFormat": "w%04u", + }, + } + (temporary / "provenance.json").write_text( + json.dumps(provenance, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + shutil.rmtree(output, ignore_errors=True) + os.replace(temporary, output) + return provenance + finally: + shutil.rmtree(temporary, ignore_errors=True) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--bundle", type=Path, default=DEFAULT_BUNDLE) + parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) + parser.add_argument("--keep-intermediates", action="store_true") + arguments = parser.parse_args() + provenance = generate( + arguments.bundle.resolve(), + arguments.output.resolve(), + arguments.keep_intermediates, + ) + print(json.dumps(provenance, ensure_ascii=False, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/apple/package_bundle.py b/tools/apple/package_bundle.py new file mode 100644 index 0000000..b2bfc7e --- /dev/null +++ b/tools/apple/package_bundle.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +"""Create a self-contained Apple provider model bundle from locked artifacts.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +import shutil +import tempfile + + +ROOT = Path(__file__).resolve().parents[2] +DEFAULT_BASE = ROOT / "models" / "generated" / "ppocrv6-small-onnx-20260714.2" +DEFAULT_APPLE = ROOT / "models" / "generated" / "apple-fp16-20260715.1" +DEFAULT_OUTPUT = ROOT / "models" / "generated" / "ppocrv6-small-apple-20260715.1" +ACCEPTANCE = ROOT / "tools" / "apple" / "acceptance.json" + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def inventory(root: Path) -> dict[str, dict[str, object]]: + result: dict[str, dict[str, object]] = {} + for path in sorted(item for item in root.rglob("*") if item.is_file()): + relative = path.relative_to(root).as_posix() + if relative in {"manifest.json", "SHA256SUMS"}: + continue + result[relative] = {"bytes": path.stat().st_size, "sha256": sha256(path)} + return result + + +def checksum_inventory(root: Path) -> dict[str, dict[str, object]]: + result: dict[str, dict[str, object]] = {} + for path in sorted(item for item in root.rglob("*") if item.is_file()): + relative = path.relative_to(root).as_posix() + if relative == "SHA256SUMS": + continue + result[relative] = {"bytes": path.stat().st_size, "sha256": sha256(path)} + return result + + +def report_hash(report: dict[str, object]) -> str: + value = dict(report) + value.pop("reportSha256", None) + encoded = json.dumps( + value, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +def accepted_device_families( + path: Path, acceptance: dict[str, object], acceptance_sha256: str +) -> list[str]: + report = json.loads(path.read_text("utf-8")) + if not isinstance(report, dict): + raise RuntimeError("Apple provider baseline is not an object") + if ( + report.get("schema") != "light-ocr-apple-provider-baselines/1.0" + or report.get("status") != "accepted" + or report.get("reportSha256") != report_hash(report) + ): + raise RuntimeError("Apple provider baseline is not an accepted intact report") + approval = str(report.get("approvedByCommit", "")) + candidate_hash = str(report.get("candidateReportSha256", "")) + if ( + len(approval) != 40 + or any(value not in "0123456789abcdef" for value in approval) + or len(candidate_hash) != 64 + or any(value not in "0123456789abcdef" for value in candidate_hash) + ): + raise RuntimeError("Apple provider baseline is missing review provenance") + candidate = dict(report) + candidate["status"] = "candidate" + candidate.pop("approvedByCommit", None) + candidate.pop("candidateReportSha256", None) + candidate["reportSha256"] = candidate_hash + if report_hash(candidate) != candidate_hash: + raise RuntimeError("Apple provider baseline is not linked to its reviewed candidate") + models = acceptance["models"] + if ( + report.get("qualificationId") != acceptance["qualificationId"] + or report.get("acceptanceSha256") != acceptance_sha256 + or report.get("modelArtifactId") != models["artifactId"] + or report.get("modelPackageSha256") != { + "detection": models["detectionPackageSha256"], + "recognition": models["recognitionPackageSha256"], + } + ): + raise RuntimeError("Apple provider baseline does not match the locked acceptance") + families = report.get("qualifiedDeviceFamilies") + devices = report.get("devices") + minimum = int(acceptance["compatibility"]["minimumQualifiedDevices"]) + if ( + not isinstance(families, list) + or not isinstance(devices, list) + or len(families) < minimum + or families != sorted(set(families)) + or sorted(device.get("deviceFamily") for device in devices) != families + ): + raise RuntimeError("Apple provider baseline has invalid device coverage") + allowed = {"Apple M1", "Apple M2", "Apple M3", "Apple M4"} + if any(family not in allowed for family in families): + raise RuntimeError("Apple provider baseline contains an unsupported device family") + return families + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--base", type=Path, default=DEFAULT_BASE) + parser.add_argument("--apple", type=Path, default=DEFAULT_APPLE) + parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) + parser.add_argument("--qualification-id") + parser.add_argument( + "--qualification-report", + type=Path, + help="Reviewed contracts/apple-provider-baselines.json used by release packaging", + ) + parser.add_argument( + "--qualified-device-family", + action="append", + dest="qualified_device_families", + choices=("Apple M1", "Apple M2", "Apple M3", "Apple M4"), + default=None, + help="Qualified Core ML CPU family prefix; may be repeated", + ) + arguments = parser.parse_args() + if arguments.qualification_report and arguments.qualified_device_families: + parser.error( + "--qualification-report and --qualified-device-family are mutually exclusive" + ) + acceptance_bytes = ACCEPTANCE.read_bytes() + acceptance = json.loads(acceptance_bytes) + if arguments.qualification_report: + qualified_device_families = accepted_device_families( + arguments.qualification_report.resolve(), acceptance, + hashlib.sha256(acceptance_bytes).hexdigest(), + ) + else: + qualified_device_families = arguments.qualified_device_families or ["Apple M4"] + if len(qualified_device_families) != len(set(qualified_device_families)): + parser.error("--qualified-device-family values must be unique") + base = arguments.base.resolve() + apple = arguments.apple.resolve() + output = arguments.output.resolve() + provenance = json.loads((apple / "provenance.json").read_text("utf-8")) + locked_models = acceptance["models"] + routing = acceptance["routing"] + qualification_id = arguments.qualification_id or acceptance["qualificationId"] + if qualification_id != acceptance["qualificationId"]: + parser.error("--qualification-id must match the locked acceptance") + if ( + provenance.get("artifactId") != locked_models["artifactId"] + or provenance.get("detection", {}).get("packageSha256") + != locked_models["detectionPackageSha256"] + or provenance.get("recognition", {}).get("packageSha256") + != locked_models["recognitionPackageSha256"] + ): + raise RuntimeError("Apple model artifacts differ from the locked acceptance") + + output.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=output.name + ".", dir=output.parent) as work: + temporary = Path(work) / output.name + shutil.copytree(base, temporary) + for package in (provenance["detection"]["package"], provenance["recognition"]["package"]): + shutil.copytree(apple / package, temporary / "apple" / package) + shutil.copy2(apple / "provenance.json", temporary / "apple" / "provenance.json") + + manifest_path = temporary / "manifest.json" + manifest = json.loads(manifest_path.read_text("utf-8")) + normalized_path = temporary / manifest["normalizedConfigPath"] + normalized = json.loads(normalized_path.read_text("utf-8")) + normalized["bundleId"] = output.name + normalized_path.write_text( + json.dumps(normalized, ensure_ascii=False, sort_keys=True, separators=(",", ":")), + encoding="utf-8", + ) + manifest["schemaVersion"] = "1.1" + manifest["bundleId"] = output.name + manifest["coreCompatibility"]["minimum"] = "0.2.1" + manifest["providers"] = { + "apple": { + "schemaVersion": "1.0", + "minimumMacOS": "15.0", + "architecture": "arm64", + "qualifiedDeviceFamilies": qualified_device_families, + "qualificationId": qualification_id, + "detection": { + **provenance["detection"], + "packagePath": "apple/" + provenance["detection"]["package"], + "preferredComputeUnit": "ane", + "strictComputeUnit": "gpu", + "qualifiedMLCPUOperations": {"ios18.relu": 1, "pad": 1}, + }, + "recognition": { + **provenance["recognition"], + "packagePath": "apple/" + provenance["recognition"]["package"], + "widthMultiple": routing["recognitionWidthMultiple"], + "aneMaximumWidth": routing["recognitionAneMaximumWidth"], + "runtimeWidthBuckets": routing["recognitionRuntimeWidthBuckets"], + "maximumCachedFunctions": routing["maximumCachedFunctions"], + "qualifiedMLCPUOperations": { + "ios18.cast": 1, + "ios18.conv": 3, + "ios18.relu": 3, + "pad": 3, + }, + }, + } + } + manifest["files"] = inventory(temporary) + manifest_path.write_text( + json.dumps(manifest, ensure_ascii=False, sort_keys=True, separators=(",", ":")), + encoding="utf-8", + ) + checksums = checksum_inventory(temporary) + (temporary / "SHA256SUMS").write_text( + "".join(f"{record['sha256']} {path}\n" for path, record in checksums.items()), + encoding="ascii", + ) + shutil.rmtree(output, ignore_errors=True) + shutil.move(temporary, output) + print(json.dumps({"bundleId": output.name, "files": len(inventory(output))}, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/apple/performance_gate.py b/tools/apple/performance_gate.py new file mode 100644 index 0000000..91d1be7 --- /dev/null +++ b/tools/apple/performance_gate.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +"""Run the locked CPU/Apple latency, CPU-time, cold-start, and RSS gate.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +import platform +import shutil +import statistics +import subprocess + + +ROOT = Path(__file__).resolve().parents[2] +DEFAULT_ACCEPTANCE = ROOT / "tools" / "apple" / "acceptance.json" +DEFAULT_REPORT = ROOT / "reports" / "apple" / "performance.json" +DEFAULT_FIXTURES = ROOT / "corpus" / "fixtures" + + +def directory_bytes(path: Path) -> int: + return sum(item.stat().st_size for item in path.rglob("*") if item.is_file()) + + +def fixture_arguments(fixtures: Path, fixture_id: str) -> list[str]: + directory = fixtures / fixture_id + fixture = json.loads((directory / "fixture.json").read_text("utf-8")) + return [ + "--pixels", str(directory / "pixels.bin"), + "--width", str(fixture["width"]), + "--height", str(fixture["height"]), + "--stride", str(fixture["stride"]), + "--format", fixture["pixelFormat"], + ] + + +def run_benchmark( + executable: Path, bundle: Path, fixtures: Path, fixture_id: str, + profile: str, warmup: int, iterations: int, output: Path, +) -> dict[str, object]: + process = subprocess.run( + [ + str(executable), + "--bundle", str(bundle), + *fixture_arguments(fixtures, fixture_id), + "--profile", profile, + "--warmup", str(warmup), + "--iterations", str(iterations), + "--report", str(output), + ], + check=False, + capture_output=True, + text=True, + timeout=900, + ) + if process.returncode != 0: + raise RuntimeError( + f"benchmark failed for {fixture_id}/{profile}: " + f"{process.stdout[-4000:]}{process.stderr[-4000:]}" + ) + return json.loads(process.stdout) + + +def median(values: list[float]) -> float: + return float(statistics.median(values)) + + +def compiled_cache_hit(run: dict[str, object]) -> bool: + execution = run["execution"] + return all( + str(execution[stage]["modelCacheStatus"]).endswith("cache_hit") + for stage in ("detection", "recognition") + ) + + +def warm_peak_resident_bytes(runs: list[dict[str, object]]) -> int | None: + measurements = [ + int(run["memoryBytes"]["residentMaximum"]) + for run in runs + if compiled_cache_hit(run) + ] + return max(measurements) if measurements else None + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--native-benchmark", type=Path, required=True) + parser.add_argument("--cpu-bundle", type=Path, required=True) + parser.add_argument("--apple-bundle", type=Path, required=True) + parser.add_argument("--fixtures", type=Path, default=DEFAULT_FIXTURES) + parser.add_argument("--acceptance", type=Path, default=DEFAULT_ACCEPTANCE) + parser.add_argument("--report", type=Path, default=DEFAULT_REPORT) + parser.add_argument("--workload", action="append", dest="workloads") + parser.add_argument("--clear-compiled-cache", action="store_true") + arguments = parser.parse_args() + acceptance = json.loads(arguments.acceptance.read_text("utf-8")) + thresholds = acceptance["performance"] + locked_workloads = list(thresholds["workloadIds"]) + cold_start_workload = str(thresholds["coldStartWorkloadId"]) + if cold_start_workload not in locked_workloads: + parser.error("coldStartWorkloadId must name a locked workload") + workloads = arguments.workloads or locked_workloads + if workloads != locked_workloads: + parser.error("--workload must exactly match the locked acceptance order") + report_root = arguments.report.resolve().parent / "performance-runs" + report_root.mkdir(parents=True, exist_ok=True) + cache_root = ( + Path.home() / "Library" / "Caches" / "com.arcships.light-ocr" / "coreml-v1" + ) + records: list[dict[str, object]] = [] + qualifying_workloads = 0 + failures: list[str] = [] + maximum_peak_resident = 0 + for fixture_id in workloads: + cpu_runs: list[dict[str, object]] = [] + apple_runs: list[dict[str, object]] = [] + for run in range(int(thresholds["coldStartRuns"])): + cpu_runs.append(run_benchmark( + arguments.native_benchmark.resolve(), arguments.cpu_bundle.resolve(), + arguments.fixtures.resolve(), fixture_id, "cpu_fast", 5, + int(thresholds["warmRuns"]), + report_root / f"{fixture_id}-cpu-{run}.json", + )) + if run == 0 and arguments.clear_compiled_cache: + shutil.rmtree(cache_root, ignore_errors=True) + apple_runs.append(run_benchmark( + arguments.native_benchmark.resolve(), arguments.apple_bundle.resolve(), + arguments.fixtures.resolve(), fixture_id, "apple_interactive", 5, + int(thresholds["warmRuns"]), + report_root / f"{fixture_id}-apple-{run}.json", + )) + + cpu_p50 = median([float(run["latencyUs"]["median"]) for run in cpu_runs]) + apple_p50 = median([ + float(run["latencyUs"]["median"]) for run in apple_runs + ]) + speedup = cpu_p50 / apple_p50 + cpu_time = median([ + float(run["processCpuUs"]) / float(run["iterations"]) + for run in cpu_runs + ]) + apple_cpu_time = median([ + float(run["processCpuUs"]) / float(run["iterations"]) + for run in apple_runs + ]) + cpu_time_reduction = 1.0 - apple_cpu_time / cpu_time + cold_starts = [ + ( + float(run["loadUs"]) + float(run["engineInitializationUs"]) + + float(run["firstPredictionUs"]) + ) / 1000.0 + for run in apple_runs + ] + cache_statuses = [ + { + "detection": run["execution"]["detection"]["modelCacheStatus"], + "recognition": run["execution"]["recognition"]["modelCacheStatus"], + } + for run in apple_runs + ] + cache_hit_indices = [ + index for index, run in enumerate(apple_runs) if compiled_cache_hit(run) + ] + workload_peak = warm_peak_resident_bytes(apple_runs) + process_lifetime_peak = max( + int(run["memoryBytes"]["peakResident"]) for run in apple_runs + ) + maximum_peak_resident = max(maximum_peak_resident, workload_peak or 0) + workload_failures: list[str] = [] + if speedup < thresholds["minimumCpuP50Speedup"]: + workload_failures.append("P50 speedup is below the locked threshold") + else: + qualifying_workloads += 1 + if cpu_time_reduction < thresholds["minimumCpuTimeReduction"]: + workload_failures.append("CPU-time reduction is below the locked threshold") + if not cache_hit_indices: + workload_failures.append("no compiled-cache-hit run measured warm RSS") + elif workload_peak > thresholds["maximumWarmPeakResidentBytes"]: + workload_failures.append("peak resident memory exceeds the locked ceiling") + if fixture_id == cold_start_workload: + for index, cold_start in enumerate(cold_starts): + cache_hit = all( + value.endswith("cache_hit") + for value in cache_statuses[index].values() + ) + ceiling = ( + thresholds["maximumCompiledCacheHitColdStartMilliseconds"] + if cache_hit + else thresholds["maximumCompiledCacheMissColdStartMilliseconds"] + ) + if cold_start > ceiling: + workload_failures.append( + f"cold start {index} exceeds its cache-aware ceiling" + ) + failures.extend(f"{fixture_id}: {value}" for value in workload_failures) + records.append({ + "fixtureId": fixture_id, + "passed": not workload_failures, + "failures": workload_failures, + "cpuP50Microseconds": cpu_p50, + "appleP50Microseconds": apple_p50, + "speedup": speedup, + "cpuTimePerIterationMicroseconds": cpu_time, + "appleCpuTimePerIterationMicroseconds": apple_cpu_time, + "cpuTimeReduction": cpu_time_reduction, + "appleColdStartMilliseconds": cold_starts, + "coldStartGateApplied": fixture_id == cold_start_workload, + "appleCacheStatuses": cache_statuses, + "appleWarmPeakResidentBytes": workload_peak, + "appleProcessLifetimePeakResidentBytes": process_lifetime_peak, + "cpuRuns": cpu_runs, + "appleRuns": apple_runs, + }) + + bundle_increment = ( + directory_bytes(arguments.apple_bundle.resolve()) + - directory_bytes(arguments.cpu_bundle.resolve()) + ) + if qualifying_workloads < thresholds["minimumTargetWorkloads"]: + failures.append("fewer than two workloads passed the Provider Gate speedup") + if bundle_increment > thresholds["maximumAppleBundleIncrementBytes"]: + failures.append("Apple model bundle increment exceeds the locked ceiling") + report: dict[str, object] = { + "schemaVersion": "1.0", + "qualificationId": acceptance["qualificationId"], + "coldStartWorkloadId": cold_start_workload, + "device": platform.platform(), + "acceptanceSha256": hashlib.sha256(arguments.acceptance.read_bytes()).hexdigest(), + "passed": not failures, + "failures": failures, + "qualifyingWorkloads": qualifying_workloads, + "bundleIncrementBytes": bundle_increment, + "maximumAppleWarmPeakResidentBytes": maximum_peak_resident, + "workloads": records, + } + encoded = json.dumps(report, ensure_ascii=False, sort_keys=True, + separators=(",", ":")) + report["reportSha256"] = hashlib.sha256(encoded.encode("utf-8")).hexdigest() + arguments.report.parent.mkdir(parents=True, exist_ok=True) + arguments.report.write_text( + json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(json.dumps({ + "passed": report["passed"], + "failures": failures, + "qualifyingWorkloads": qualifying_workloads, + "report": str(arguments.report), + }, ensure_ascii=False, sort_keys=True)) + return 0 if report["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/apple/qualify_models.py b/tools/apple/qualify_models.py new file mode 100644 index 0000000..d894823 --- /dev/null +++ b/tools/apple/qualify_models.py @@ -0,0 +1,422 @@ +#!/usr/bin/env python3 +"""Produce placement and tensor-parity evidence for derived Apple models.""" + +from __future__ import annotations + +import argparse +from collections import Counter +from concurrent.futures import ThreadPoolExecutor, as_completed +import hashlib +import json +from pathlib import Path +import platform +import subprocess +import sys +import time + +import coremltools as ct +from coremltools.models.compute_plan import MLComputePlan +import numpy as np +import onnxruntime as ort + + +ROOT = Path(__file__).resolve().parents[2] +DEFAULT_BUNDLE = ROOT / "models" / "generated" / "ppocrv6-small-onnx-20260714.2" +DEFAULT_MODELS = ROOT / "models" / "generated" / "apple-fp16-20260715.1" +DEFAULT_REPORT = ROOT / "reports" / "apple" / "model-qualification.json" +DEFAULT_ACCEPTANCE = ROOT / "tools" / "apple" / "acceptance.json" +QUALIFIED_DETECTOR_CPU_OPERATIONS = {"ios18.relu": 1, "pad": 1} +QUALIFIED_RECOGNIZER_CPU_OPERATIONS = { + "ios18.cast": 1, + "ios18.conv": 3, + "ios18.relu": 3, + "pad": 3, +} + + +def cpu_operations_within_declaration( + observed: dict[str, int], declared: dict[str, int] +) -> bool: + """Return whether observed CPU fallbacks stay inside the qualified envelope.""" + return all( + operation in declared and count <= declared[operation] + for operation, count in observed.items() + ) + + +def scheduled_operations_fully_accounted_for( + placement: dict[str, object], target_device: str, + allow_cpu: bool, +) -> bool: + """Require every scheduled operation to use the declared device set. + + Core ML reports constants and other compile-time-only operations with no + device usage. Those remain visible as ``none`` and are not runtime fallback. + """ + devices = placement["preferredDevices"] + allowed_devices = {target_device, "none"} + if allow_cpu: + allowed_devices.add("MLCPUComputeDevice") + if any(device not in allowed_devices for device in devices): + return False + scheduled = sum( + int(count) for device, count in devices.items() if device != "none" + ) + accounted = int(devices.get(target_device, 0)) + if allow_cpu: + accounted += int(devices.get("MLCPUComputeDevice", 0)) + return scheduled > 0 and accounted == scheduled + + +def preferred_devices(model: ct.models.MLModel, function_name: str) -> dict[str, object]: + plan = MLComputePlan.load_from_path( + model.get_compiled_model_path(), compute_units=model.compute_unit + ) + program = plan.model_structure.program + if program is None or function_name not in program.functions: + raise RuntimeError(f"compute plan does not contain function {function_name}") + operations = program.functions[function_name].block.operations + devices: Counter[str] = Counter() + cpu_operations: Counter[str] = Counter() + for operation in operations: + usage = plan.get_compute_device_usage_for_mlprogram_operation(operation) + if usage is None: + devices["none"] += 1 + continue + device = type(usage.preferred_compute_device).__name__ + devices[device] += 1 + if device == "MLCPUComputeDevice": + cpu_operations[operation.operator_name] += 1 + return { + "operationCount": len(operations), + "preferredDevices": dict(sorted(devices.items())), + "cpuOperations": dict(sorted(cpu_operations.items())), + } + + +def tensor_parity( + onnx_path: Path, + model: ct.models.MLModel, + shape: tuple[int, ...], + seed: int, +) -> dict[str, object]: + random = np.random.default_rng(seed) + values = random.normal(0.0, 0.25, size=shape).astype(np.float32) + session = ort.InferenceSession(str(onnx_path), providers=["CPUExecutionProvider"]) + expected = session.run(None, {session.get_inputs()[0].name: values})[0] + started = time.monotonic() + actual = next(iter(model.predict({"x": values}).values())) + elapsed = time.monotonic() - started + if actual.shape != expected.shape: + raise RuntimeError(f"output shape mismatch: {actual.shape} != {expected.shape}") + difference = np.abs(expected - actual) + result: dict[str, object] = { + "shape": list(shape), + "outputShape": list(actual.shape), + "maximumAbsoluteDifference": float(np.max(difference)), + "meanAbsoluteDifference": float(np.mean(difference)), + "elementsAbove1e-3": int(np.count_nonzero(difference > 1e-3)), + "predictionMilliseconds": elapsed * 1000.0, + } + if actual.ndim == 3: + expected_indices = np.argmax(expected, axis=2) + actual_indices = np.argmax(actual, axis=2) + result["argmaxMatches"] = int(np.count_nonzero(expected_indices == actual_indices)) + result["argmaxCount"] = int(expected_indices.size) + return result + + +def probe_detector(bundle: Path, models: Path) -> dict[str, object]: + detector_path = models / "detector-fp16.mlpackage" + detector_ane = ct.models.MLModel( + str(detector_path), compute_units=ct.ComputeUnit.CPU_AND_NE + ) + detector_gpu = ct.models.MLModel( + str(detector_path), compute_units=ct.ComputeUnit.CPU_AND_GPU + ) + return { + "ane": preferred_devices(detector_ane, "main"), + "gpu": preferred_devices(detector_gpu, "main"), + "parity": tensor_parity( + bundle / "det" / "inference.onnx", + detector_gpu, + (1, 3, 768, 960), + 20260715, + ), + } + + +def probe_recognition( + bundle: Path, models: Path, width: int, ane_maximum_width: int +) -> dict[str, object]: + function_name = f"w{width:04d}" + compute_units = ( + ct.ComputeUnit.CPU_AND_NE + if width <= ane_maximum_width + else ct.ComputeUnit.CPU_AND_GPU + ) + package_path = models / "recognizer-fp16.mlpackage" + model = ct.models.MLModel( + str(package_path), + compute_units=compute_units, + function_name=function_name, + ) + # MLComputePlan only assigns devices for a multifunction package's default + # function. Rewrite only the temporary model spec's default, reusing the + # original 91-function program and weights, so every routed function gets + # placement evidence without changing the delivered program. + placement_spec = ct.utils.load_spec(str(package_path)) + placement_spec.description.defaultFunctionName = function_name + placement_model = ct.models.MLModel( + placement_spec, + weights_dir=str(package_path / "Data/com.apple.CoreML/weights"), + compute_units=compute_units, + ) + placement = preferred_devices(placement_model, function_name) + record: dict[str, object] = { + "width": width, + "function": function_name, + "requestedComputeUnits": compute_units.name, + "placementInspection": "temporary-default-function-rewrite", + "placement": placement, + } + if width in {320, 1024, 1600, 2176, 3200}: + record["parity"] = tensor_parity( + bundle / "rec" / "inference.onnx", + model, + (1, 3, 48, width), + 20260715 + width, + ) + return record + + +def isolated_probe(command: list[str], context: str) -> dict[str, object]: + try: + completed = subprocess.run( + command, + check=False, + capture_output=True, + text=True, + timeout=300, + ) + except subprocess.TimeoutExpired as error: + return { + "status": "failed", + "failure": {"context": context, "reason": "timeout", "seconds": 300}, + } + if completed.returncode != 0: + return { + "status": "failed", + "failure": { + "context": context, + "reason": "process_exit", + "returnCode": completed.returncode, + "stderrTail": completed.stderr[-4000:], + }, + } + try: + payload = json.loads(completed.stdout.strip().splitlines()[-1]) + except (IndexError, json.JSONDecodeError) as error: + return { + "status": "failed", + "failure": { + "context": context, + "reason": "invalid_probe_output", + "detail": str(error), + "stdoutTail": completed.stdout[-4000:], + "stderrTail": completed.stderr[-4000:], + }, + } + payload["status"] = "passed" + return payload + + +def placement_gate( + detection: dict[str, object], recognition: list[dict[str, object]], + ane_maximum_width: int, expected_widths: list[int], +) -> dict[str, object]: + failures: list[str] = [] + if detection.get("status") != "passed": + failures.append("detector probe did not complete") + else: + ane = detection["ane"] + gpu = detection["gpu"] + if ane["preferredDevices"].get("MLNeuralEngineComputeDevice", 0) == 0: + failures.append("detector has no Neural Engine placement") + if not scheduled_operations_fully_accounted_for( + ane, "MLNeuralEngineComputeDevice", allow_cpu=True + ): + failures.append("detector has unexpected scheduled placement") + if not cpu_operations_within_declaration( + ane["cpuOperations"], QUALIFIED_DETECTOR_CPU_OPERATIONS + ): + failures.append("detector MLCPU operations exceed the declaration") + if not scheduled_operations_fully_accounted_for( + gpu, "MLGPUComputeDevice", allow_cpu=False + ) or gpu["cpuOperations"]: + failures.append("strict detector is not fully placed on GPU") + parity = detection["parity"] + if parity["maximumAbsoluteDifference"] > 0.01 or parity["meanAbsoluteDifference"] > 0.001: + failures.append("detector tensor parity exceeds the locked tolerance") + + observed_widths: list[int] = [] + for record in recognition: + width = int(record.get("width", -1)) + observed_widths.append(width) + if record.get("status") != "passed": + failures.append(f"recognizer width {width} probe did not complete") + continue + placement = record["placement"] + devices = placement["preferredDevices"] + if width <= ane_maximum_width: + if devices.get("MLNeuralEngineComputeDevice", 0) == 0: + failures.append(f"recognizer width {width} has no Neural Engine placement") + if not scheduled_operations_fully_accounted_for( + placement, "MLNeuralEngineComputeDevice", allow_cpu=True + ): + failures.append( + f"recognizer width {width} has unexpected scheduled placement" + ) + if not cpu_operations_within_declaration( + placement["cpuOperations"], QUALIFIED_RECOGNIZER_CPU_OPERATIONS + ): + failures.append( + f"recognizer width {width} MLCPU operations exceed the declaration" + ) + elif not scheduled_operations_fully_accounted_for( + placement, "MLGPUComputeDevice", allow_cpu=False + ) or placement["cpuOperations"]: + failures.append(f"recognizer width {width} is not fully placed on GPU") + if "parity" in record: + parity = record["parity"] + if parity["argmaxMatches"] != parity["argmaxCount"]: + failures.append(f"recognizer width {width} changes an argmax token") + coverage_complete = observed_widths == expected_widths + if not coverage_complete: + failures.append("recognizer function coverage is incomplete") + return { + "passed": not failures, + "coverageComplete": coverage_complete, + "failures": failures, + "declaredMaximumMLCPUOperations": { + "detection": QUALIFIED_DETECTOR_CPU_OPERATIONS, + "recognition": QUALIFIED_RECOGNIZER_CPU_OPERATIONS, + }, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--bundle", type=Path, default=DEFAULT_BUNDLE) + parser.add_argument("--models", type=Path, default=DEFAULT_MODELS) + parser.add_argument("--report", type=Path, default=DEFAULT_REPORT) + parser.add_argument("--acceptance", type=Path, default=DEFAULT_ACCEPTANCE) + parser.add_argument("--ane-maximum-width", type=int, default=1600) + parser.add_argument( + "--qualification-id", + default="apple-fp16-mixed-20260715.1", + ) + parser.add_argument("--widths", help="Comma-separated development subset") + parser.add_argument("--jobs", type=int, default=1, + help="Independent recognition probe processes") + parser.add_argument("--probe-kind", choices=("detector", "recognition"), + help=argparse.SUPPRESS) + parser.add_argument("--probe-width", type=int, help=argparse.SUPPRESS) + arguments = parser.parse_args() + if arguments.jobs < 1 or arguments.jobs > 8: + parser.error("--jobs must be between 1 and 8") + acceptance = json.loads(arguments.acceptance.read_text("utf-8")) + locked_routing = acceptance["routing"] + if arguments.qualification_id != acceptance["qualificationId"]: + parser.error("--qualification-id must match the locked acceptance") + if arguments.ane_maximum_width != locked_routing["recognitionAneMaximumWidth"]: + parser.error("--ane-maximum-width must match the locked acceptance") + bundle = arguments.bundle.resolve() + models = arguments.models.resolve() + provenance = json.loads((models / "provenance.json").read_text("utf-8")) + + if arguments.probe_kind == "detector": + print(json.dumps(probe_detector(bundle, models), sort_keys=True)) + return 0 + if arguments.probe_kind == "recognition": + if arguments.probe_width is None: + parser.error("--probe-width is required for a recognition probe") + print(json.dumps(probe_recognition( + bundle, models, arguments.probe_width, arguments.ane_maximum_width + ), sort_keys=True)) + return 0 + + all_widths = list(provenance["recognition"]["widths"]) + widths = all_widths if arguments.widths is None else [ + int(value) for value in arguments.widths.split(",") if value + ] + base_command = [ + sys.executable, + str(Path(__file__).resolve()), + "--bundle", str(bundle), + "--models", str(models), + "--acceptance", str(arguments.acceptance.resolve()), + "--ane-maximum-width", str(arguments.ane_maximum_width), + "--qualification-id", str(arguments.qualification_id), + ] + detection = isolated_probe( + base_command + ["--probe-kind", "detector"], "detector" + ) + def recognition_probe(width: int) -> dict[str, object]: + record = isolated_probe( + base_command + ["--probe-kind", "recognition", "--probe-width", str(width)], + f"recognizer width {width}", + ) + record.setdefault("width", width) + return record + + recognition_by_width: dict[int, dict[str, object]] = {} + with ThreadPoolExecutor(max_workers=arguments.jobs) as executor: + futures = {executor.submit(recognition_probe, width): width for width in widths} + for future in as_completed(futures): + width = futures[future] + record = future.result() + recognition_by_width[width] = record + print(json.dumps({"width": width, "status": record["status"]}), flush=True) + recognition = [recognition_by_width[width] for width in widths] + + report = { + "schemaVersion": "1.0", + "qualificationId": arguments.qualification_id, + "device": { + "platform": platform.platform(), + "machine": platform.machine(), + "processor": platform.processor(), + }, + "runtime": {"coremltools": ct.__version__, "onnxruntime": ort.__version__}, + "probeProcesses": arguments.jobs, + "models": provenance, + "routing": { + "recognitionWidthMultiple": locked_routing["recognitionWidthMultiple"], + "aneMaximumWidth": arguments.ane_maximum_width, + "runtimeWidthBuckets": locked_routing["recognitionRuntimeWidthBuckets"], + "maximumCachedFunctions": locked_routing["maximumCachedFunctions"], + }, + "detection": detection, + "recognition": recognition, + } + report["gate"] = placement_gate( + detection, recognition, arguments.ane_maximum_width, all_widths + ) + encoded = json.dumps(report, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + report["reportSha256"] = hashlib.sha256(encoded.encode("utf-8")).hexdigest() + arguments.report.parent.mkdir(parents=True, exist_ok=True) + arguments.report.write_text( + json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(json.dumps({ + "passed": report["gate"]["passed"], + "report": str(arguments.report), + "reportSha256": report["reportSha256"], + }, ensure_ascii=False, sort_keys=True)) + return 0 if report["gate"]["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/apple/quality_gate.py b/tools/apple/quality_gate.py new file mode 100644 index 0000000..296c76d --- /dev/null +++ b/tools/apple/quality_gate.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +"""Compare the public CPU and Apple OCR contracts on the locked corpus.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +import platform +import subprocess +import sys + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "oracle")) +from compare import box_metrics # noqa: E402 + + +DEFAULT_ACCEPTANCE = ROOT / "tools" / "apple" / "acceptance.json" +DEFAULT_FIXTURES = ROOT / "corpus" / "fixtures" +DEFAULT_REPORT = ROOT / "reports" / "apple" / "quality.json" + + +def edit_distance(left: str, right: str) -> int: + previous = list(range(len(right) + 1)) + for left_index, left_value in enumerate(left, 1): + current = [left_index] + for right_index, right_value in enumerate(right, 1): + current.append(min( + current[-1] + 1, + previous[right_index] + 1, + previous[right_index - 1] + (left_value != right_value), + )) + previous = current + return previous[-1] + + +def run_native( + executable: Path, bundle: Path, fixture: dict[str, object], + fixture_root: Path, profile: str, +) -> dict[str, object]: + directory = fixture_root / str(fixture["id"]) + process = subprocess.run( + [ + str(executable), + "--bundle", str(bundle), + "--pixels", str(directory / "pixels.bin"), + "--width", str(fixture["width"]), + "--height", str(fixture["height"]), + "--stride", str(fixture["stride"]), + "--format", str(fixture["pixelFormat"]), + "--profile", profile, + "--diagnostics", + ], + check=False, + capture_output=True, + text=True, + timeout=180, + ) + if process.returncode != 0: + raise RuntimeError( + f"{profile} failed for {fixture['id']}: " + f"{process.stdout[-4000:]}{process.stderr[-4000:]}" + ) + return json.loads(process.stdout) + + +def greedy_matches( + cpu_lines: list[dict[str, object]], apple_lines: list[dict[str, object]], +) -> list[dict[str, object]]: + candidates: list[tuple[float, int, int]] = [] + for cpu_index, cpu in enumerate(cpu_lines): + for apple_index, apple in enumerate(apple_lines): + iou, _ = box_metrics(cpu["box"], apple["box"]) + candidates.append((iou, cpu_index, apple_index)) + used_cpu: set[int] = set() + used_apple: set[int] = set() + matches: list[dict[str, object]] = [] + for iou, cpu_index, apple_index in sorted(candidates, reverse=True): + if iou < 0.5: + break + if cpu_index in used_cpu or apple_index in used_apple: + continue + used_cpu.add(cpu_index) + used_apple.add(apple_index) + matches.append({ + "cpuIndex": cpu_index, + "appleIndex": apple_index, + "iou": iou, + "confidenceDifference": abs( + float(cpu_lines[cpu_index]["confidence"]) + - float(apple_lines[apple_index]["confidence"]) + ), + }) + return sorted(matches, key=lambda value: int(value["cpuIndex"])) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--native-validate", type=Path, required=True) + parser.add_argument("--bundle", type=Path, required=True) + parser.add_argument("--fixtures", type=Path, default=DEFAULT_FIXTURES) + parser.add_argument("--acceptance", type=Path, default=DEFAULT_ACCEPTANCE) + parser.add_argument("--report", type=Path, default=DEFAULT_REPORT) + arguments = parser.parse_args() + acceptance = json.loads(arguments.acceptance.read_text("utf-8")) + quality = acceptance["quality"] + manifest = json.loads( + (arguments.bundle.resolve() / "manifest.json").read_text("utf-8") + ) + provider = manifest.get("providers", {}).get("apple", {}) + models = { + "detectionPackageSha256": provider.get("detection", {}).get( + "packageSha256" + ), + "recognitionPackageSha256": provider.get("recognition", {}).get( + "packageSha256" + ), + "qualificationId": provider.get("qualificationId"), + } + expected_models = acceptance["models"] + if models != { + "detectionPackageSha256": expected_models["detectionPackageSha256"], + "recognitionPackageSha256": expected_models["recognitionPackageSha256"], + "qualificationId": acceptance["qualificationId"], + }: + raise RuntimeError("quality bundle differs from the locked Apple models") + fixture_root = arguments.fixtures.resolve() + fixtures = [ + json.loads(path.read_text("utf-8")) + for path in sorted(fixture_root.glob("*/fixture.json")) + ] + records: list[dict[str, object]] = [] + cpu_characters = 0 + character_errors = 0 + cpu_lines_total = 0 + matched_lines = 0 + iou_total = 0.0 + confidence_difference_total = 0.0 + critical_failures: list[str] = [] + for fixture in fixtures: + cpu = run_native( + arguments.native_validate.resolve(), arguments.bundle.resolve(), + fixture, fixture_root, "bounded_default", + ) + apple = run_native( + arguments.native_validate.resolve(), arguments.bundle.resolve(), + fixture, fixture_root, "apple_interactive", + ) + cpu_text = "\n".join(line["text"] for line in cpu["lines"]) + apple_text = "\n".join(line["text"] for line in apple["lines"]) + errors = edit_distance(cpu_text, apple_text) + matches = greedy_matches(cpu["lines"], apple["lines"]) + cpu_characters += len(cpu_text) + character_errors += errors + cpu_lines_total += len(cpu["lines"]) + matched_lines += len(matches) + iou_total += sum(float(match["iou"]) for match in matches) + confidence_difference_total += sum( + float(match["confidenceDifference"]) for match in matches + ) + exact = [line["text"] for line in cpu["lines"]] == [ + line["text"] for line in apple["lines"] + ] + if fixture["id"] in quality["criticalFixtureIds"] and not exact: + critical_failures.append(str(fixture["id"])) + records.append({ + "fixtureId": fixture["id"], + "cpuText": [line["text"] for line in cpu["lines"]], + "appleText": [line["text"] for line in apple["lines"]], + "exactText": exact, + "characterErrors": errors, + "cpuCharacters": len(cpu_text), + "cpuLineCount": len(cpu["lines"]), + "appleLineCount": len(apple["lines"]), + "matches": matches, + }) + + character_similarity = ( + 1.0 - character_errors / cpu_characters if cpu_characters else 1.0 + ) + detection_recall = matched_lines / cpu_lines_total if cpu_lines_total else 1.0 + mean_iou = iou_total / matched_lines if matched_lines else 1.0 + mean_confidence_difference = ( + confidence_difference_total / matched_lines if matched_lines else 0.0 + ) + failures: list[str] = [] + if character_similarity < quality["minimumCharacterSimilarity"]: + failures.append("character similarity is below the locked threshold") + if detection_recall < quality["minimumDetectionRecallAgainstCpu"]: + failures.append("detection recall against CPU is below the locked threshold") + if mean_iou < quality["minimumMeanMatchedIoU"]: + failures.append("matched box IoU is below the locked threshold") + if mean_confidence_difference > quality["maximumMeanMatchedConfidenceDifference"]: + failures.append("confidence drift exceeds the locked threshold") + if critical_failures: + failures.append("critical fixture text changed") + report: dict[str, object] = { + "schemaVersion": "1.0", + "qualificationId": acceptance["qualificationId"], + "device": platform.platform(), + "acceptanceSha256": hashlib.sha256(arguments.acceptance.read_bytes()).hexdigest(), + "models": models, + "passed": not failures, + "failures": failures, + "metrics": { + "fixtureCount": len(records), + "characterSimilarity": character_similarity, + "detectionRecallAgainstCpu": detection_recall, + "meanMatchedIoU": mean_iou, + "meanMatchedConfidenceDifference": mean_confidence_difference, + "criticalFailures": critical_failures, + }, + "fixtures": records, + } + encoded = json.dumps(report, ensure_ascii=False, sort_keys=True, + separators=(",", ":")) + report["reportSha256"] = hashlib.sha256(encoded.encode("utf-8")).hexdigest() + arguments.report.parent.mkdir(parents=True, exist_ok=True) + arguments.report.write_text( + json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(json.dumps({ + "passed": report["passed"], + "metrics": report["metrics"], + "report": str(arguments.report), + }, ensure_ascii=False, sort_keys=True)) + return 0 if report["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/apple/requirements.in b/tools/apple/requirements.in new file mode 100644 index 0000000..a5dd70f --- /dev/null +++ b/tools/apple/requirements.in @@ -0,0 +1,6 @@ +coremltools==9.0 +numpy==2.5.1 +onnx==1.18.0 +onnx2torch==1.5.15 +onnxruntime @ https://files.pythonhosted.org/packages/4d/de/9162872c6e502e9ac8c99a98a8738b2fab408123d11de55022ac4f92562a/onnxruntime-1.22.0-cp312-cp312-macosx_13_0_universal2.whl +torch==2.7.0 diff --git a/tools/apple/requirements.lock b/tools/apple/requirements.lock new file mode 100644 index 0000000..8104b44 --- /dev/null +++ b/tools/apple/requirements.lock @@ -0,0 +1,515 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile tools/apple/requirements.in --python-version 3.12 --python-platform aarch64-apple-darwin --generate-hashes --no-emit-index-url --output-file tools/apple/requirements.lock +attrs==26.1.0 \ + --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ + --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32 + # via + # cattrs + # coremltools +cattrs==26.1.0 \ + --hash=sha256:d1e0804c42639494d469d08d4f26d6b9de9b8ab26b446db7b5f8c2e97f7c3096 \ + --hash=sha256:fa239e0f0ec0715ba34852ce813986dfed1e12117e209b816ab87401271cdd40 + # via coremltools +coloredlogs==15.0.1 \ + --hash=sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934 \ + --hash=sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0 + # via onnxruntime +coremltools==9.0 \ + --hash=sha256:0151445cad6119eabb4f218f1c4918aef7265f71fb34a186b2ac379fa2ad6afe \ + --hash=sha256:0717c0bf556be69e862fd512ba323448ecb59d770dcbbcd029ba3583e7236685 \ + --hash=sha256:0af02216767232ece83bc4ec5035d7bba3c53c27de11be1a32e8461b4025d866 \ + --hash=sha256:0b8c907301e8896b4adadc3727bd56d81a02670cd4f845a1a3e6160c39969fc4 \ + --hash=sha256:0e079fea3f13f96a30587c9f7375796ff61cad53f703bde53c56fbf1374813ed \ + --hash=sha256:35d6e972e254081e364e6c7763eae89df8cc775dbf53756ba1ca08a2bc22f018 \ + --hash=sha256:36c0040e23b7d5e0d3efb7daf6a80d63ce27aa73634e579f66bfbb7c59be4cd5 \ + --hash=sha256:37375d48a4081a01d1659e090e2e1968e222ea7c5894d17739ffe59db62dd98d \ + --hash=sha256:4ff346b29c31c4b45acd19a20e0f0a1ac65180a96776e62f15bd5c46f4926687 \ + --hash=sha256:7079e8b6ff5a63f0e2c08eeeb8673e4eab8ca231d4b2eae4f7fb005e0d08a8cd \ + --hash=sha256:89f80d93a71954525f42e59a4f6464b3fb9641ff51cd1b710d10e94f4fecab18 \ + --hash=sha256:8e6765539e0c830ac39755e80cef9f8ff323a46c54dda051a0562a622931094b \ + --hash=sha256:8fb0dcbcf8d3d38618c1545256f3ba01632cd3d8f3d8fd7a839aa7cfe85f5c1e \ + --hash=sha256:99a101085a7919de9f1c18e514c17d2b3e6a06ad4f7a35aae9515ad47f5a843f \ + --hash=sha256:9adc304c5a891d366e513a01d5d34e43c6803511b031a3e5eba10e662d27eca0 \ + --hash=sha256:9f2f858beec7f5d486cd1a59aefb452d59347e236670b67db325795bf692f480 \ + --hash=sha256:c3965805df319d5f2755d0adfb8e28312db655be09be87bd00fad097b104be57 \ + --hash=sha256:e6e58143c5270c1a37872fef41f8c18c042d22fa38f0ad33b33250007d9e1186 \ + --hash=sha256:e9080254a4b9d286e168f3b1bc8616edd5d48ab664c17870b85e496629a00e81 \ + --hash=sha256:e9692a53b8a18891c1a54e8871de4c59ed435c5016e734c8989298b03bdb50de \ + --hash=sha256:f3247ec310eb13ce3f0e98ff76747a238ff1bde31835a2a289c84e95fe93f6a9 + # via -r tools/apple/requirements.in +filelock==3.29.7 \ + --hash=sha256:5b481979797ae69e72f0b389d89a80bdd585c260c5b3f1fb9c0a5ba9bb3f195d \ + --hash=sha256:987db6f789a3a2a59f55081801b2b3697cb97e2a736b5f1a9e99b559285fbc51 + # via torch +flatbuffers==25.12.19 \ + --hash=sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4 + # via onnxruntime +fsspec==2026.6.0 \ + --hash=sha256:02e0b71817df9b2169dc30a16832045764def1191b43dcff5bb85bdee212d2a1 \ + --hash=sha256:f5bac145310fe30e16e1471bd6840b2d990d609e872251d7e674241822abf01a + # via torch +humanfriendly==10.0 \ + --hash=sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477 \ + --hash=sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc + # via coloredlogs +jinja2==3.1.6 \ + --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \ + --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 + # via torch +markupsafe==3.0.3 \ + --hash=sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f \ + --hash=sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a \ + --hash=sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf \ + --hash=sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19 \ + --hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \ + --hash=sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c \ + --hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \ + --hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \ + --hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \ + --hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \ + --hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \ + --hash=sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26 \ + --hash=sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1 \ + --hash=sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce \ + --hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \ + --hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \ + --hash=sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695 \ + --hash=sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad \ + --hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \ + --hash=sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c \ + --hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \ + --hash=sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa \ + --hash=sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559 \ + --hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \ + --hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \ + --hash=sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758 \ + --hash=sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f \ + --hash=sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8 \ + --hash=sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d \ + --hash=sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c \ + --hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \ + --hash=sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a \ + --hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \ + --hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \ + --hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \ + --hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \ + --hash=sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2 \ + --hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \ + --hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \ + --hash=sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50 \ + --hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \ + --hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \ + --hash=sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b \ + --hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \ + --hash=sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115 \ + --hash=sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e \ + --hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \ + --hash=sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f \ + --hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \ + --hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \ + --hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \ + --hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \ + --hash=sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b \ + --hash=sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a \ + --hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \ + --hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \ + --hash=sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d \ + --hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \ + --hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \ + --hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \ + --hash=sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f \ + --hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \ + --hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \ + --hash=sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b \ + --hash=sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c \ + --hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \ + --hash=sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8 \ + --hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \ + --hash=sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6 \ + --hash=sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e \ + --hash=sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d \ + --hash=sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d \ + --hash=sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01 \ + --hash=sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7 \ + --hash=sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419 \ + --hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \ + --hash=sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1 \ + --hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \ + --hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \ + --hash=sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42 \ + --hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \ + --hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \ + --hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \ + --hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \ + --hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \ + --hash=sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591 \ + --hash=sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc \ + --hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \ + --hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50 + # via jinja2 +mpmath==1.3.0 \ + --hash=sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f \ + --hash=sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c + # via sympy +networkx==3.6.1 \ + --hash=sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509 \ + --hash=sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762 + # via torch +numpy==2.5.1 \ + --hash=sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2 \ + --hash=sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d \ + --hash=sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1 \ + --hash=sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b \ + --hash=sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd \ + --hash=sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077 \ + --hash=sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a \ + --hash=sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e \ + --hash=sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277 \ + --hash=sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6 \ + --hash=sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75 \ + --hash=sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7 \ + --hash=sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1 \ + --hash=sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9 \ + --hash=sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21 \ + --hash=sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca \ + --hash=sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0 \ + --hash=sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb \ + --hash=sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d \ + --hash=sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75 \ + --hash=sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74 \ + --hash=sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf \ + --hash=sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0 \ + --hash=sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8 \ + --hash=sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af \ + --hash=sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a \ + --hash=sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4 \ + --hash=sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22 \ + --hash=sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3 \ + --hash=sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1 \ + --hash=sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b \ + --hash=sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1 \ + --hash=sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373 \ + --hash=sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95 \ + --hash=sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6 \ + --hash=sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09 \ + --hash=sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9 \ + --hash=sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438 \ + --hash=sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2 \ + --hash=sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7 \ + --hash=sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace \ + --hash=sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3 \ + --hash=sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2 \ + --hash=sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107 + # via + # -r tools/apple/requirements.in + # coremltools + # onnx + # onnx2torch + # onnxruntime + # torchvision +onnx==1.18.0 \ + --hash=sha256:030d9f5f878c5f4c0ff70a4545b90d7812cd6bfe511de2f3e469d3669c8cff95 \ + --hash=sha256:102c04edc76b16e9dfeda5a64c1fccd7d3d2913b1544750c01d38f1ac3c04e05 \ + --hash=sha256:230b0fb615e5b798dc4a3718999ec1828360bc71274abd14f915135eab0255f1 \ + --hash=sha256:2bd5c0c55669b6d8f12e859cc27f3a631fe58730871b21f001527e1d56219e2a \ + --hash=sha256:2f4d37b0b5c96a873887652d1cbf3f3c70821b8c66302d84b0f0d89dd6e47653 \ + --hash=sha256:3c137eecf6bc618c2f9398bcc381474b55c817237992b169dfe728e169549e8f \ + --hash=sha256:3d8dbf9e996629131ba3aa1afd1d8239b660d1f830c6688dd7e03157cccd6b9c \ + --hash=sha256:4a3b50d94620e2c7c1404d1d59bc53e665883ae3fecbd856cc86da0639fd0fc3 \ + --hash=sha256:4c8c4bbda760c654e65eaffddb1a7de71ec02e60092d33f9000521f897c99be9 \ + --hash=sha256:521bac578448667cbb37c50bf05b53c301243ede8233029555239930996a625b \ + --hash=sha256:6acafb3823238bbe8f4340c7ac32fb218689442e074d797bee1c5c9a02fdae75 \ + --hash=sha256:6c093ffc593e07f7e33862824eab9225f86aa189c048dd43ffde207d7041a55f \ + --hash=sha256:6f91930c1a284135db0f891695a263fc876466bf2afbd2215834ac08f600cfca \ + --hash=sha256:73160799472e1a86083f786fecdf864cf43d55325492a9b5a1cfa64d8a523ecc \ + --hash=sha256:735e06d8d0cf250dc498f54038831401063c655a8d6e5975b2527a4e7d24be3e \ + --hash=sha256:7839bf2adb494e46ccf375a7936b5d9e241b63e1a84254f3eb2e2e184e3292c8 \ + --hash=sha256:8521544987d713941ee1e591520044d35e702f73dc87e91e6d4b15a064ae813d \ + --hash=sha256:911b37d724a5d97396f3c2ef9ea25361c55cbc9aa18d75b12a52b620b67145af \ + --hash=sha256:9235b3493951e11e75465d56f4cd97e3e9247f096160dd3466bfabe4cbc938bc \ + --hash=sha256:99afac90b4cdb1471432203c3c1f74e16549c526df27056d39f41a9a47cfb4af \ + --hash=sha256:a186b1518450e04dc3679da315a663a56429418e7ccfd947d721de9bd710b0ea \ + --hash=sha256:a3ff1735f99589be4f311eb586f2b949998614a82fb6261ae6af5a29879b9375 \ + --hash=sha256:a5810194f0f6be2e58c8d6dedc6119510df7a14280dd07ed5f0f0a85bd74816a \ + --hash=sha256:a69afd0baa372162948b52c13f3aa2730123381edf926d7ef3f68ca7cec6d0d0 \ + --hash=sha256:aa1b7483fac6cdec26922174fc4433f8f5c2f239b1133c5625063bb3b35957d0 \ + --hash=sha256:bfb1f271b1523b29f324bfd223f6a4cfbdc5a2f2f16e73563671932d33663365 \ + --hash=sha256:dc22abacfb0d3cd024d6ab784cb5eb5aca9c966a791e8e13b1a4ecb93ddb47d3 \ + --hash=sha256:e03071041efd82e0317b3c45433b2f28146385b80f26f82039bc68048ac1a7a0 \ + --hash=sha256:e189652dad6e70a0465035c55cc565c27aa38803dd4f4e74e4b952ee1c2de94b \ + --hash=sha256:e4da451bf1c5ae381f32d430004a89f0405bc57a8471b0bddb6325a5b334aa40 \ + --hash=sha256:ee159b41a3ae58d9c7341cf432fc74b96aaf50bd7bb1160029f657b40dc69715 + # via + # -r tools/apple/requirements.in + # onnx2torch +onnx2torch==1.5.15 \ + --hash=sha256:123258e0f147e07b259cf845c8113c5634b8260e52c5fb26b7d507226732d9e5 \ + --hash=sha256:d2a239cf0871cbbeec13d43e2a72ac2f8b3c8dcf80695e4d1040c3deed2b14a2 + # via -r tools/apple/requirements.in +onnxruntime @ https://files.pythonhosted.org/packages/4d/de/9162872c6e502e9ac8c99a98a8738b2fab408123d11de55022ac4f92562a/onnxruntime-1.22.0-cp312-cp312-macosx_13_0_universal2.whl \ + --hash=sha256:f3c0380f53c1e72a41b3f4d6af2ccc01df2c17844072233442c3a7e74851ab97 + # via -r tools/apple/requirements.in +packaging==26.2 \ + --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ + --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 + # via + # coremltools + # onnxruntime +pillow==12.3.0 \ + --hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 \ + --hash=sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a \ + --hash=sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59 \ + --hash=sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45 \ + --hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3 \ + --hash=sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df \ + --hash=sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139 \ + --hash=sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b \ + --hash=sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39 \ + --hash=sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e \ + --hash=sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8 \ + --hash=sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1 \ + --hash=sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8 \ + --hash=sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89 \ + --hash=sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5 \ + --hash=sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130 \ + --hash=sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd \ + --hash=sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d \ + --hash=sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b \ + --hash=sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed \ + --hash=sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace \ + --hash=sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb \ + --hash=sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931 \ + --hash=sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510 \ + --hash=sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6 \ + --hash=sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1 \ + --hash=sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce \ + --hash=sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385 \ + --hash=sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e \ + --hash=sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c \ + --hash=sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7 \ + --hash=sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace \ + --hash=sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c \ + --hash=sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f \ + --hash=sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64 \ + --hash=sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f \ + --hash=sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a \ + --hash=sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827 \ + --hash=sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17 \ + --hash=sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4 \ + --hash=sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a \ + --hash=sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701 \ + --hash=sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e \ + --hash=sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91 \ + --hash=sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66 \ + --hash=sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468 \ + --hash=sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217 \ + --hash=sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658 \ + --hash=sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418 \ + --hash=sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a \ + --hash=sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c \ + --hash=sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330 \ + --hash=sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402 \ + --hash=sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09 \ + --hash=sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930 \ + --hash=sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f \ + --hash=sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec \ + --hash=sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a \ + --hash=sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94 \ + --hash=sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468 \ + --hash=sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b \ + --hash=sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965 \ + --hash=sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8 \ + --hash=sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd \ + --hash=sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7 \ + --hash=sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c \ + --hash=sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777 \ + --hash=sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35 \ + --hash=sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9 \ + --hash=sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f \ + --hash=sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f \ + --hash=sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0 \ + --hash=sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c \ + --hash=sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71 \ + --hash=sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3 \ + --hash=sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838 \ + --hash=sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf \ + --hash=sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321 \ + --hash=sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26 \ + --hash=sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec \ + --hash=sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9 \ + --hash=sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65 \ + --hash=sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5 \ + --hash=sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e \ + --hash=sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d \ + --hash=sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198 \ + --hash=sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7 + # via torchvision +protobuf==7.35.1 \ + --hash=sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799 \ + --hash=sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87 \ + --hash=sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6 \ + --hash=sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30 \ + --hash=sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9 \ + --hash=sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4 \ + --hash=sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4 \ + --hash=sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a + # via + # coremltools + # onnx + # onnxruntime +pyaml==26.7.0 \ + --hash=sha256:11cda3a796efc6dbce0d56836be56cfd26289dad07bcd78e9904086729929c93 \ + --hash=sha256:cfa382780c43ae660669b87d394d550a41856ef175f5749f51f753e12d7077ac + # via coremltools +pyyaml==6.0.3 \ + --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ + --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ + --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ + --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ + --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ + --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ + --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ + --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ + --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ + --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ + --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ + --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ + --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ + --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ + --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ + --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ + --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ + --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ + --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ + --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ + --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ + --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ + --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ + --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ + --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ + --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ + --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ + --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ + --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ + --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ + --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ + --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ + --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ + --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ + --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ + --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ + --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ + --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ + --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ + --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ + --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ + --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ + --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ + --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ + --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ + --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ + --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ + --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ + --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ + --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ + --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ + --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ + --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ + --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ + --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ + --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ + --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ + --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ + --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ + --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ + --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ + --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ + --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ + --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ + --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ + --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ + --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ + --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ + --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 + # via pyaml +setuptools==83.0.0 \ + --hash=sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef \ + --hash=sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3 + # via torch +sympy==1.14.0 \ + --hash=sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517 \ + --hash=sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5 + # via + # coremltools + # onnxruntime + # torch +torch==2.7.0 \ + --hash=sha256:0a8d43caa342b9986101ec5feb5bbf1d86570b5caa01e9cb426378311258fdde \ + --hash=sha256:0b9960183b6e5b71239a3e6c883d8852c304e691c0b2955f7045e8a6d05b9183 \ + --hash=sha256:15aab3e31c16feb12ae0a88dba3434a458874636f360c567caa6a91f6bfba481 \ + --hash=sha256:176300ff5bc11a5f5b0784e40bde9e10a35c4ae9609beed96b4aeb46a27f5fae \ + --hash=sha256:27f5007bdf45f7bb7af7f11d1828d5c2487e030690afb3d89a651fd7036a390e \ + --hash=sha256:2a885fc25afefb6e6eb18a7d1e8bfa01cc153e92271d980a49243b250d5ab6d9 \ + --hash=sha256:2ad79d0d8c2a20a37c5df6052ec67c2078a2c4e9a96dd3a8b55daaff6d28ea29 \ + --hash=sha256:2b7813e904757b125faf1a9a3154e1d50381d539ced34da1992f52440567c156 \ + --hash=sha256:30b7688a87239a7de83f269333651d8e582afffce6f591fff08c046f7787296e \ + --hash=sha256:34e0168ed6de99121612d72224e59b2a58a83dae64999990eada7260c5dd582d \ + --hash=sha256:36a6368c7ace41ad1c0f69f18056020b6a5ca47bedaca9a2f3b578f5a104c26c \ + --hash=sha256:434cf3b378340efc87c758f250e884f34460624c0523fe5c9b518d205c91dd1b \ + --hash=sha256:58df8d5c2eeb81305760282b5069ea4442791a6bbf0c74d9069b7b3304ff8a37 \ + --hash=sha256:868ccdc11798535b5727509480cd1d86d74220cfdc42842c4617338c1109a205 \ + --hash=sha256:87b0802cab44659fcb6bcf5678d58fa4a8b48561cde8fb2d317edf0b6990e1bb \ + --hash=sha256:9b52347118116cf3dff2ab5a3c3dd97c719eb924ac658ca2a7335652076df708 \ + --hash=sha256:c9afea41b11e1a1ab1b258a5c31afbd646d6319042bfe4f231b408034b51128b \ + --hash=sha256:ccd7509141713997861b7a947ef0a717143cd7e9240addd168f38ba8fd23fd56 \ + --hash=sha256:d0ca446a93f474985d81dc866fcc8dccefb9460a29a456f79d99c29a78a66993 \ + --hash=sha256:e362efaa5b3078e5f75c33efc05005b9b46de0d2e899519d5b4cad0e050ed0f7 \ + --hash=sha256:edad98dddd82220465b106506bb91ee5ce32bd075cddbcf2b443dfaa2cbd83bf \ + --hash=sha256:f56d4b2510934e072bab3ab8987e00e60e1262fb238176168f5e0c43a1320c6d \ + --hash=sha256:fc1ed9258cbfce69970ff508ea60881818d414d098a800b7695ba36f570d34b0 \ + --hash=sha256:fd5cfbb4c3bbadd57ad1b27d56a28008f8d8753733411a140fcfb84d7f933a25 + # via + # -r tools/apple/requirements.in + # onnx2torch + # torchvision +torchvision==0.22.0 \ + --hash=sha256:0dc9b97fea14e7a8d047d0d21d8bfde6afd655c41a9a86207c9d3a7605319fcd \ + --hash=sha256:191ea28321fc262d8aa1a7fe79c41ff2848864bf382f9f6ea45c41dde8313792 \ + --hash=sha256:24b8c9255c209ca419cc7174906da2791c8b557b75c23496663ec7d73b55bebf \ + --hash=sha256:2b839ac0610a38f56bef115ee5b9eaca5f9c2da3c3569a68cc62dbcc179c157f \ + --hash=sha256:2ef38a397f1b9cf62846fb20659cb99101f9d361de8c45d79284ee45c6f40d50 \ + --hash=sha256:31c3165418fe21c3d81fe3459e51077c2f948801b8933ed18169f54652796a0f \ + --hash=sha256:3548d594ed7d0b7bc59486d642e2dd437f37910e52ab67e5f01567f12ed767dc \ + --hash=sha256:4095fac2b2e49a9c30f701e09ec1bdf3d11b1e48b006a76a9015a2ed8b39556e \ + --hash=sha256:471c6dd75bb984c6ebe4f60322894a290bf3d4b195e769d80754f3689cd7f238 \ + --hash=sha256:4ada1c08b2f761443cd65b7c7b4aec9e2fc28f75b0d4e1b1ebc9d3953ebccc4d \ + --hash=sha256:6c5620e10ffe388eb6f4744962106ed7cf1508d26e6fdfa0c10522d3249aea24 \ + --hash=sha256:6fbca169c690fa2b9b8c39c0ad76d5b8992296d0d03df01e11df97ce12b4e0ac \ + --hash=sha256:72256f1d7ff510b16c9fb4dd488584d0693f40c792f286a9620674438a81ccca \ + --hash=sha256:753d3c84eeadd5979a33b3b73a25ecd0aa4af44d6b45ed2c70d44f5e0ac68312 \ + --hash=sha256:810ea4af3bc63cf39e834f91f4218ff5999271caaffe2456247df905002bd6c0 \ + --hash=sha256:8c869df2e8e00f7b1d80a34439e6d4609b50fe3141032f50b38341ec2b59404e \ + --hash=sha256:8f116bc82e0c076e70ba7776e611ed392b9666aa443662e687808b08993d26af \ + --hash=sha256:b30e3ed29e4a61f7499bca50f57d8ebd23dfc52b14608efa17a534a55ee59a03 \ + --hash=sha256:cdc96daa4658b47ce9384154c86ed1e70cba9d972a19f5de6e33f8f94a626790 \ + --hash=sha256:ce292701c77c64dd3935e3e31c722c3b8b176a75f76dc09b804342efc1db5494 \ + --hash=sha256:ce4dc334ebd508de2c534817c9388e928bc2500cf981906ae8d6e2ca3bf4727a \ + --hash=sha256:e4017b5685dbab4250df58084f07d95e677b2f3ed6c2e507a1afb8eb23b580ca \ + --hash=sha256:e5d680162694fac4c8a374954e261ddfb4eb0ce103287b0f693e4e9c579ef957 \ + --hash=sha256:ece17995857dd328485c9c027c0b20ffc52db232e30c84ff6c95ab77201112c5 + # via onnx2torch +tqdm==4.68.4 \ + --hash=sha256:19829c9673638f2a0b8617da4cdcb927e831cd88bcfcb6e78d42a4d1af131520 \ + --hash=sha256:5168118b2368f48c561afda8020fd79195b1bdb0bdf8086b88442c267a315dc2 + # via coremltools +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 + # via + # cattrs + # onnx + # torch diff --git a/tools/benchmark/main.cpp b/tools/benchmark/main.cpp index 18e23f2..cf90d16 100644 --- a/tools/benchmark/main.cpp +++ b/tools/benchmark/main.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -57,6 +58,33 @@ std::string stable_result_hash(const light_ocr::OcrResult& result) { reinterpret_cast(canonical.data()), canonical.size()); } +const char* provider_name(light_ocr::ExecutionProvider provider) { + return provider == light_ocr::ExecutionProvider::apple ? "apple" : "cpu"; +} + +nlohmann::json session_execution_json( + const light_ocr::SessionExecutionInfo& info) { + nlohmann::json result = { + {"requestedProvider", info.requested_provider}, + {"actualProviderChain", info.actual_provider_chain}, + {"device", info.device}, + {"deviceFamily", info.device_family}, + {"operatingSystem", info.operating_system}, + {"precision", info.precision}, + {"shapePolicy", info.shape_policy}, + {"modelId", info.model_id}, + {"modelSha256", info.model_sha256}, + {"runtime", info.runtime}, + {"runtimeVersion", info.runtime_version}, + {"providerVersion", info.provider_version}, + {"modelCacheStatus", info.model_cache_status}, + {"qualificationId", info.qualification_id}, + {"sessionFallback", info.session_fallback}, + }; + if (info.fallback_reason) result["fallbackReason"] = *info.fallback_reason; + return result; +} + } // namespace int main(int argc, char** argv) { @@ -79,6 +107,13 @@ int main(int argc, char** argv) { auto pixels = light_ocr::tools::read_binary_file(arguments.pixels); const light_ocr::ImageView image{pixels.data(), pixels.size(), arguments.width, arguments.height, arguments.stride, arguments.format}; + const auto first_prediction_begin = std::chrono::steady_clock::now(); + auto first_prediction = engine.value()->recognize(image); + const auto first_prediction_end = std::chrono::steady_clock::now(); + if (!first_prediction) { + throw std::runtime_error(first_prediction.error().message + ": " + + first_prediction.error().detail); + } for (std::uint32_t index = 0; index < arguments.warmup; ++index) { auto result = engine.value()->recognize(image); if (!result) throw std::runtime_error(result.error().message + ": " + result.error().detail); @@ -108,6 +143,7 @@ int main(int argc, char** argv) { recognize_options.include_diagnostics = true; for (auto& samples : stages) samples.reserve(arguments.iterations); + const auto process_cpu_begin = std::clock(); for (std::uint32_t index = 0; index < arguments.iterations; ++index) { const auto begin = std::chrono::steady_clock::now(); auto result = engine.value()->recognize(image, recognize_options); @@ -143,6 +179,12 @@ int main(int argc, char** argv) { stages[8].push_back(timing.recognition_postprocess_us); resident.push_back(light_ocr::tools::resident_memory_bytes()); } + const auto process_cpu_end = std::clock(); + const auto process_cpu_us = static_cast( + (static_cast(process_cpu_end - process_cpu_begin) * 1'000'000.0) / + static_cast(CLOCKS_PER_SEC)); + std::uint64_t measured_wall_us = 0; + for (const auto value : wall) measured_wall_us += value; constexpr std::array stage_names = { "inputValidation", "detectionPreprocess", "detectionInference", @@ -169,8 +211,14 @@ int main(int argc, char** argv) { ? "tiled" : "upstreamExact"; nlohmann::json recognition_shapes = nlohmann::json::array(); + nlohmann::json recognition_routes = nlohmann::json::array(); for (const auto& shape : recognition_batch_shapes) { recognition_shapes.push_back({shape.batch_size, 3, shape.height, shape.width}); + recognition_routes.push_back({ + {"tensorShape", {shape.batch_size, 3, shape.height, shape.width}}, + {"computeUnit", shape.compute_unit}, + {"modelId", shape.model_id}, + {"shapeBucket", shape.shape_bucket}}); } nlohmann::json pass_report = nlohmann::json::array(); for (const auto& pass : detection_passes) { @@ -187,11 +235,19 @@ int main(int argc, char** argv) { {"modelBundleId", model_bundle_id}, {"modelBundleBytes", model_bundle_bytes}, {"profile", arguments.profile}, {"runtime", {{"coreVersion", engine_info.core_version}, + {"backend", engine_info.backend}, {"normalizedConfigSchemaVersion", engine_info.normalized_config_schema_version}, {"detectionStrategy", detection_strategy}, {"detectionMaxSide", engine_info.detection_max_side}, {"recognitionBatchSize", engine_info.default_recognition_batch_size}}}, + {"execution", + {{"requestedProvider", + provider_name(engine_info.execution.requested_provider)}, + {"detection", + session_execution_json(engine_info.execution.detection)}, + {"recognition", + session_execution_json(engine_info.execution.recognition)}}}, {"result", {{"acceptedBoxes", accepted_boxes}, {"acceptedLines", accepted_lines}, {"stableSha256", result_hashes.front()}, @@ -201,10 +257,19 @@ int main(int argc, char** argv) { {"detectionPasses", std::move(pass_report)}, {"detectionInputShape", {1, 3, detection_input_height, detection_input_width}}, - {"recognitionBatchShapes", std::move(recognition_shapes)}}}, + {"recognitionBatchShapes", std::move(recognition_shapes)}, + {"recognitionRoutes", std::move(recognition_routes)}}}, {"loadUs", elapsed_us(load_begin, load_end)}, {"engineInitializationUs", elapsed_us(initialize_begin, initialize_end)}, + {"firstPredictionUs", + elapsed_us(first_prediction_begin, first_prediction_end)}, {"warmup", arguments.warmup}, {"iterations", arguments.iterations}, + {"processCpuUs", process_cpu_us}, + {"averageProcessCpuCores", + measured_wall_us == 0 + ? 0.0 + : static_cast(process_cpu_us) / + static_cast(measured_wall_us)}, {"latencyUs", distribution(std::move(wall))}, {"reportedTotalUs", distribution(std::move(total))}, {"inferenceOnlyUs", distribution(std::move(inference_only))}, diff --git a/tools/common/arguments.hpp b/tools/common/arguments.hpp index d2ff74b..2f35313 100644 --- a/tools/common/arguments.hpp +++ b/tools/common/arguments.hpp @@ -1,10 +1,12 @@ #pragma once +#include #include #include #include #include #include +#include #include "light_ocr/types.hpp" @@ -27,6 +29,7 @@ struct Arguments { std::uint32_t minimum_boxes = 0; std::optional maximum_boxes; bool diagnostics = false; + bool reuse_engine = false; std::string diagnostics_mode = "on"; }; @@ -47,11 +50,27 @@ inline PixelFormat parse_format(const std::string& value) { inline EngineOptions engine_options_for_profile(const std::string& profile) { EngineOptions options; - if (profile == "upstream_exact") { + if (profile == "cpu_fast") { + options.intra_op_threads = std::max( + 1u, std::min(12u, std::thread::hardware_concurrency())); + } else if (profile == "upstream_exact") { options.detection.strategy = DetectionStrategy::upstream_exact; options.recognition_batch_size = 8; } else if (profile == "tiled_v1") { options.detection.strategy = DetectionStrategy::tiled; + } else if (profile == "apple_interactive" || + profile == "apple_strict" || + profile == "apple_cpu_fallback") { + options.execution.provider = ExecutionProvider::apple; + options.execution.session_fallback = + profile == "apple_cpu_fallback" ? SessionFallback::cpu + : SessionFallback::error; + options.execution.cpu_partition = + profile == "apple_strict" ? CpuPartition::forbid + : CpuPartition::allow; + options.execution.precision = Precision::fp16; + options.detection.strategy = DetectionStrategy::bounded; + options.recognition_batch_size = 1; } return options; } @@ -64,6 +83,10 @@ inline Arguments parse_arguments(int argc, char** argv, bool benchmark) { result.diagnostics = true; continue; } + if (option == "--reuse-engine") { + result.reuse_engine = true; + continue; + } if (index + 1 >= argc) throw std::runtime_error("missing value for " + option); const std::string value = argv[++index]; if (option == "--bundle") result.bundle = value; @@ -92,11 +115,14 @@ inline Arguments parse_arguments(int argc, char** argv, bool benchmark) { if (result.profile.empty()) { result.profile = benchmark ? "runtime_default" : "upstream_exact"; } - if (result.profile != "upstream_exact" && + if (result.profile != "upstream_exact" && result.profile != "cpu_fast" && result.profile != "bounded_default" && result.profile != "runtime_default" && - result.profile != "tiled_v1") { + result.profile != "tiled_v1" && result.profile != "apple_interactive" && + result.profile != "apple_strict" && + result.profile != "apple_cpu_fallback") { throw std::runtime_error( - "profile must be upstream_exact, bounded_default, runtime_default, or tiled_v1"); + "profile must be upstream_exact, cpu_fast, bounded_default, runtime_default, " + "tiled_v1, apple_interactive, apple_strict, or apple_cpu_fallback"); } if (result.diagnostics_mode != "on" && result.diagnostics_mode != "off") { throw std::runtime_error("diagnostics-mode must be on or off"); diff --git a/tools/leak_check/main.cpp b/tools/leak_check/main.cpp index 2e8c910..e99015a 100644 --- a/tools/leak_check/main.cpp +++ b/tools/leak_check/main.cpp @@ -13,19 +13,46 @@ #include "common/bundle_files.hpp" #include "common/process_memory.hpp" #include "light_ocr/core.hpp" +#include "util/sha256.hpp" namespace { -void run_cycle(const std::vector& files, - const light_ocr::ImageView& image, - const light_ocr::EngineOptions& options) { +std::unique_ptr create_engine( + const std::vector& files, + const light_ocr::EngineOptions& options) { auto bundle = light_ocr::ModelBundle::create(files); if (!bundle) throw std::runtime_error(bundle.error().message + ": " + bundle.error().detail); auto engine = light_ocr::Engine::create(std::move(bundle).value(), options); if (!engine) throw std::runtime_error(engine.error().message + ": " + engine.error().detail); - auto result = engine.value()->recognize(image); + return std::move(engine).value(); +} + +void run_page(light_ocr::Engine* engine, + const light_ocr::ImageView& image) { + auto result = engine->recognize(image); if (!result) throw std::runtime_error(result.error().message + ": " + result.error().detail); - engine.value()->close(); +} + +light_ocr::EngineInfo run_cycle( + const std::vector& files, + const light_ocr::ImageView& image, + const light_ocr::EngineOptions& options) { + auto engine = create_engine(files, options); + run_page(engine.get(), image); + auto info = engine->info(); + engine->close(); + return info; +} + +const char* provider_name(light_ocr::ExecutionProvider provider) { + return provider == light_ocr::ExecutionProvider::apple ? "apple" : "cpu"; +} + +nlohmann::json session_identity( + const light_ocr::SessionExecutionInfo& session) { + return {{"modelSha256", session.model_sha256}, + {"qualificationId", session.qualification_id}, + {"sessionFallback", session.session_fallback}}; } } // namespace @@ -39,18 +66,35 @@ int main(int argc, char** argv) { auto pixels = light_ocr::tools::read_binary_file(arguments.pixels); const light_ocr::ImageView image{pixels.data(), pixels.size(), arguments.width, arguments.height, arguments.stride, arguments.format}; - for (std::uint32_t index = 0; index < arguments.warmup; ++index) { - run_cycle(files, image, engine_options); - } - - light_ocr::tools::release_unused_memory(); - const auto baseline = light_ocr::tools::resident_memory_bytes(); std::vector resident; resident.reserve(arguments.iterations); - for (std::uint32_t index = 0; index < arguments.iterations; ++index) { - run_cycle(files, image, engine_options); + std::uint64_t baseline = 0; + light_ocr::EngineInfo engine_info; + if (arguments.reuse_engine) { + auto engine = create_engine(files, engine_options); + engine_info = engine->info(); + for (std::uint32_t index = 0; index < arguments.warmup; ++index) { + run_page(engine.get(), image); + } + light_ocr::tools::release_unused_memory(); + baseline = light_ocr::tools::resident_memory_bytes(); + for (std::uint32_t index = 0; index < arguments.iterations; ++index) { + run_page(engine.get(), image); + light_ocr::tools::release_unused_memory(); + resident.push_back(light_ocr::tools::resident_memory_bytes()); + } + engine->close(); + } else { + for (std::uint32_t index = 0; index < arguments.warmup; ++index) { + engine_info = run_cycle(files, image, engine_options); + } light_ocr::tools::release_unused_memory(); - resident.push_back(light_ocr::tools::resident_memory_bytes()); + baseline = light_ocr::tools::resident_memory_bytes(); + for (std::uint32_t index = 0; index < arguments.iterations; ++index) { + engine_info = run_cycle(files, image, engine_options); + light_ocr::tools::release_unused_memory(); + resident.push_back(light_ocr::tools::resident_memory_bytes()); + } } const auto minmax = std::minmax_element(resident.begin(), resident.end()); const auto growth = static_cast(resident.back()) - @@ -61,16 +105,27 @@ int main(int argc, char** argv) { constexpr std::int64_t maximum_growth = 32ll * 1024 * 1024; constexpr std::int64_t maximum_per_cycle = 8ll * 1024 * 1024; const bool passed = growth <= maximum_growth && per_cycle <= maximum_per_cycle; - const auto report = nlohmann::json({ + auto report_json = nlohmann::json({ {"schemaVersion", "1.0"}, {"ok", true}, {"passed", passed}, {"profile", arguments.profile}, + {"modelBundleId", engine_info.model_bundle_id}, + {"execution", + {{"requestedProvider", + provider_name(engine_info.execution.requested_provider)}, + {"detection", session_identity(engine_info.execution.detection)}, + {"recognition", session_identity(engine_info.execution.recognition)}}}, + {"lifecycleMode", arguments.reuse_engine ? "pages" : "engineCycles"}, {"warmupCycles", arguments.warmup}, {"measuredCycles", arguments.iterations}, {"residentBytes", {{"baseline", baseline}, {"minimum", *minmax.first}, {"maximum", *minmax.second}, {"final", resident.back()}, {"growth", growth}, {"growthPerCycle", per_cycle}, {"peak", light_ocr::tools::peak_resident_memory_bytes()}}}, {"gate", {{"maximumGrowthBytes", maximum_growth}, - {"maximumGrowthPerCycleBytes", maximum_per_cycle}}}}).dump() + "\n"; + {"maximumGrowthPerCycleBytes", maximum_per_cycle}}}}); + const auto canonical = report_json.dump(); + report_json["reportSha256"] = light_ocr::internal::sha256_hex( + reinterpret_cast(canonical.data()), canonical.size()); + const auto report = report_json.dump() + "\n"; if (!arguments.report.empty()) { const auto parent = arguments.report.parent_path(); if (!parent.empty()) std::filesystem::create_directories(parent); diff --git a/tools/npm/smoke.cjs b/tools/npm/smoke.cjs index 2845190..5eb997e 100644 --- a/tools/npm/smoke.cjs +++ b/tools/npm/smoke.cjs @@ -19,7 +19,7 @@ async function main() { const engine = await cjs.createEngine(); try { - assert.equal(engine.info.modelBundleId, 'ppocrv6-small-onnx-20260714.2'); + assert.equal(engine.info.modelBundleId, 'ppocrv6-small-apple-20260715.1'); assert.equal(engine.info.detectionStrategy, 'bounded'); assert.equal(engine.info.detectionMaxSide, 960); assert.equal(engine.info.defaultRecognitionBatchSize, 1); @@ -35,6 +35,31 @@ async function main() { await engine.close(); } + const apple = await cjs.createEngine({ + execution: { + provider: 'apple', + precision: 'fp16', + sessionFallback: 'cpu', + }, + }); + try { + assert.equal(apple.info.execution.requestedProvider, 'apple'); + const detection = apple.info.execution.sessions.detection; + if (apple.info.executionProvider === 'CoreML') { + assert.equal(detection.sessionFallback, false); + assert.match(detection.qualificationId, /^apple-/); + } else { + assert.equal(apple.info.executionProvider, 'CPUExecutionProvider'); + assert.equal(detection.sessionFallback, true); + assert.match( + detection.fallbackReason, + /^apple_(provider_not_built|device_unavailable|device_unqualified|initialization_failed)$/, + ); + } + } finally { + await apple.close(); + } + const tiledPixels = Buffer.alloc(2048 * 2048 * 3, 255); const offsetX = 600; const offsetY = 760; diff --git a/tools/npm_release.py b/tools/npm_release.py index 8396fbd..60cc366 100644 --- a/tools/npm_release.py +++ b/tools/npm_release.py @@ -20,7 +20,7 @@ SOURCE_VERSION = json.loads( (ROOT / "bindings" / "node" / "package.json").read_text("utf-8") )["version"] -BUNDLE_ID = "ppocrv6-small-onnx-20260714.2" +BUNDLE_ID = "ppocrv6-small-apple-20260715.1" MODEL_PACKAGE = "@arcships/light-ocr-model-ppocrv6-small" FACADE_PACKAGE = "@arcships/light-ocr" NPM_REGISTRY = "https://registry.npmjs.org/" @@ -257,10 +257,21 @@ def assemble(arguments: argparse.Namespace) -> None: raise RuntimeError("model bundle ID does not match the npm release contract") normalized_config = read_json(bundle / manifest["normalizedConfigPath"]) tiled_contract = normalized_config.get("runtimeProfiles", {}).get("tiled", {}) - if (manifest.get("schemaVersion") != "1.0" or + apple_provider = manifest.get("providers", {}).get("apple", {}) + qualified_families = apple_provider.get("qualifiedDeviceFamilies", []) + if (manifest.get("schemaVersion") != "1.1" or normalized_config.get("schemaVersion") != "1.2" or - tiled_contract.get("contractVersion") != "tiled-v1"): - raise RuntimeError("model bundle does not contain the tiled-v1 release contract") + tiled_contract.get("contractVersion") != "tiled-v1" or + apple_provider.get("schemaVersion") != "1.0" or + apple_provider.get("architecture") != "arm64" or + not isinstance(qualified_families, list) or + len(qualified_families) < 2 or + len(qualified_families) != len(set(qualified_families)) or + any(family not in {"Apple M1", "Apple M2", "Apple M3", "Apple M4"} + for family in qualified_families)): + raise RuntimeError( + "model bundle does not contain the tiled-v1 Apple release contract" + ) facade = output / "facade" facade.mkdir() diff --git a/tools/validate/main.cpp b/tools/validate/main.cpp index e2a11f5..ff9a124 100644 --- a/tools/validate/main.cpp +++ b/tools/validate/main.cpp @@ -37,6 +37,8 @@ nlohmann::json result_json(const light_ocr::OcrResult& result) { {"recognitionPostprocess", result.timing.recognition_postprocess_us}}}}; if (result.diagnostics) { nlohmann::json passes = nlohmann::json::array(); + nlohmann::json recognition_shapes = nlohmann::json::array(); + nlohmann::json recognition_routes = nlohmann::json::array(); for (const auto& pass : result.diagnostics->detection_passes) { passes.push_back({{"tileOrdinal", pass.tile_ordinal}, {"roi", {pass.x, pass.y, pass.width, pass.height}}, @@ -44,6 +46,15 @@ nlohmann::json result_json(const light_ocr::OcrResult& result) { {"contourCandidates", pass.contour_candidates}, {"rawCandidates", pass.raw_candidates}}); } + for (const auto& shape : result.diagnostics->recognition_batch_shapes) { + recognition_shapes.push_back( + {shape.batch_size, 3, shape.height, shape.width}); + recognition_routes.push_back({ + {"tensorShape", {shape.batch_size, 3, shape.height, shape.width}}, + {"computeUnit", shape.compute_unit}, + {"modelId", shape.model_id}, + {"shapeBucket", shape.shape_bucket}}); + } output["diagnostics"] = {{"detectedCandidates", result.diagnostics->detected_candidates}, {"acceptedBoxes", result.diagnostics->accepted_boxes}, {"rawDetectionBoxes", result.diagnostics->raw_detection_boxes}, @@ -52,6 +63,10 @@ nlohmann::json result_json(const light_ocr::OcrResult& result) { {"maxLiveDetectionPassBuffers", result.diagnostics->max_live_detection_pass_buffers}, {"detectionPasses", std::move(passes)}, + {"recognitionBatchShapes", + std::move(recognition_shapes)}, + {"recognitionRoutes", + std::move(recognition_routes)}, {"rejectedLines", result.diagnostics->rejected_lines.size()}}; } return output; From 9cdf5371776229a4f72f688dd6fa2284fae4a258 Mon Sep 17 00:00:00 2001 From: eric8810 Date: Wed, 15 Jul 2026 22:23:41 +0800 Subject: [PATCH 3/9] =?UTF-8?q?ci(apple):=20=E4=BA=91=E8=88=9F=E5=88=9D?= =?UTF-8?q?=E6=B8=A1=EF=BC=8C=E5=8F=8C=E8=8A=AF=E5=85=B1=E9=AA=8C=E4=B9=9D?= =?UTF-8?q?=E5=8D=81=E4=B8=80=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/apple-qualification.yml | 21 ++++++++++++++++++--- docs/build-and-release.md | 5 ++++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/.github/workflows/apple-qualification.yml b/.github/workflows/apple-qualification.yml index 7222fdd..907bee3 100644 --- a/.github/workflows/apple-qualification.yml +++ b/.github/workflows/apple-qualification.yml @@ -1,6 +1,21 @@ name: Apple device qualification on: + pull_request: + branches: [main] + paths: + - ".github/workflows/apple-qualification.yml" + - "CMakeLists.txt" + - "bindings/node/**" + - "contracts/apple-provider-baselines.schema.json" + - "include/**" + - "src/**" + - "tests/integration/apple.cpp" + - "tests/python/test_apple_qualification.py" + - "tools/apple/**" + - "tools/benchmark/**" + - "tools/common/**" + - "tools/leak_check/**" workflow_dispatch: inputs: run_qualification: @@ -18,7 +33,7 @@ concurrency: jobs: derive-models: - if: inputs.run_qualification + if: github.event_name == 'pull_request' || inputs.run_qualification runs-on: macos-15 timeout-minutes: 90 steps: @@ -42,7 +57,7 @@ jobs: retention-days: 30 qualify: - if: inputs.run_qualification + if: github.event_name == 'pull_request' || inputs.run_qualification needs: derive-models name: qualify ${{ matrix.id }} strategy: @@ -155,7 +170,7 @@ jobs: retention-days: 90 collect: - if: inputs.run_qualification + if: github.event_name == 'pull_request' || inputs.run_qualification needs: qualify runs-on: ubuntu-24.04 steps: diff --git a/docs/build-and-release.md b/docs/build-and-release.md index b8064db..5df0acd 100644 --- a/docs/build-and-release.md +++ b/docs/build-and-release.md @@ -224,9 +224,12 @@ Apple provider 另使用显式双设备资格 workflow;它在标准 `macos-15` 91-function placement、5 个哨兵宽度 tensor parity、14-fixture 质量、两 workload 性能/CPU-time、并发空缓存、 cold start/RSS 和 100 次生命周期 Gate: +首次引入 workflow 的 PR 会在相关 Apple/runtime 路径变化时自动运行,避免 +`workflow_dispatch` 只能从默认分支发现 workflow 的循环依赖。合并后可显式重跑: + ```bash gh workflow run apple-qualification.yml \ - --ref codex/apple-device-acceleration \ + --ref main \ -f run_qualification=true ``` From 78cd3acd4fc28186a6dd021cdc87d6e1b00a06ff Mon Sep 17 00:00:00 2001 From: eric8810 Date: Wed, 15 Jul 2026 22:32:19 +0800 Subject: [PATCH 4/9] =?UTF-8?q?fix(build):=20=E4=BA=91=E5=A4=96=E6=94=B6?= =?UTF-8?q?=E9=94=8B=EF=BC=8C=E5=BC=82=E5=9F=9F=E7=BC=96=E8=AF=91=E4=B8=8D?= =?UTF-8?q?=E9=97=BB=E8=99=9A=E8=AD=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/engine.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/core/engine.cpp b/src/core/engine.cpp index c298d4a..eef7e01 100644 --- a/src/core/engine.cpp +++ b/src/core/engine.cpp @@ -93,6 +93,7 @@ bool valid_execution_options(const ExecutionOptions& options) { options.precision == Precision::fp16); } +#if defined(LIGHT_OCR_HAS_COREML) internal::AppleModelPackage make_apple_package( const internal::BundleData& bundle, const internal::AppleModelConfig& model, @@ -125,6 +126,7 @@ internal::AppleModelPackage make_apple_package( } return package; } +#endif class EngineImpl final : public Engine { public: From ab94a8e5ba64f9b7cce2d01ebdd31b91ab48b50d Mon Sep 17 00:00:00 2001 From: eric8810 Date: Thu, 16 Jul 2026 00:03:58 +0800 Subject: [PATCH 5/9] =?UTF-8?q?feat(apple):=20=E4=BA=91=E5=A4=96=E6=81=AF?= =?UTF-8?q?=E7=83=BD=EF=BC=8C=E7=9C=9F=E6=9C=BA=E7=8B=AC=E7=85=A7=E8=8A=AF?= =?UTF-8?q?=E9=80=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除依赖 GitHub 托管加速器的重型资格工作流,将 placement、质量、性能、缓存和生命周期 Gate 固化为本机真实 Apple Silicon 流程。\n\n当前仅允许完整通过 Gate 的 Apple M4 加速,新增设备身份采集与未资格设备 CPU 回退证明,并同步更新资格合同、发布校验和文档。 --- .github/workflows/apple-qualification.yml | 194 ------------------ .../apple-provider-baselines.schema.json | 4 +- docs/apple-device-acceleration.md | 36 ++-- docs/build-and-release.md | 17 +- docs/implementation-status.md | 17 +- docs/model-bundle.md | 4 +- docs/native-api.md | 2 +- docs/roadmap.md | 4 +- tests/python/test_apple_qualification.py | 48 ++++- tests/python/test_npm_release.py | 2 +- tools/apple/acceptance.json | 10 +- tools/apple/capture_identity.py | 48 +++++ tools/apple/fallback_gate.py | 151 ++++++++++++++ tools/apple/qualify_models.py | 2 +- tools/npm_release.py | 2 +- 15 files changed, 284 insertions(+), 257 deletions(-) delete mode 100644 .github/workflows/apple-qualification.yml create mode 100644 tools/apple/capture_identity.py create mode 100644 tools/apple/fallback_gate.py diff --git a/.github/workflows/apple-qualification.yml b/.github/workflows/apple-qualification.yml deleted file mode 100644 index 907bee3..0000000 --- a/.github/workflows/apple-qualification.yml +++ /dev/null @@ -1,194 +0,0 @@ -name: Apple device qualification - -on: - pull_request: - branches: [main] - paths: - - ".github/workflows/apple-qualification.yml" - - "CMakeLists.txt" - - "bindings/node/**" - - "contracts/apple-provider-baselines.schema.json" - - "include/**" - - "src/**" - - "tests/integration/apple.cpp" - - "tests/python/test_apple_qualification.py" - - "tools/apple/**" - - "tools/benchmark/**" - - "tools/common/**" - - "tools/leak_check/**" - workflow_dispatch: - inputs: - run_qualification: - description: Confirm the full M1 and M2 Apple qualification run - required: true - default: false - type: boolean - -permissions: - contents: read - -concurrency: - group: apple-qualification-${{ github.ref }} - cancel-in-progress: false - -jobs: - derive-models: - if: github.event_name == 'pull_request' || inputs.run_qualification - runs-on: macos-15 - timeout-minutes: 90 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 - with: - python-version: "3.12" - - name: Install the hash-locked Apple model toolchain - run: python -m pip install --require-hashes -r tools/apple/requirements.lock - - name: Bootstrap and derive the deterministic Apple models - shell: bash - run: | - python tools/bootstrap_models.py --cache-dir .cache/models - python tools/package_model_bundle.py - python tools/apple/convert_models.py - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: apple-fp16-models - path: models/generated/apple-fp16-20260715.1 - if-no-files-found: error - retention-days: 30 - - qualify: - if: github.event_name == 'pull_request' || inputs.run_qualification - needs: derive-models - name: qualify ${{ matrix.id }} - strategy: - fail-fast: false - matrix: - include: - - id: apple-m1 - runner: macos-15 - family: Apple M1 - - id: apple-m2 - runner: macos-15-xlarge - family: Apple M2 - runs-on: ${{ matrix.runner }} - timeout-minutes: 180 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 - with: - python-version: "3.12" - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - with: - node-version: "22" - - name: Install the hash-locked Apple toolchain - run: python -m pip install --require-hashes -r tools/apple/requirements.lock - - name: Bootstrap pinned dependencies and CPU model - shell: bash - run: | - python tools/bootstrap_dependencies.py --cache-dir .cache/dependencies - python tools/bootstrap_dependencies.py --cache-dir .cache/dependencies --offline - python tools/bootstrap_models.py --cache-dir .cache/models - python tools/package_model_bundle.py - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 - with: - name: apple-fp16-models - path: models/generated/apple-fp16-20260715.1 - - name: Verify the runner and package its qualified provider - shell: bash - env: - EXPECTED_FAMILY: ${{ matrix.family }} - RUNNER_LABEL: ${{ matrix.runner }} - run: | - brand="$(sysctl -n machdep.cpu.brand_string)" - [[ "$brand" == "$EXPECTED_FAMILY"* ]] - python tools/apple/package_bundle.py \ - --qualified-device-family "$EXPECTED_FAMILY" - mkdir -p "reports/apple/${{ matrix.id }}" - python -c 'import json,os,platform,subprocess; print(json.dumps({"schemaVersion":"1.0","expectedDeviceFamily":os.environ["EXPECTED_FAMILY"],"deviceBrand":subprocess.check_output(["sysctl","-n","machdep.cpu.brand_string"],text=True).strip(),"operatingSystem":platform.platform(),"runnerLabel":os.environ["RUNNER_LABEL"]},sort_keys=True))' \ - > "reports/apple/${{ matrix.id }}/identity.json" - - name: Install verified Node development files - shell: bash - run: | - node_version="$(node -p process.versions.node)" - node_dev="$PWD/.cache/node-gyp/$node_version" - npx --yes node-gyp@11.4.2 install "$node_version" --devdir "$PWD/.cache/node-gyp" - test -f "$node_dev/include/node/node_api.h" - echo "NODE_INCLUDE_DIR=$node_dev/include/node" >> "$GITHUB_ENV" - - name: Configure and build the Apple runtime - shell: bash - run: >- - cmake -S . -B build-apple -G Ninja - -DCMAKE_BUILD_TYPE=Release - -DLIGHT_OCR_DEPENDENCY_CACHE_DIR="$PWD/.cache/dependencies" - -DLIGHT_OCR_BUILD_NODE=ON - -DLIGHT_OCR_BUILD_TESTS=ON - -DLIGHT_OCR_BUILD_TOOLS=ON - -DLIGHT_OCR_NODE_INCLUDE_DIR="$NODE_INCLUDE_DIR" - -DLIGHT_OCR_NODE_EXECUTABLE="$(command -v node)" - - name: Run native and Node contract tests - run: | - cmake --build build-apple --parallel - ctest --test-dir build-apple --output-on-failure - - name: Qualify all Core ML functions and the locked quality corpus - shell: bash - run: | - root="$PWD/reports/apple/${{ matrix.id }}" - python tools/apple/qualify_models.py \ - --jobs 2 \ - --report "$root/model-qualification.json" - python tools/apple/quality_gate.py \ - --native-validate build-apple/bin/light_ocr_validate \ - --bundle models/generated/ppocrv6-small-apple-20260715.1 \ - --report "$root/quality.json" - - name: Run latency, CPU-time, cache, RSS, and lifecycle gates - shell: bash - run: | - root="$PWD/reports/apple/${{ matrix.id }}" - python tools/apple/cache_concurrency_gate.py \ - --native-benchmark build-apple/bin/light_ocr_benchmark \ - --bundle models/generated/ppocrv6-small-apple-20260715.1 \ - --report "$root/cache-concurrency.json" - python tools/apple/performance_gate.py \ - --native-benchmark build-apple/bin/light_ocr_benchmark \ - --cpu-bundle models/generated/ppocrv6-small-onnx-20260714.2 \ - --apple-bundle models/generated/ppocrv6-small-apple-20260715.1 \ - --clear-compiled-cache \ - --report "$root/performance.json" - build-apple/bin/light_ocr_leak_check \ - --bundle models/generated/ppocrv6-small-apple-20260715.1 \ - --pixels corpus/fixtures/paddleocr-xfund-form/pixels.bin \ - --width 1488 --height 2105 --stride 4464 --format bgr8 \ - --profile apple_interactive --reuse-engine \ - --warmup 2 --iterations 100 \ - --report "$root/lifecycle.json" - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - if: always() - with: - name: apple-qualification-${{ matrix.id }} - path: reports/apple/${{ matrix.id }} - if-no-files-found: error - retention-days: 90 - - collect: - if: github.event_name == 'pull_request' || inputs.run_qualification - needs: qualify - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 - with: - pattern: apple-qualification-* - path: reports/apple/devices - merge-multiple: false - - name: Validate and collect the two-device candidate - run: >- - python tools/apple/collect_qualification.py - --reports-root reports/apple/devices - --git-commit "$GITHUB_SHA" - --output reports/apple/provider-baselines.candidate.json - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: apple-provider-baselines-candidate - path: reports/apple/provider-baselines.candidate.json - if-no-files-found: error - retention-days: 90 diff --git a/contracts/apple-provider-baselines.schema.json b/contracts/apple-provider-baselines.schema.json index f05368b..d368ef1 100644 --- a/contracts/apple-provider-baselines.schema.json +++ b/contracts/apple-provider-baselines.schema.json @@ -35,11 +35,11 @@ }, "qualifiedDeviceFamilies": { "type": "array", - "minItems": 2, + "minItems": 1, "uniqueItems": true, "items": { "enum": ["Apple M1", "Apple M2", "Apple M3", "Apple M4"] } }, - "devices": { "type": "array", "minItems": 2, "items": { "type": "object" } }, + "devices": { "type": "array", "minItems": 1, "items": { "type": "object" } }, "reportSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" } }, "allOf": [ diff --git a/docs/apple-device-acceleration.md b/docs/apple-device-acceleration.md index 05e8544..9f3bd7e 100644 --- a/docs/apple-device-acceleration.md +++ b/docs/apple-device-acceleration.md @@ -1,12 +1,12 @@ # Apple Device 加速技术方案 -状态:Implemented candidate;M4 Max 本地实现、放置、质量、性能、缓存和 100 页生命周期 Gate 已通过,M1/M2 远端资格尚待通过;不代表已经发布 +状态:Implemented and locally qualified;M4 Max 的实现、放置、质量、性能、缓存、100 页生命周期和未资格设备 CPU fallback Gate 已通过;不代表已经发布 更新时间:2026-07-15 范围:以 macOS Apple Silicon 为当前交付目标;iPhone/iPad 只保留架构兼容性,不在当前 Tier 1 平台承诺内 -实施状态:Direct Objective-C++ Core ML bridge、schema 1.1 capability manifest、哈希锁 FP16 模型派生、自包含 npm 模型包、ANE/GPU 混合路由、严格 GPU 模式、离线编译缓存、跨进程锁、20 个加权宽度桶的有界函数缓存、设备资格与显式 CPU fallback、C++/Node API 和资格工具均已实现。默认仍为 ONNX Runtime CPU;Apple 必须显式请求并由 bundle 中的设备族 allow-list 放行。正式发布仍取决于本节 Gate 和双设备 CI 证据。 +实施状态:Direct Objective-C++ Core ML bridge、schema 1.1 capability manifest、哈希锁 FP16 模型派生、自包含 npm 模型包、ANE/GPU 混合路由、严格 GPU 模式、离线编译缓存、跨进程锁、20 个加权宽度桶的有界函数缓存、设备资格与显式 CPU fallback、C++/Node API 和资格工具均已实现。默认仍为 ONNX Runtime CPU;Apple 必须显式请求并由 bundle 中的设备族 allow-list 放行。当前 accepted allow-list 只包含真实 M4 Max 上通过完整 Gate 的 `Apple M4`;M1–M3 不宣称加速并稳定回退 CPU。 关联 Roadmap:[Perf-0–Perf-4](roadmap.md#7-perf-0perf-4--性能与宿主加速线) @@ -91,27 +91,27 @@ flowchart TD 设备为 Apple M4 Max。完整文档 workload 是同一份 15 页参考 PDF,SHA-256 为 `d9be780fe4674e16ca78a09e1513dff0665ac02cbbbbc56f80381d8f0f5e12c4`,200 DPI 渲染为 1700×2200 页面。以下数据是 spike 证据,不是发布性能承诺。 -该 SHA 对应的 PDF 不在仓库、git history 或当前工作区可访问的本机文件中,因此旧 15 页数据目前只能作为历史 spike,不能被本次实现冒充为可复跑的正式证据。当前自动化 Gate 使用提交前锁定的 `generated-hello-123` 与 `paddleocr-xfund-form` 两个 workload、完整 14-fixture 质量语料和 100 次生命周期;恢复上述 PDF 后还必须补跑同一 SHA 的 15 页 scoreboard。 +该 SHA 对应的 PDF 不在仓库、git history 或当前工作区可访问的本机文件中,因此旧 15 页数据只能作为历史 spike,不能被本次实现冒充为可复跑的正式证据,也不作为当前 M4 provider 的完成或发布门槛。正式 Gate 使用提交前锁定的 `generated-hello-123` 与 `paddleocr-xfund-form` 两个 workload、完整 14-fixture 质量语料和 100 次生命周期;未来恢复该 PDF 时可补跑同一 SHA 的历史 scoreboard。 #### FP16 混合生产候选的锁定 Gate -2026-07-15 的 M4 Max 本机报告使用 acceptance SHA-256 `97b99d6e…f57d6`,得到: +2026-07-15 的 M4 Max 本机报告使用 acceptance SHA-256 `b3fe8423…5460`,得到: | 验收面 | 本机结果 | 锁定门槛 | | --- | ---: | ---: | | Core ML placement | detector 通过;recognition 91/91 函数通过 | 覆盖完整,无未声明设备回退 | | 字符相似度 | 99.6484% | ≥99.5% | | detection recall / 平均 IoU | 100% / 99.5508% | ≥99.5% / ≥98% | -| `generated-hello-123` warm P50 | 8.708 ms,相对 CPU 2.287× | ≥1.5× | -| `paddleocr-xfund-form` warm P50 | 330.837 ms,相对 CPU 2.808× | ≥1.5× | -| 两 workload CPU time 降幅 | 95.86% / 97.65% | ≥80% | -| canary cold start | cache miss 7.289 s;hit 1.285/1.282 s | miss ≤30 s;hit ≤3 s | -| warm peak RSS | 最大 695.97 MiB | ≤768 MiB | +| `generated-hello-123` warm P50 | 8.599 ms,相对 CPU 2.300× | ≥1.5× | +| `paddleocr-xfund-form` warm P50 | 331.011 ms,相对 CPU 2.851× | ≥1.5× | +| 两 workload CPU time 降幅 | 95.91% / 97.67% | ≥80% | +| canary cold start | cache miss 7.219 s;hit 1.275/1.278 s | miss ≤30 s;hit ≤3 s | +| warm peak RSS | 最大 692.14 MiB | ≤768 MiB | | Apple bundle 增量 | 25.42 MiB | ≤32 MiB | | 四进程缓存竞争 | 通过,无残留临时目录 | 只允许每阶段一个 miss | -| 同 engine 100 页 RSS 增长 | -21.39 MiB,测量最大 745.14 MiB | ≤64 MiB(工具实际执行 ≤32 MiB) | +| 同 engine 100 页 RSS 增长 | -27.47 MiB,测量最大 888.09 MiB | ≤64 MiB(工具实际执行 ≤32 MiB) | -密集表单的首次整页耗时另行保留:cache miss 54.487 s,hit 12.834/12.784 s;其中包含 113 行 OCR 和 14 个 Core ML 函数的按需装载,不纳入固定 canary 的 provider cold-start ceiling。确定性派生的 detector/recognizer 包哈希分别为 `2097bd78…7f76` 与 `c54a0719…5f4b`;模型放置、质量、性能、缓存和生命周期报告哈希分别为 `e9d371db…c7373`、`0c4d9865…326e`、`e373a9a4…a983`、`df0e7b75…5b2c` 和 `5c20fc47…6a8fb`。 +密集表单的首次整页耗时另行保留:cache miss 53.846 s,hit 12.677/12.677 s;其中包含 113 行 OCR 和 14 个 Core ML 函数的按需装载,不纳入固定 canary 的 provider cold-start ceiling。确定性派生的 detector/recognizer 包哈希分别为 `2097bd78…7f76` 与 `c54a0719…5f4b`;`.2` acceptance 下的模型放置、质量、性能、缓存、生命周期和未资格设备回退报告哈希分别为 `f9b4cfdb…d983`、`79d5b9f6…6ae1`、`cecf7607…cd8d`、`8356c20c…2f64`、`f695157a…6195` 和 `2e72ab7e…d823`。 #### CPU 与 FP16 GPU @@ -352,13 +352,13 @@ Apple provider 继承 Roadmap Provider Gate,并增加交互式 CPU 目标: - 公共 contract 100% 通过;FP16/W8A8 的质量容差在查看最终 benchmark 前锁定; - reference PDF 不允许出现预注册的关键文本漏检; - 固定启动 canary 的 cold start、warm RSS、包增量和缓存行为通过预注册 ceiling;其他 workload 仍报告首次整页耗时; -- 至少在两台目标设备上复核,其中 W8A8 必须覆盖计划宣称支持的硬件代际。 +- 每个进入 allow-list 的设备族必须至少在一台真实目标设备上完成全套复核;当前只发布资格内的 `Apple M4`。W8A8 若未来启用,必须覆盖计划宣称支持的每个硬件代际。 ## 12. 分阶段落地 ### Phase A — 固化证据和决策 -状态:除缺失的旧 15 页 PDF 重跑外已完成。Direct Core ML、shape contract、质量/性能/RSS/cache 阈值和 D111 addendum 已锁定。 +状态:已完成。Direct Core ML、shape contract、质量/性能/RSS/cache 阈值和 D111 addendum 已锁定;缺失的旧 15 页 PDF 只保留为历史 spike 复跑项。 - 统一 CPU/CoreML 的 15 页 PDF benchmark harness。 - 固定测试 corpus、质量指标和最简 scoreboard。 @@ -369,7 +369,7 @@ Apple provider 继承 Roadmap Provider Gate,并增加交互式 CPU 目标: ### Phase B — FP16 Apple interactive preview -状态:实现完成,M4 Max 本机 Gate 已通过,M1/M2 双设备 Gate 待远端验收。 +状态:实现完成,M4 Max 本机全套 Gate 已通过;M1–M3 未进入 allow-list,按合同稳定回退 CPU。 - Detector、常规 recognition 优先 FP16 ANE。 - ANE-unqualified recognition shape 使用 FP16 GPU。 @@ -391,23 +391,23 @@ Apple provider 继承 Roadmap Provider Gate,并增加交互式 CPU 目标: ### Phase D — 稳定自包含分发 -状态:代码与 workflow 已实现;npm release 会在 macOS 从哈希锁工具链派生 Core ML 工件,并把 Apple superset bundle 与四平台 native package 组装为原有六包入口。正式发布仍需双设备 candidate 被接受。 +状态:代码与发布流程已实现;npm release 会在 macOS 从哈希锁工具链派生 Core ML 工件,并把 Apple superset bundle 与四平台 native package 组装为原有六包入口。重型模型派生、placement 和性能资格只在真实本机运行,不进入 CI;CI 保持跨平台编译、契约与轻量单测。 - 将通过 Gate 的 Apple runtime/provider、模型派生物、SBOM、licenses、provenance 和签名纳入由主 facade 自动取得的 Darwin native release set,不新增用户安装入口。 - 固化 device/OS/model compatibility manifest 和故障语义。 -- 在未安装任何额外 provider/runtime 的干净目标机上,通过两个目标设备、正式 corpus、禁网安装和 release qualification。 +- 在未安装任何额外 provider/runtime 的干净目标机上,对每个拟加入 allow-list 的设备族运行正式 corpus、禁网安装和 release qualification。 退出条件:用户仅安装 `@arcships/light-ocr` 即可运行;从 `engine.info()` 和 qualification report 可以证明实际执行路径,且稳定 CPU fallback 保持可用。 ## 13. 已落地决策与剩余外部证据 1. 正式 backend 候选为 Direct Core ML;ORT 1.22 CoreML EP 在禁止 CPU fallback 时不能完整放置当前 graph,保留为未来对照而非产品路径。 -2. FP16 只对 manifest 明列且独立通过资格的 Apple Silicon family 启用;当前本机包只列 M4,M1/M2 必须由双设备 CI 产生证据后才能加入发布 allow-list。W8A8 仍不发布。 +2. FP16 只对 manifest 明列且在真实本机独立通过资格的 Apple Silicon family 启用;当前包只列 M4。M1–M3 必须取得对应真实设备并完成同一套本地 Gate 后才能加入 allow-list;CI 虚拟 M1 不暴露 GPU/Neural Engine,不能作为加速证据。W8A8 仍不发布。 3. Detector 使用 32–960 的受限 range MLProgram;interactive 使用资格内 ANE/MLCPU envelope,strict 使用全 GPU。 4. Recognition 使用 320–3200、步长 32 的 91-function MLProgram 做全量资格审查;运行时向上取整到锁定的 20 个加权 bucket,≤1600 走 ANE envelope,>1600 走 GPU,LRU≤20。 5. 随包携带源 `.mlpackage`,首次运行离线编译并以 package/OS/device identity 缓存;不分发跨 OS 的预编译 `.mlmodelc`。 6. 质量、两 workload speedup、CPU-time 降幅、canary 的 3 次 cold start、30 次 warm、RSS、同 engine 100 页生命周期和 32 MiB 包增量阈值由 `tools/apple/acceptance.json` 锁定。 -7. 剩余外部证据只有:M1/M2 workflow 的真实通过报告,以及重新取得 SHA `d9be…12c4` 的旧 15 页 PDF 后补跑文档 scoreboard。两者没有完成前,状态保持 candidate。 +7. M4 的 placement、质量、两 workload 性能、CPU-time、cache、RSS、100 页生命周期和未资格设备 CPU fallback 报告已完成并由 accepted provider baseline 锁定。旧 SHA `d9be…12c4` PDF 仅是无法复跑的历史 scoreboard,不阻塞当前 M4 实现;新增设备族时必须重新执行本地资格流程并审阅新 baseline。 ## 14. 关联工作 diff --git a/docs/build-and-release.md b/docs/build-and-release.md index 5df0acd..0fce3b8 100644 --- a/docs/build-and-release.md +++ b/docs/build-and-release.md @@ -219,22 +219,11 @@ gh workflow run tiled-qualification.yml --ref main -f run_benchmark=true benchmark 结果是独立资格审查证据,不是每次发布的重复步骤。需要建立或更新 accepted baseline 时,仍须人工 review 并作为源码提交;脚本不会自动接受当前值。`promote_latest` 默认为 `false`,需要在 registry evidence 人工核对后显式选择。 -Apple provider 另使用显式双设备资格 workflow;它在标准 `macos-15` -(M1) 与 arm64 larger `macos-15-xlarge` (M2) 上消费同一模型 artifact,逐一运行 -91-function placement、5 个哨兵宽度 tensor parity、14-fixture 质量、两 workload 性能/CPU-time、并发空缓存、 -cold start/RSS 和 100 次生命周期 Gate: +Apple provider 的模型派生、91-function placement、tensor parity、14-fixture 质量、两 workload 性能/CPU-time、并发空缓存、cold start/RSS 和 100 次生命周期 Gate 只在真实 Apple Silicon 本机执行,不进入 GitHub Actions。标准 hosted macOS runner 是虚拟 M1,不暴露可用于资格审查的 GPU/Neural Engine;普通 CI 只保留跨平台编译、契约和轻量单测。 -首次引入 workflow 的 PR 会在相关 Apple/runtime 路径变化时自动运行,避免 -`workflow_dispatch` 只能从默认分支发现 workflow 的循环依赖。合并后可显式重跑: +本地资格目录必须先用 `tools/apple/capture_identity.py` 记录真实设备身份,再依次运行 `qualify_models.py`、`quality_gate.py`、`cache_concurrency_gate.py`、`performance_gate.py`、`light_ocr_leak_check` 和 `fallback_gate.py`。完整命令及阈值以 [Apple Device 加速技术方案](apple-device-acceleration.md) 和 `tools/apple/acceptance.json` 为准。每个拟加入 allow-list 的设备族都必须单独保留五份正向报告;`fallback_gate.py` 通过临时排除当前设备族,证明 `apple_device_unqualified` 会稳定切到 ONNX Runtime CPU。 -```bash -gh workflow run apple-qualification.yml \ - --ref main \ - -f run_qualification=true -``` - -`collect` 只输出 candidate;它会验证模型、质量、性能、缓存和生命周期报告哈希,但仍必须审阅设备身份、报告内容和门槛。审阅后用 -`tools/apple/accept_qualification.py` 生成并提交 `contracts/apple-provider-baselines.json`;npm release 会校验该文件的自身哈希、acceptance、模型身份与至少两个设备族,并只从这个 accepted contract 生成发布 bundle allow-list。 +`tools/apple/collect_qualification.py` 只从本地真机报告输出 candidate;它会验证设备身份、模型、质量、性能、缓存和生命周期报告自哈希。审阅后用 `tools/apple/accept_qualification.py` 生成并提交 `contracts/apple-provider-baselines.json`。npm release 会校验该文件的自身哈希、acceptance、模型身份与至少一个真实资格设备族,并只从 accepted contract 生成发布 bundle allow-list;当前只包含 `Apple M4`。 `.github/workflows/npm-promote.yml` 只负责给已经发布且完整性已验证的 release set 更新 dist-tag。它必须引用原 `npm release` run 保存的 `light-ocr-npm-` artifact,逐包复核 registry integrity,并按 model/native 依赖优先、facade 最后的顺序更新;不会重新构建、测试或发布 tarball。该 workflow 用于人工分阶段 promotion,以及 npm metadata 最终一致性导致主发布 job 在 tag 校验阶段中断后的安全恢复。 diff --git a/docs/implementation-status.md b/docs/implementation-status.md index 452be8c..a7de78f 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -1,7 +1,7 @@ # C++ Core 与 Node-API 实施状态 更新时间:2026-07-15
-结论:`@arcships/light-ocr@0.2.0` 已发布并提升为 npm `latest`。当前 0.2.1 源码候选已实现 Direct Core ML FP16 Apple provider、自包含模型派生物、ANE/GPU 混合路由、缓存/回退和 C++/Node 可观测性;CPU 与 bounded/960 仍是默认。M4 Max 本机全部锁定 Gate 已通过,M1/M2 双设备 workflow 已配置但尚无远端通过证据,因此还不能宣称 Apple provider 已发布。 +结论:`@arcships/light-ocr@0.2.0` 已发布并提升为 npm `latest`。当前 0.2.1 源码候选已实现 Direct Core ML FP16 Apple provider、自包含模型派生物、ANE/GPU 混合路由、缓存/回退和 C++/Node 可观测性;CPU 与 bounded/960 仍是默认。M4 Max 本机全部锁定 Gate 与未资格设备 CPU fallback Gate 已通过,accepted allow-list 只包含 `Apple M4`;M1–M3 不宣称加速。Apple provider 尚未发布到 npm。 状态含义: @@ -25,7 +25,7 @@ | 无 network/shell/cwd/locale 运行依赖 | Done | sterile cwd/minimal env 与 Linux network namespace disabled 测试通过;npm release 另完成已安装 package 的禁网运行。 | | manifest、hash、licenses、SBOM、parity、benchmark | Done | Release commit 已重新生成并保存四平台 metadata、六个 npm tarballs 的 hashes/integrity、parity、quality 与 benchmark 证据。 | | N-API/npm 非本 Core milestone | Done / `0.2.0` published | raw Node-API v8、CJS/ESM、`.d.ts`、内置模型解析、四平台 prebuild、双重背压、AbortSignal 与生命周期均已完成;[npm release run 29340467784](https://github.com/arcships/light-ocr/actions/runs/29340467784) 与 [promotion run 29342178842](https://github.com/arcships/light-ocr/actions/runs/29342178842) 保存六包发布、registry 和禁网证据。 | -| Perf-1A / Apple execution | Implemented locally / qualification running | provider-neutral `InferenceSession` 已加入 Objective-C++ Direct Core ML;公开 union 为 `cpu | apple`。detector 使用 FP16 range model,recognizer 使用 91-function FP16 MLProgram 完成全宽度放置审查,运行时使用锁定的 20 个加权宽度桶;interactive 为 ANE + 宽文本 GPU,strict 为 GPU,整 session CPU fallback 有稳定原因。schema 1.1 bundle、哈希锁模型、离线编译缓存、跨进程锁、LRU≤20、device/OS/qualification/逐批 route 诊断和 Node 映射均已完成。M1/M2 远端双设备 Gate 尚待运行。 | +| Perf-1A / Apple execution | Done locally / M4 qualified | provider-neutral `InferenceSession` 已加入 Objective-C++ Direct Core ML;公开 union 为 `cpu | apple`。detector 使用 FP16 range model,recognizer 使用 91-function FP16 MLProgram 完成全宽度放置审查,运行时使用锁定的 20 个加权宽度桶;interactive 为 ANE + 宽文本 GPU,strict 为 GPU,整 session CPU fallback 有稳定原因。schema 1.1 bundle、哈希锁模型、离线编译缓存、跨进程锁、LRU≤20、device/OS/qualification/逐批 route 诊断和 Node 映射均已完成。重型资格只在真实本机运行;当前 M4 已通过,其他家族保持 CPU fallback。 | | Node.js JPEG/PNG 内存输入 | Done / `0.2.0` published | `recognizeEncoded(Uint8Array)` 在 engine worker 上使用固定 stb revision 解码,保持 Core raw-pixel 边界;格式、尺寸、pixels、临时内存、queue/snapshot budget、AbortSignal 与 `timingUs.decode` 均有四平台 Node 22/24 package 测试。 | | 高分辨率峰值内存 | Done | Release 原生独立进程本机参考:2048² 空白 `318.8 MiB ≤ 384 MiB`;xfund 密集表单 116 框 `400.5 MiB ≤ 640 MiB`。四平台 release jobs 的真实模型与 RSS gates 均通过。 | | Tiled 高分辨率准确模式 | Done / `0.2.0` published | 1280 tile、2048→4-pass row-major、全局 candidate ceiling、IoU/IOS greedy merge、原图 recognition、C++/Node contract、8-fixture/196-line corpus、独立 oracle、四平台 36-entry accepted baseline 与 package smoke 均已完成。 | @@ -46,12 +46,13 @@ | offline contract | sterile cwd/minimal locale environment passed | | model archive | 已发布 `.1`:31,334,400 bytes / `74e246bf…de17`;已发布 tiled `.2`:31,334,400 bytes / `e543b93b…712f` | | Node-API v1 | Node.js 22.13.0;macOS arm64 Release/Werror 构建;CTest 3/3;bounded/exact 映射、真实 PP-OCRv6 API、snapshot/byteOffset、校验、symlink root、双重背压、abort、heartbeat、close/worker teardown 测试通过 | -| Perf-1A local validation | macOS arm64 Release/Werror 构建;Apple Release CTest 7/7、Node 绑定 16/16、Python Apple/npm 合约 9/9;CPU 默认结果不变,逐 session execution summary、未知 provider、FP16、device ID 和无效 fallback 组合均有 C++/Node integration 覆盖 | -| Apple model placement | detector interactive 为 190 ANE + 2 个已声明 MLCPU 操作,strict 为 192 GPU;recognition 91/91 宽度函数全部通过,宽区间 213 GPU 且无 MLCPU;detector/recognizer 包哈希 `2097bd78…7f76` / `c54a0719…5f4b`,报告 `e9d371db…c7373` | -| Apple quality | 14 fixtures 全部通过;字符相似度 99.6484%,detection recall 100%,平均 IoU 99.5508%,平均置信度差 0.004349,critical failure 0;报告 `0c4d9865…326e` | -| Apple performance | hello / xfund warm P50 为 8.708 / 330.837 ms,相对 CPU-fast 加速 2.287× / 2.808×,CPU time 降低 95.86% / 97.65%;canary cold cache miss 7.289 s、hit 1.285/1.282 s;warm peak RSS 最大 695.97 MiB,bundle 增量 25.42 MiB;报告 `e373a9a4…a983` | -| Apple cache concurrency | 4 进程竞争通过;detector/recognizer 各恰好一个 miss、3 个 hit,结果哈希一致且无临时目录残留;报告 `df0e7b75…5b2c` | -| Apple 100-page lifecycle | 同一 interactive engine 预热 2 页后连续处理 100 个 xfund 密集页;RSS baseline/final/maximum 为 743.28/721.89/745.14 MiB,growth -21.39 MiB,通过 32 MiB 工具门槛和 64 MiB acceptance;报告 `5c20fc47…6a8fb` | +| Perf-1A local validation | macOS arm64 Release/Werror 构建;Apple Release CTest 7/7、Node 绑定 16/16、Python Apple/npm 合约 13/13;CPU 默认结果不变,逐 session execution summary、未知 provider、FP16、device ID 和无效 fallback 组合均有 C++/Node integration 覆盖 | +| Apple model placement | detector interactive 为 190 ANE + 2 个已声明 MLCPU 操作,strict 为 192 GPU;recognition 91/91 宽度函数全部通过,宽区间 213 GPU 且无 MLCPU;detector/recognizer 包哈希 `2097bd78…7f76` / `c54a0719…5f4b`,`.2` 报告 `f9b4cfdb…d983` | +| Apple quality | 14 fixtures 全部通过;字符相似度 99.6484%,detection recall 100%,平均 IoU 99.5508%,平均置信度差 0.004349,critical failure 0;`.2` 报告 `79d5b9f6…6ae1` | +| Apple performance | hello / xfund warm P50 为 8.599 / 331.011 ms,相对 CPU-fast 加速 2.300× / 2.851×,CPU time 降低 95.91% / 97.67%;canary cold cache miss 7.219 s、hit 1.275/1.278 s;warm peak RSS 最大 692.14 MiB,bundle 增量 25.42 MiB;`.2` 报告 `cecf7607…cd8d` | +| Apple cache concurrency | 4 进程竞争通过;detector/recognizer 各恰好一个 miss、3 个 hit,结果哈希一致且无临时目录残留;`.2` 报告 `8356c20c…2f64` | +| Apple 100-page lifecycle | 同一 interactive engine 预热 2 页后连续处理 100 个 xfund 密集页;RSS baseline/final/maximum 为 887.27/859.80/888.09 MiB,growth -27.47 MiB,通过 32 MiB 工具门槛和 64 MiB acceptance;`.2` 报告 `f695157a…6195` | +| Apple unqualified fallback | 本机以不含 M4 的临时 allow-list 请求 `apple_cpu_fallback`,detector/recognizer 均稳定落到 ONNX Runtime CPU,原因 `apple_device_unqualified`,canary 保持 `HELLO 123`;报告 `2e72ab7e…d823` | | Tiled corpus | 八张 2048² locked fixtures 共 196 行:196 TP / 0 FP / 0 FN、CER 0、duplicate line 0;独立 oracle 与原生 pass tensor、candidate source、suppression、representative、crop、decode 和 final order 对齐;side override、tile ceiling、global candidate ceiling 均返回稳定错误 | | Tiled qualification | [run 29336329115](https://github.com/arcships/light-ocr/actions/runs/29336329115) 四个平台采样 jobs 成功;36 个 Core/Node 22/Node 24 entries 已受审。各平台最大 Core/Node 峰值:Linux x64 639.7/715.6 MiB、Windows x64 616.1/667.5 MiB、macOS arm64 667.4/733.6 MiB、macOS x64 623.1/672.8 MiB | diff --git a/docs/model-bundle.md b/docs/model-bundle.md index f2ac58d..6cff2dd 100644 --- a/docs/model-bundle.md +++ b/docs/model-bundle.md @@ -1,6 +1,6 @@ # light-ocr Model Bundle -Status: normalized schema 1.2 / `tiled-v1` published in npm `0.2.0`; manifest schema 1.1 Apple provider candidate implemented for 0.2.1
+Status: normalized schema 1.2 / `tiled-v1` published in npm `0.2.0`; manifest schema 1.1 Apple provider implemented and locally qualified for M4 in 0.2.1 source
Authority: model identity, bundle schema, normalized configuration, integrity, and licensing Requirements: [requirements.md](requirements.md) @@ -183,7 +183,7 @@ The real manifest lists every payload file. Core `0.1.x` and `0.2.0` accept mani Schema 1.1 adds a top-level `providers.apple` object. Its release contract fixes: - `minimumMacOS: "15.0"`, `architecture: "arm64"`, a non-empty qualified - Apple Silicon family list, and `qualificationId: "apple-fp16-mixed-20260715.1"`; + Apple Silicon family list, and `qualificationId: "apple-fp16-mixed-20260715.2"`; - detector package/model/hash/tensor/shape identities, interactive ANE and strict GPU policies, plus the maximum qualified MLCPU operation envelope; - recognizer package identity, 32-pixel width multiple, ANE maximum width 1600, diff --git a/docs/native-api.md b/docs/native-api.md index 1c91a32..a8ef9df 100644 --- a/docs/native-api.md +++ b/docs/native-api.md @@ -1,6 +1,6 @@ # light-ocr Native C++ API -Status: Core 0.2.0 tiled contract published;0.2.1 Apple provider source candidate implemented and under qualification
+Status: Core 0.2.0 tiled contract published;0.2.1 Apple provider source implemented and locally qualified for M4
Authority: public C++ source contract, ownership, lifecycle, errors, and compatibility Requirements: [requirements.md](requirements.md) Architecture: [architecture.md](architecture.md) diff --git a/docs/roadmap.md b/docs/roadmap.md index 563ebdf..80d2721 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -126,7 +126,7 @@ flowchart LR | G2 模型 Pareto 验证 | D107 预先锁定的 tier profile;同机安装大小、质量、延迟、RSS 报告 | 候选包至少两个 prerelease | 每个 GA 杯型通过预注册阈值,且拥有不能被 small 同时满足的清晰受众 | tiny 没有显著降低总部署成本,或 medium 没有在目标 corpus 上产生实际质量收益 | | G3 文档入口验证 | S3 接受/缩减;至少 30 份、100 页、5 类文档的 corpus;公开反馈或集成记录 | preview 发布后 30–60 天 | 安全/资源 gates 全绿;接受分支有至少 3 名独立用户或 2 个外部集成使用 PDF,缩减分支达到同等数量的多页 page-image/Markdown 使用证据 | 接受分支的 renderer 成本不可接受,或任一分支的用户普遍更愿意自行完成文档编排 | | G4 结构化价值验证 | D109 预注册 Layout/reading-order profile;至少 100 页独立标注;preview 集成反馈 | preview 发布后至少 60 天 | 质量 gates 全绿;至少 2 个外部集成证明 Layout 改善 Markdown、chunking 或字段定位 | Layout 结果没有优于 OCR-order,或模型/标注/安装成本超过使用价值 | -| PG Provider Gate | 同机 CPU/EP 对照;simple、dense、tiled、medium 和 Layout 候选 workload;provider payload/driver inventory | 至少 3 次独立冷启动与 30 次 warm run;两个目标设备 | contract 100% 通过;在至少两个目标 workload 上 `CPU P50 latency / EP P50 latency ≥ 1.5`,或吞吐 ≥2× CPU;质量通过预注册容差;无隐式 session fallback;cold-start、安装大小和设备内存不超过预注册 ceiling | 只有 inference microbenchmark 加速、端到端无收益,或初始化/copy/package/质量成本抵消收益 | +| PG Provider Gate | 同机 CPU/EP 对照;simple、dense、tiled、medium 和 Layout 候选 workload;provider payload/driver inventory | 至少 3 次独立冷启动与 30 次 warm run;每个进入 allow-list 的设备族至少一台真实目标设备 | contract 100% 通过;在至少两个目标 workload 上 `CPU P50 latency / EP P50 latency ≥ 1.5`,或吞吐 ≥2× CPU;质量通过预注册容差;无隐式 session fallback;cold-start、安装大小和设备内存不超过预注册 ceiling | 只有 inference microbenchmark 加速、端到端无收益,或初始化/copy/package/质量成本抵消收益 | 阈值不得在看到最终候选结果后调整。模型质量、Layout 指标的具体数值由对应 decision 在运行正式评测前锁定;Roadmap 只规定证据类别和决策纪律。 @@ -554,7 +554,7 @@ interface ExecutionOptions { - provider/device/driver/runtime、线程、并发、batch、precision 和电源模式; - CER、Detection P/R/Hmean、空结果、重复行和 schema/坐标 contract。 -正式候选至少进行 3 次独立 cold start、30 次 warm run,并在两个目标设备上复核。设备内存无法可靠读取时必须标为 unavailable,不能用 host RSS 代替。性能改动不得放宽 bounded/tiled 资源上限或质量阈值。 +正式候选至少进行 3 次独立 cold start、30 次 warm run,并对每个进入 allow-list 的设备族使用至少一台真实目标设备复核;扩大到新的设备族时必须增加对应真机证据。设备内存无法可靠读取时必须标为 unavailable,不能用 host RSS 代替。性能改动不得放宽 bounded/tiled 资源上限或质量阈值。 ### 7.5 Perf-1B — CPU 基线与调优 diff --git a/tests/python/test_apple_qualification.py b/tests/python/test_apple_qualification.py index 094ec88..e07044b 100644 --- a/tests/python/test_apple_qualification.py +++ b/tests/python/test_apple_qualification.py @@ -9,6 +9,7 @@ from tools.apple import ( accept_qualification, collect_qualification, + fallback_gate, package_bundle, performance_gate, ) @@ -42,7 +43,7 @@ def apple_execution() -> dict[str, object]: }, } - def test_collects_two_distinct_devices_with_identical_models(self) -> None: + def test_collects_a_locally_qualified_device(self) -> None: with tempfile.TemporaryDirectory() as work: root = Path(work) acceptance = { @@ -51,6 +52,8 @@ def test_collects_two_distinct_devices_with_identical_models(self) -> None: "artifactId": "artifact", "detectionPackageSha256": "det", "recognitionPackageSha256": "rec", + "detectionCpuSha256": "cpu-det", + "recognitionCpuSha256": "cpu-rec", }, "routing": { "recognitionWidthMultiple": 32, @@ -65,14 +68,16 @@ def test_collects_two_distinct_devices_with_identical_models(self) -> None: "coldStartWorkloadId": "generated-hello-123", "maximumResidentGrowthAfter100PagesBytes": 64, }, - "compatibility": {"minimumQualifiedDevices": 2}, + "compatibility": { + "minimumQualifiedDevices": 1, + }, } acceptance_path = root / "acceptance.json" self.write_json(acceptance_path, acceptance) acceptance_hash = collect_qualification.hashlib.sha256( acceptance_path.read_bytes() ).hexdigest() - for identifier, family in (("m1", "Apple M1"), ("m2", "Apple M2")): + for identifier, family in (("m4", "Apple M4"),): directory = root / "reports" / identifier self.write_json(directory / "identity.json", { "expectedDeviceFamily": family, @@ -142,7 +147,7 @@ def test_collects_two_distinct_devices_with_identical_models(self) -> None: self.assertEqual(collect_qualification.main(), 0) candidate = json.loads(output.read_text("utf-8")) self.assertEqual(candidate["status"], "candidate") - self.assertEqual(candidate["qualifiedDeviceFamilies"], ["Apple M1", "Apple M2"]) + self.assertEqual(candidate["qualifiedDeviceFamilies"], ["Apple M4"]) self.assertEqual(candidate["modelPackageSha256"], { "detection": "det", "recognition": "rec" }) @@ -164,6 +169,28 @@ def test_rejects_execution_from_a_different_model(self) -> None: [{"execution": execution}], "apple-test", ("det", "rec"), "test" ) + def test_validates_the_locked_unqualified_device_fallback(self) -> None: + execution = {"requestedProvider": "apple"} + for stage, model_hash in (("detection", "cpu-det"), + ("recognition", "cpu-rec")): + execution[stage] = { + "requestedProvider": "apple", + "actualProviderChain": ["CPUExecutionProvider"], + "device": "cpu", + "precision": "fp32", + "modelSha256": model_hash, + "sessionFallback": True, + "fallbackReason": "apple_device_unqualified", + } + fallback_gate.validate_cpu_fallback( + {"execution": execution}, ("cpu-det", "cpu-rec") + ) + execution["recognition"]["sessionFallback"] = False + with self.assertRaisesRegex(RuntimeError, "locked CPU fallback path"): + fallback_gate.validate_cpu_fallback( + {"execution": execution}, ("cpu-det", "cpu-rec") + ) + def test_accepts_and_validates_a_reviewed_provider_baseline(self) -> None: acceptance = { "qualificationId": "apple-test", @@ -171,8 +198,12 @@ def test_accepts_and_validates_a_reviewed_provider_baseline(self) -> None: "artifactId": "artifact", "detectionPackageSha256": "d" * 64, "recognitionPackageSha256": "r" * 64, + "detectionCpuSha256": "c" * 64, + "recognitionCpuSha256": "e" * 64, + }, + "compatibility": { + "minimumQualifiedDevices": 1, }, - "compatibility": {"minimumQualifiedDevices": 2}, } acceptance_bytes = json.dumps(acceptance).encode("utf-8") acceptance_sha256 = collect_qualification.hashlib.sha256( @@ -189,10 +220,9 @@ def test_accepts_and_validates_a_reviewed_provider_baseline(self) -> None: "detection": "d" * 64, "recognition": "r" * 64, }, - "qualifiedDeviceFamilies": ["Apple M1", "Apple M2"], + "qualifiedDeviceFamilies": ["Apple M4"], "devices": [ - {"deviceFamily": "Apple M1"}, - {"deviceFamily": "Apple M2"}, + {"deviceFamily": "Apple M4"}, ], } candidate["reportSha256"] = collect_qualification.report_hash(candidate) @@ -214,7 +244,7 @@ def test_accepts_and_validates_a_reviewed_provider_baseline(self) -> None: package_bundle.accepted_device_families( accepted_path, acceptance, acceptance_sha256 ), - ["Apple M1", "Apple M2"], + ["Apple M4"], ) def test_rejects_tampered_accepted_provider_baseline(self) -> None: diff --git a/tests/python/test_npm_release.py b/tests/python/test_npm_release.py index 11bcfca..abda8d5 100644 --- a/tests/python/test_npm_release.py +++ b/tests/python/test_npm_release.py @@ -107,7 +107,7 @@ def test_stages_and_deterministically_packs_six_packages(self) -> None: "apple": { "schemaVersion": "1.0", "architecture": "arm64", - "qualifiedDeviceFamilies": ["Apple M1", "Apple M2"], + "qualifiedDeviceFamilies": ["Apple M4"], } }, }) + "\n", "utf-8" diff --git a/tools/apple/acceptance.json b/tools/apple/acceptance.json index c5483f7..1d3f7f6 100644 --- a/tools/apple/acceptance.json +++ b/tools/apple/acceptance.json @@ -1,10 +1,12 @@ { - "schemaVersion": "1.0", - "qualificationId": "apple-fp16-mixed-20260715.1", + "schemaVersion": "1.1", + "qualificationId": "apple-fp16-mixed-20260715.2", "models": { "artifactId": "apple-fp16-20260715.1", "detectionPackageSha256": "2097bd785947c6bc239bfcb27599362c48ec78bab72f439583e41a585b727f76", - "recognitionPackageSha256": "c54a0719cbde2d93e65eb40dd01fff5b78373b5aaaaa648fee614d3ef3615f4b" + "recognitionPackageSha256": "c54a0719cbde2d93e65eb40dd01fff5b78373b5aaaaa648fee614d3ef3615f4b", + "detectionCpuSha256": "d73e0058b7a8086bbd57f3d10b8bcd4ff95363f67e06e2762b5e814fe9c9410e", + "recognitionCpuSha256": "5435fd747c9e0efe15a96d0b378d5bd157e9492ed8fd80edf08f30d02fa24634" }, "routing": { "recognitionWidthMultiple": 32, @@ -49,6 +51,6 @@ "compatibility": { "minimumMacOS": "15.0", "architectures": ["arm64"], - "minimumQualifiedDevices": 2 + "minimumQualifiedDevices": 1 } } diff --git a/tools/apple/capture_identity.py b/tools/apple/capture_identity.py new file mode 100644 index 0000000..6cc3bde --- /dev/null +++ b/tools/apple/capture_identity.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Capture the real Apple Silicon identity used for local qualification.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import platform +import subprocess + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--expected-device-family", + required=True, + choices=("Apple M1", "Apple M2", "Apple M3", "Apple M4"), + ) + parser.add_argument("--runner-label", default="local-apple-silicon") + parser.add_argument("--report", type=Path, required=True) + arguments = parser.parse_args() + + brand = subprocess.check_output( + ["sysctl", "-n", "machdep.cpu.brand_string"], text=True + ).strip() + if not brand.startswith(arguments.expected_device_family): + parser.error( + f"device {brand!r} does not match {arguments.expected_device_family!r}" + ) + identity = { + "schemaVersion": "1.0", + "expectedDeviceFamily": arguments.expected_device_family, + "deviceBrand": brand, + "operatingSystem": platform.platform(), + "runnerLabel": arguments.runner_label, + } + arguments.report.parent.mkdir(parents=True, exist_ok=True) + arguments.report.write_text( + json.dumps(identity, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(json.dumps(identity, ensure_ascii=False, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/apple/fallback_gate.py b/tools/apple/fallback_gate.py new file mode 100644 index 0000000..14c41f4 --- /dev/null +++ b/tools/apple/fallback_gate.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Prove that an unqualified Apple family takes the stable CPU fallback path.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import subprocess + +try: + from .collect_qualification import read_json, report_hash +except ImportError: # Direct script execution. + from collect_qualification import read_json, report_hash + + +ROOT = Path(__file__).resolve().parents[2] +DEFAULT_ACCEPTANCE = ROOT / "tools" / "apple" / "acceptance.json" +HELLO_PIXELS = ROOT / "corpus" / "fixtures" / "generated-hello-123" / "pixels.bin" + + +def run_json(command: list[str]) -> dict[str, object]: + process = subprocess.run( + command, check=False, capture_output=True, text=True, encoding="utf-8" + ) + lines = [line for line in process.stdout.splitlines() if line.strip()] + if process.returncode != 0 or not lines: + detail = process.stderr.strip() or process.stdout.strip() + raise RuntimeError(f"fallback probe failed ({process.returncode}): {detail}") + value = json.loads(lines[-1]) + if not isinstance(value, dict) or value.get("ok") is not True: + raise RuntimeError("fallback probe did not return a successful JSON object") + return value + + +def validate_cpu_fallback( + benchmark: dict[str, object], expected_hashes: tuple[str, str] +) -> None: + execution = benchmark.get("execution", {}) + if execution.get("requestedProvider") != "apple": + raise RuntimeError("fallback probe did not request the Apple provider") + for stage, expected_hash in zip( + ("detection", "recognition"), expected_hashes, strict=True + ): + session = execution.get(stage, {}) + if ( + session.get("requestedProvider") != "apple" + or session.get("actualProviderChain") != ["CPUExecutionProvider"] + or session.get("device") != "cpu" + or session.get("precision") != "fp32" + or session.get("modelSha256") != expected_hash + or session.get("sessionFallback") is not True + or session.get("fallbackReason") != "apple_device_unqualified" + ): + raise RuntimeError(f"{stage} did not take the locked CPU fallback path") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--native-benchmark", type=Path, required=True) + parser.add_argument("--native-validate", type=Path, required=True) + parser.add_argument("--bundle", type=Path, required=True) + parser.add_argument("--expected-device-family", required=True) + parser.add_argument("--acceptance", type=Path, default=DEFAULT_ACCEPTANCE) + parser.add_argument("--report", type=Path, required=True) + arguments = parser.parse_args() + + acceptance = read_json(arguments.acceptance.resolve()) + models = acceptance["models"] + bundle = arguments.bundle.resolve() + manifest = read_json(bundle / "manifest.json") + provider = manifest.get("providers", {}).get("apple", {}) + accelerated_families = provider.get("qualifiedDeviceFamilies", []) + if ( + not isinstance(accelerated_families, list) + or not accelerated_families + or arguments.expected_device_family in accelerated_families + ): + parser.error("fallback bundle must exclude the expected device family") + if ( + provider.get("qualificationId") != acceptance["qualificationId"] + or provider.get("detection", {}).get("packageSha256") + != models["detectionPackageSha256"] + or provider.get("recognition", {}).get("packageSha256") + != models["recognitionPackageSha256"] + ): + parser.error("fallback bundle does not match the locked Apple models") + + common = [ + "--bundle", str(bundle), + "--pixels", str(HELLO_PIXELS), + "--width", "800", + "--height", "180", + "--stride", "2400", + "--format", "bgr8", + "--profile", "apple_cpu_fallback", + ] + benchmark = run_json([ + str(arguments.native_benchmark.resolve()), *common, + "--warmup", "0", "--iterations", "1", + ]) + expected_cpu_hashes = ( + str(models["detectionCpuSha256"]), + str(models["recognitionCpuSha256"]), + ) + validate_cpu_fallback(benchmark, expected_cpu_hashes) + validation = run_json([ + str(arguments.native_validate.resolve()), *common, "--diagnostics", + ]) + lines = validation.get("lines", []) + if len(lines) != 1 or lines[0].get("text") != "HELLO 123": + raise RuntimeError("CPU fallback changed the locked canary result") + + execution = benchmark["execution"] + report: dict[str, object] = { + "schemaVersion": "1.0", + "qualificationId": acceptance["qualificationId"], + "expectedDeviceFamily": arguments.expected_device_family, + "passed": True, + "bundleId": manifest["bundleId"], + "acceleratedDeviceFamilies": sorted(accelerated_families), + "models": { + "detectionPackageSha256": models["detectionPackageSha256"], + "recognitionPackageSha256": models["recognitionPackageSha256"], + "detectionCpuSha256": expected_cpu_hashes[0], + "recognitionCpuSha256": expected_cpu_hashes[1], + }, + "execution": execution, + "canary": { + "fixtureId": "generated-hello-123", + "text": "HELLO 123", + "acceptedLines": benchmark.get("result", {}).get("acceptedLines"), + }, + } + report["reportSha256"] = report_hash(report) + arguments.report.parent.mkdir(parents=True, exist_ok=True) + arguments.report.write_text( + json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(json.dumps({ + "passed": True, + "deviceFamily": arguments.expected_device_family, + "report": str(arguments.report), + "reportSha256": report["reportSha256"], + }, ensure_ascii=False, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/apple/qualify_models.py b/tools/apple/qualify_models.py index d894823..ed61c6c 100644 --- a/tools/apple/qualify_models.py +++ b/tools/apple/qualify_models.py @@ -314,7 +314,7 @@ def main() -> int: parser.add_argument("--ane-maximum-width", type=int, default=1600) parser.add_argument( "--qualification-id", - default="apple-fp16-mixed-20260715.1", + default="apple-fp16-mixed-20260715.2", ) parser.add_argument("--widths", help="Comma-separated development subset") parser.add_argument("--jobs", type=int, default=1, diff --git a/tools/npm_release.py b/tools/npm_release.py index 60cc366..96a9c90 100644 --- a/tools/npm_release.py +++ b/tools/npm_release.py @@ -265,7 +265,7 @@ def assemble(arguments: argparse.Namespace) -> None: apple_provider.get("schemaVersion") != "1.0" or apple_provider.get("architecture") != "arm64" or not isinstance(qualified_families, list) or - len(qualified_families) < 2 or + len(qualified_families) < 1 or len(qualified_families) != len(set(qualified_families)) or any(family not in {"Apple M1", "Apple M2", "Apple M3", "Apple M4"} for family in qualified_families)): From 14715f6bfa1dacefc0facd71f8dde9a7259743c3 Mon Sep 17 00:00:00 2001 From: eric8810 Date: Thu, 16 Jul 2026 00:07:36 +0800 Subject: [PATCH 6/9] =?UTF-8?q?chore(apple):=20=E8=8A=AF=E5=8D=B0=E6=97=A2?= =?UTF-8?q?=E5=AE=9A=EF=BC=8C=E7=8E=89=E5=86=8C=E7=8B=AC=E7=85=A7=E5=9B=9B?= =?UTF-8?q?=E4=BB=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接受由本机 M4 Max 真机报告生成的 Apple provider baseline,锁定模型、placement、质量、性能、缓存、生命周期与回退证据哈希。\n\n当前加速 allow-list 仅含 Apple M4;其他设备族保持可审计的 CPU fallback,新增设备族须重新完成本地真机资格流程。 --- contracts/apple-provider-baselines.json | 32 +++++++++++++++++++++++++ docs/apple-device-acceleration.md | 2 +- docs/implementation-status.md | 1 + 3 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 contracts/apple-provider-baselines.json diff --git a/contracts/apple-provider-baselines.json b/contracts/apple-provider-baselines.json new file mode 100644 index 0000000..00c5070 --- /dev/null +++ b/contracts/apple-provider-baselines.json @@ -0,0 +1,32 @@ +{ + "acceptanceSha256": "b3fe8423ad4fda604fcb3512e3bdcb9a6294338c8f17e0ca80f9291cddb65460", + "approvedByCommit": "ab94a8e5ba64f9b7cce2d01ebdd31b91ab48b50d", + "candidateReportSha256": "8a49eda1f0c4f57a3b02ee409764a7057d2eb577d22dd4819d7bb43e48b5e4bc", + "devices": [ + { + "cacheConcurrencyReportSha256": "8356c20cfebf5c10aef5eed401e72ddb8bac968da5abcea46eab2cb9c53f2f64", + "deviceBrand": "Apple M4 Max", + "deviceFamily": "Apple M4", + "lifecycleGrowthBytes": -28803072, + "lifecycleReportSha256": "f695157ab3ed27b1d20db8eb109a11306835f434e3740c058c7fe2a940f66195", + "modelQualificationReportSha256": "f9b4cfdb198ebd6f523c7a71365b6a206f68a022792e029a716bf290c084d983", + "operatingSystem": "macOS-26.5.1-arm64-arm-64bit", + "performanceReportSha256": "cecf760749ace0982905557af833522db0bbaa69e1c8ed6e0fd9e8e50d05cd8d", + "qualityReportSha256": "79d5b9f6db90142a57cdee8c20428280d642a9a203b5b190d6eaacc3fcff6ae1", + "runnerLabel": "local-m4-max" + } + ], + "generatedFromCommit": "ab94a8e5ba64f9b7cce2d01ebdd31b91ab48b50d", + "modelArtifactId": "apple-fp16-20260715.1", + "modelPackageSha256": { + "detection": "2097bd785947c6bc239bfcb27599362c48ec78bab72f439583e41a585b727f76", + "recognition": "c54a0719cbde2d93e65eb40dd01fff5b78373b5aaaaa648fee614d3ef3615f4b" + }, + "qualificationId": "apple-fp16-mixed-20260715.2", + "qualifiedDeviceFamilies": [ + "Apple M4" + ], + "reportSha256": "5ac8e11738babee028c4e63f470a290a8582222ed76acfab209e5a3f6ee62788", + "schema": "light-ocr-apple-provider-baselines/1.0", + "status": "accepted" +} diff --git a/docs/apple-device-acceleration.md b/docs/apple-device-acceleration.md index 9f3bd7e..95cdc7f 100644 --- a/docs/apple-device-acceleration.md +++ b/docs/apple-device-acceleration.md @@ -407,7 +407,7 @@ Apple provider 继承 Roadmap Provider Gate,并增加交互式 CPU 目标: 4. Recognition 使用 320–3200、步长 32 的 91-function MLProgram 做全量资格审查;运行时向上取整到锁定的 20 个加权 bucket,≤1600 走 ANE envelope,>1600 走 GPU,LRU≤20。 5. 随包携带源 `.mlpackage`,首次运行离线编译并以 package/OS/device identity 缓存;不分发跨 OS 的预编译 `.mlmodelc`。 6. 质量、两 workload speedup、CPU-time 降幅、canary 的 3 次 cold start、30 次 warm、RSS、同 engine 100 页生命周期和 32 MiB 包增量阈值由 `tools/apple/acceptance.json` 锁定。 -7. M4 的 placement、质量、两 workload 性能、CPU-time、cache、RSS、100 页生命周期和未资格设备 CPU fallback 报告已完成并由 accepted provider baseline 锁定。旧 SHA `d9be…12c4` PDF 仅是无法复跑的历史 scoreboard,不阻塞当前 M4 实现;新增设备族时必须重新执行本地资格流程并审阅新 baseline。 +7. M4 的 placement、质量、两 workload 性能、CPU-time、cache、RSS、100 页生命周期和未资格设备 CPU fallback 报告已完成,并由 accepted provider baseline `5ac8e117…2788` 锁定。旧 SHA `d9be…12c4` PDF 仅是无法复跑的历史 scoreboard,不阻塞当前 M4 实现;新增设备族时必须重新执行本地资格流程并审阅新 baseline。 ## 14. 关联工作 diff --git a/docs/implementation-status.md b/docs/implementation-status.md index a7de78f..3711f57 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -53,6 +53,7 @@ | Apple cache concurrency | 4 进程竞争通过;detector/recognizer 各恰好一个 miss、3 个 hit,结果哈希一致且无临时目录残留;`.2` 报告 `8356c20c…2f64` | | Apple 100-page lifecycle | 同一 interactive engine 预热 2 页后连续处理 100 个 xfund 密集页;RSS baseline/final/maximum 为 887.27/859.80/888.09 MiB,growth -27.47 MiB,通过 32 MiB 工具门槛和 64 MiB acceptance;`.2` 报告 `f695157a…6195` | | Apple unqualified fallback | 本机以不含 M4 的临时 allow-list 请求 `apple_cpu_fallback`,detector/recognizer 均稳定落到 ONNX Runtime CPU,原因 `apple_device_unqualified`,canary 保持 `HELLO 123`;报告 `2e72ab7e…d823` | +| Apple provider baseline | qualification `apple-fp16-mixed-20260715.2` 已接受;allow-list 仅 `Apple M4`,candidate/accepted 自哈希链完整,accepted 报告 `5ac8e117…2788` | | Tiled corpus | 八张 2048² locked fixtures 共 196 行:196 TP / 0 FP / 0 FN、CER 0、duplicate line 0;独立 oracle 与原生 pass tensor、candidate source、suppression、representative、crop、decode 和 final order 对齐;side override、tile ceiling、global candidate ceiling 均返回稳定错误 | | Tiled qualification | [run 29336329115](https://github.com/arcships/light-ocr/actions/runs/29336329115) 四个平台采样 jobs 成功;36 个 Core/Node 22/Node 24 entries 已受审。各平台最大 Core/Node 峰值:Linux x64 639.7/715.6 MiB、Windows x64 616.1/667.5 MiB、macOS arm64 667.4/733.6 MiB、macOS x64 623.1/672.8 MiB | From bdb77a7dd46bc285f86694f6bf4a3b86b35c323e Mon Sep 17 00:00:00 2001 From: eric8810 Date: Thu, 16 Jul 2026 00:10:43 +0800 Subject: [PATCH 7/9] =?UTF-8?q?ci(core):=20=E4=BA=91=E8=88=9F=E5=87=8F?= =?UTF-8?q?=E6=A3=B9=EF=BC=8C=E5=88=86=E6=94=AF=E4=B8=80=E6=A3=80=E4=B8=8D?= =?UTF-8?q?=E9=87=8D=E8=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 功能分支仅由 pull_request 触发,push 仅保留 main;同一 PR 的新提交会取消旧运行,避免重复消耗免费 runner。 --- .github/workflows/core.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml index 4f908d8..aa6a4e1 100644 --- a/.github/workflows/core.yml +++ b/.github/workflows/core.yml @@ -2,9 +2,15 @@ name: core on: push: + branches: + - main pull_request: workflow_dispatch: +concurrency: + group: core-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + permissions: contents: read From bb0f0a729677a36531a23f5d57048a420fa74363 Mon Sep 17 00:00:00 2001 From: eric8810 Date: Thu, 16 Jul 2026 10:47:35 +0800 Subject: [PATCH 8/9] =?UTF-8?q?feat(apple):=20=E8=8A=AF=E9=97=A8=E5=B0=BD?= =?UTF-8?q?=E5=90=AF=EF=BC=8C=E5=8F=8C=E6=9E=B6=E5=90=8C=E8=88=9F=E6=B8=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将生产 Core ML 策略改为 open-macos,允许 macOS 15+ 的 arm64 与 x86_64 设备显式尝试加速。 保留 M4 为已验证性能证据,通过 deviceValidated 区分其余 Mac 的实验兼容状态;Intel 使用 CPU+GPU 路由。 同步更新 bundle schema、C++/Node 诊断、资格工具、测试与文档。 Refs #4 --- bindings/node/README.md | 5 +- bindings/node/js/index.d.ts | 4 ++ bindings/node/src/addon.cpp | 4 ++ bindings/node/test/adapter.test.cjs | 12 +++- docs/apple-device-acceleration.md | 31 +++++----- docs/architecture.md | 2 +- docs/build-and-release.md | 4 +- docs/decisions.md | 6 +- docs/implementation-status.md | 10 ++-- docs/model-bundle.md | 24 +++++--- docs/napi-design.md | 6 +- docs/native-api.md | 8 ++- docs/npm-packaging.md | 4 +- docs/roadmap.md | 4 +- include/light_ocr/types.hpp | 2 + src/core/engine.cpp | 41 ++++++++----- src/inference/backend.hpp | 4 +- src/inference/coreml/backend.hpp | 8 ++- src/inference/coreml/backend.mm | 73 +++++++++++++++++++----- src/inference/onnxruntime/backend.cpp | 1 + src/model/bundle_data.hpp | 5 +- src/model/model_bundle.cpp | 30 ++++++---- tests/integration/apple.cpp | 67 ++++++++++++++++------ tests/integration/main.cpp | 3 + tests/python/test_apple_qualification.py | 10 ++++ tests/python/test_npm_release.py | 7 ++- tests/unit/test_model_bundle.cpp | 43 ++++++++++++-- tools/apple/collect_qualification.py | 3 +- tools/apple/fallback_gate.py | 17 +++--- tools/apple/package_bundle.py | 33 +++++++---- tools/benchmark/main.cpp | 1 + tools/leak_check/main.cpp | 1 + tools/npm_release.py | 15 ++--- 33 files changed, 345 insertions(+), 143 deletions(-) diff --git a/bindings/node/README.md b/bindings/node/README.md index ffef670..1f222c4 100644 --- a/bindings/node/README.md +++ b/bindings/node/README.md @@ -1,6 +1,6 @@ # light-ocr Node-API adapter -状态:`@arcships/light-ocr@0.2.0` 已发布;当前 0.2.1 源码候选加入受资格约束的 Apple/Core ML provider。tiled detection 和内存 JPEG/PNG 输入继续可用,默认推理仍为 CPU。 +状态:`@arcships/light-ocr@0.2.0` 已发布;当前 0.2.1 源码候选加入开放 macOS 兼容的 Apple/Core ML provider。tiled detection 和内存 JPEG/PNG 输入继续可用,默认推理仍为 CPU。 推荐直接安装公开 package: @@ -21,7 +21,7 @@ npm install @arcships/light-ocr - 支持 `AbortSignal` 协作式取消:queued 请求会从队列移除;running 请求立即拒绝 public Promise,但 Core 会安全运行到返回并丢弃结果。 - native addon 只接收现有绝对 bundle 目录。当前源码开发调用显式传 `bundlePath`;发布后的 facade 默认使用随 npm 安装的 model package 路径。 - 产品 engine 默认报告 `detectionStrategy: 'bounded'`、`detectionMaxSide: 960` 和 `defaultRecognitionBatchSize: 1`。0.2.0 可通过 `detection: {strategy: 'tiled'}` 显式选择 `tiled-v1`;`upstreamExact` 只用于上游对照,单次 `recognize({detectionMaxSide})` 只能继续降低 bounded engine 的 side。 -- `createEngine({execution})` 接受 `cpu` 或 `apple`。Apple interactive 使用 FP16 ANE + 宽文本 FP16 GPU 混合路由,strict 使用全 GPU,显式 CPU fallback 会报告稳定原因;`engine.info.execution.sessions` 和逐批 diagnostics 提供模型、设备、缓存、qualification ID、shape bucket 与实际 compute unit。 +- `createEngine({execution})` 接受 `cpu` 或 `apple`。macOS 15+ 默认开放:Apple Silicon interactive 使用 FP16 ANE + 宽文本 FP16 GPU,strict 使用全 GPU;Intel Mac 使用 Core ML CPU+GPU 且只接受 `cpuPartition: 'allow'`。显式 CPU fallback 会报告稳定原因;`deviceValidated` 区分已有 M4 证据与其他 Mac 的实验兼容,`engine.info.execution.sessions` 和逐批 diagnostics 还提供模型、设备、缓存、qualification ID、shape bucket 与实际 compute unit。 不支持 WebP、GIF、PDF、EXIF orientation 自动旋转、zero-copy/transfer、运行中 inference 硬中断、Electron 或 Bun。详细契约见 [Node-API 设计](../../docs/napi-design.md)。 @@ -77,6 +77,7 @@ const engine = await createEngine({ }); console.log(engine.info.execution.sessions.detection.actualProviderChain); +console.log(engine.info.execution.sessions.detection.deviceValidated); ``` 当前源码开发用法仍需显式 bundle: diff --git a/bindings/node/js/index.d.ts b/bindings/node/js/index.d.ts index f2027fb..cc2a415 100644 --- a/bindings/node/js/index.d.ts +++ b/bindings/node/js/index.d.ts @@ -144,6 +144,8 @@ export interface ProviderCapabilityInfo { readonly provider: string; readonly packageIncluded: boolean; readonly deviceAvailable: boolean; + /** True only when this exact hardware family has reviewed qualification evidence. */ + readonly deviceValidated: boolean; } export interface SessionExecutionInfo { readonly requestedProvider: string; @@ -160,6 +162,8 @@ export interface SessionExecutionInfo { readonly providerVersion: string; readonly modelCacheStatus: string; readonly qualificationId: string; + /** False means the open macOS compatibility path is experimental on this device. */ + readonly deviceValidated: boolean; readonly sessionFallback: boolean; readonly fallbackReason?: string; } diff --git a/bindings/node/src/addon.cpp b/bindings/node/src/addon.cpp index 6b3c2a0..4ea7768 100644 --- a/bindings/node/src/addon.cpp +++ b/bindings/node/src/addon.cpp @@ -1301,6 +1301,8 @@ napi_value create_session_execution_info(napi_env env, string_value(env, info.model_cache_status)); set_named(env, object, "qualificationId", string_value(env, info.qualification_id)); + set_named(env, object, "deviceValidated", + boolean_value(env, info.device_validated)); set_named(env, object, "sessionFallback", boolean_value(env, info.session_fallback)); if (info.fallback_reason) { @@ -1341,6 +1343,8 @@ napi_value create_execution_info(napi_env env, const ExecutionInfo& info) { boolean_value(env, capability.package_included)); set_named(env, entry, "deviceAvailable", boolean_value(env, capability.device_available)); + set_named(env, entry, "deviceValidated", + boolean_value(env, capability.device_validated)); check(env, napi_set_element(env, capabilities, static_cast(index), entry), diff --git a/bindings/node/test/adapter.test.cjs b/bindings/node/test/adapter.test.cjs index a08a171..58d586f 100644 --- a/bindings/node/test/adapter.test.cjs +++ b/bindings/node/test/adapter.test.cjs @@ -121,6 +121,7 @@ test('loads PP-OCRv6, snapshots pixels, maps results, and closes idempotently', provider: 'cpu', packageIncluded: true, deviceAvailable: true, + deviceValidated: true, }]); assert.deepEqual( engine.info.execution.sessions.detection.actualProviderChain, @@ -141,6 +142,7 @@ test('loads PP-OCRv6, snapshots pixels, maps results, and closes idempotently', assert.match(engine.info.execution.sessions.detection.modelSha256, /^[a-f0-9]{64}$/); assert.equal(engine.info.execution.sessions.detection.precision, 'fp32'); assert.equal(engine.info.execution.sessions.detection.shapePolicy, 'dynamic'); + assert.equal(engine.info.execution.sessions.detection.deviceValidated, true); assert.equal(engine.info.execution.sessions.detection.sessionFallback, false); assert.equal(engine.info.execution.sessions.detection.fallbackReason, undefined); assert.ok(Object.isFrozen(engine.info)); @@ -179,7 +181,7 @@ test('loads PP-OCRv6, snapshots pixels, maps results, and closes idempotently', ); }); -test('exposes qualified Apple interactive and strict routing', { +test('exposes open Apple routing and validation status', { skip: appleBundlePath === undefined, }, async () => { const image = loadFixture('generated-hello-123'); @@ -192,11 +194,11 @@ test('exposes qualified Apple interactive and strict routing', { assert.equal(interactive.info.execution.requestedProvider, 'apple'); assert.deepEqual( interactive.info.execution.sessions.detection.actualProviderChain, - ['CoreML(MLNeuralEngine,qualified-MLCPU)'], + ['CoreML(MLNeuralEngine,MLCPU)'], ); assert.deepEqual( interactive.info.execution.sessions.recognition.actualProviderChain, - ['CoreML(MLNeuralEngine,qualified-MLCPU)', 'CoreML(MLGPU)'], + ['CoreML(MLNeuralEngine,MLCPU)', 'CoreML(MLGPU)'], ); assert.match( interactive.info.execution.sessions.detection.qualificationId, @@ -206,6 +208,10 @@ test('exposes qualified Apple interactive and strict routing', { interactive.info.execution.sessions.detection.deviceFamily, /^Apple M/, ); + assert.equal( + interactive.info.execution.sessions.detection.deviceValidated, + interactive.info.execution.sessions.detection.deviceFamily.startsWith('Apple M4'), + ); assert.ok( interactive.info.execution.sessions.detection.operatingSystem.length > 0, ); diff --git a/docs/apple-device-acceleration.md b/docs/apple-device-acceleration.md index 95cdc7f..07fa521 100644 --- a/docs/apple-device-acceleration.md +++ b/docs/apple-device-acceleration.md @@ -1,12 +1,12 @@ # Apple Device 加速技术方案 -状态:Implemented and locally qualified;M4 Max 的实现、放置、质量、性能、缓存、100 页生命周期和未资格设备 CPU fallback Gate 已通过;不代表已经发布 +状态:Implemented with open macOS compatibility;M4 Max 的实现、放置、质量、性能、缓存和 100 页生命周期 Gate 已通过;其他 Mac 以实验兼容模式开放;不代表已经发布 -更新时间:2026-07-15 +更新时间:2026-07-16 -范围:以 macOS Apple Silicon 为当前交付目标;iPhone/iPad 只保留架构兼容性,不在当前 Tier 1 平台承诺内 +范围:以 macOS 15+ 为当前交付目标;Apple Silicon 使用 ANE/GPU 混合路由,Intel Mac 使用 CPU+GPU 路由;iPhone/iPad 不在当前 Tier 1 平台承诺内 -实施状态:Direct Objective-C++ Core ML bridge、schema 1.1 capability manifest、哈希锁 FP16 模型派生、自包含 npm 模型包、ANE/GPU 混合路由、严格 GPU 模式、离线编译缓存、跨进程锁、20 个加权宽度桶的有界函数缓存、设备资格与显式 CPU fallback、C++/Node API 和资格工具均已实现。默认仍为 ONNX Runtime CPU;Apple 必须显式请求并由 bundle 中的设备族 allow-list 放行。当前 accepted allow-list 只包含真实 M4 Max 上通过完整 Gate 的 `Apple M4`;M1–M3 不宣称加速并稳定回退 CPU。 +实施状态:Direct Objective-C++ Core ML bridge、schema 1.1 capability manifest、哈希锁 FP16 模型派生、自包含 npm 模型包、ANE/GPU 与 Intel CPU+GPU 路由、严格 GPU 模式、离线编译缓存、跨进程锁、20 个加权宽度桶的有界函数缓存、显式 CPU fallback、C++/Node API 和资格工具均已实现。默认仍为 ONNX Runtime CPU;Apple 必须显式请求。生产 bundle 使用 `devicePolicy: open-macos`,macOS 15+ 的 arm64/x86_64 Mac 均可尝试 Core ML;`validatedDeviceFamilies: ["Apple M4"]` 只标记已有性能证据,不再充当运行白名单。`deviceValidated` 让调用方区分 M4 实证与其他设备的实验兼容。 关联 Roadmap:[Perf-0–Perf-4](roadmap.md#7-perf-0perf-4--性能与宿主加速线) @@ -111,7 +111,7 @@ flowchart TD | 四进程缓存竞争 | 通过,无残留临时目录 | 只允许每阶段一个 miss | | 同 engine 100 页 RSS 增长 | -27.47 MiB,测量最大 888.09 MiB | ≤64 MiB(工具实际执行 ≤32 MiB) | -密集表单的首次整页耗时另行保留:cache miss 53.846 s,hit 12.677/12.677 s;其中包含 113 行 OCR 和 14 个 Core ML 函数的按需装载,不纳入固定 canary 的 provider cold-start ceiling。确定性派生的 detector/recognizer 包哈希分别为 `2097bd78…7f76` 与 `c54a0719…5f4b`;`.2` acceptance 下的模型放置、质量、性能、缓存、生命周期和未资格设备回退报告哈希分别为 `f9b4cfdb…d983`、`79d5b9f6…6ae1`、`cecf7607…cd8d`、`8356c20c…2f64`、`f695157a…6195` 和 `2e72ab7e…d823`。 +密集表单的首次整页耗时另行保留:cache miss 53.846 s,hit 12.677/12.677 s;其中包含 113 行 OCR 和 14 个 Core ML 函数的按需装载,不纳入固定 canary 的 provider cold-start ceiling。确定性派生的 detector/recognizer 包哈希分别为 `2097bd78…7f76` 与 `c54a0719…5f4b`;`.2` acceptance 下的模型放置、质量、性能、缓存、生命周期和历史策略回退报告哈希分别为 `f9b4cfdb…d983`、`79d5b9f6…6ae1`、`cecf7607…cd8d`、`8356c20c…2f64`、`f695157a…6195` 和 `2e72ab7e…d823`。 #### CPU 与 FP16 GPU @@ -157,13 +157,13 @@ W8A8 的已有 quality smoke 只使用 3 页 detector 校准图和 10 个 recogn | 设备类别 | ANE | 首选模式 | W8A8 策略 | 兼容/回退 | | --- | --- | --- | --- | --- | -| M4 系列及项目独立验证过的后续 Mac | 有;M4 具备 Apple 明确说明的 INT8×INT8 加速 | FP16 ANE + FP16 GPU | 质量与收益通过后,可对 ANE 子模型启用 | FP16 GPU;显式 CPU session fallback | -| M1–M3 Mac | 有 | 先资格审查 FP16 ANE/GPU | 不承诺 W8A8 加速;必须逐代实测 | FP16 GPU 或 CPU | -| Intel Mac | 无 | CPU baseline;CoreML FP16 GPU 仅在独立 Gate 后可选 | 不适用 ANE W8A8 | CPU | +| M4 系列 | 有;M4 具备 Apple 明确说明的 INT8×INT8 加速 | 已验证的 FP16 ANE + FP16 GPU | 质量与收益通过后,可对 ANE 子模型启用 | `deviceValidated=true`;显式 CPU session fallback | +| M1–M3 与后续 Apple Silicon Mac | 有 | 开放 FP16 ANE/GPU 实验兼容 | 不承诺 W8A8 加速;社区设备反馈后逐步补证据 | `deviceValidated=false`;初始化失败可显式回退 CPU | +| Intel Mac | 无 | 开放 Core ML FP16 CPU+GPU 实验兼容;仅 `cpuPartition=allow` | 不适用 ANE W8A8 | `deviceValidated=false`;初始化失败可显式回退 CPU | | A17 Pro/M4 系列及项目独立验证过的后续 iPhone/iPad | 有;A17 Pro/M4 具备 Apple 明确说明的 INT8×INT8 加速 | 架构上与 Mac 相同 | 未来平台工作,当前不发布 | 平台自有 CPU/GPU 策略 | | 更老 iPhone/iPad | 有或无,能力不同 | 当前不在项目支持矩阵 | 不承诺 | 当前不发布 | -“硬件存在”与“模型完整运行在该硬件”是两件事。每个发布设备族仍需要 model × shape × precision 的 compute-plan 和端到端证据。 +“可以运行”与“已有项目性能承诺”是两件事。开放模式让真实用户设备尽早暴露兼容问题;只有 `validatedDeviceFamilies` 中的设备族拥有项目审阅过的 model × shape × precision、质量和端到端性能证据。 ## 5. 推荐运行时架构 @@ -323,6 +323,7 @@ Apple provider release set | shape policy | exact、enumerated、bucket 版本 | | session fallback | 是否发生以及稳定原因码 | | qualification ID | 对应的设备/模型/benchmark 报告版本 | +| device validated | 当前硬件族是否包含在已审阅证据中;`false` 表示开放实验兼容 | 普通运行时不必为每次调用生成昂贵 compute plan,但发布资格审查工具必须能证明每个预注册 model × shape × precision 的实际 placement。产品文案只能描述已经通过资格审查的执行路径。 @@ -352,7 +353,7 @@ Apple provider 继承 Roadmap Provider Gate,并增加交互式 CPU 目标: - 公共 contract 100% 通过;FP16/W8A8 的质量容差在查看最终 benchmark 前锁定; - reference PDF 不允许出现预注册的关键文本漏检; - 固定启动 canary 的 cold start、warm RSS、包增量和缓存行为通过预注册 ceiling;其他 workload 仍报告首次整页耗时; -- 每个进入 allow-list 的设备族必须至少在一台真实目标设备上完成全套复核;当前只发布资格内的 `Apple M4`。W8A8 若未来启用,必须覆盖计划宣称支持的每个硬件代际。 +- `open-macos` 兼容设备无需先进入白名单即可运行,但公开性能数字只能来自 `validatedDeviceFamilies`;当前只有 `Apple M4`。W8A8 若未来启用,仍必须覆盖计划宣称支持的每个硬件代际。 ## 12. 分阶段落地 @@ -369,7 +370,7 @@ Apple provider 继承 Roadmap Provider Gate,并增加交互式 CPU 目标: ### Phase B — FP16 Apple interactive preview -状态:实现完成,M4 Max 本机全套 Gate 已通过;M1–M3 未进入 allow-list,按合同稳定回退 CPU。 +状态:实现完成,M4 Max 本机全套 Gate 已通过;macOS 15+ 的其他 Apple Silicon 以同一 ANE/GPU 路径开放,Intel Mac 以 CPU+GPU 路径开放,二者均明确报告 `deviceValidated=false`。 - Detector、常规 recognition 优先 FP16 ANE。 - ANE-unqualified recognition shape 使用 FP16 GPU。 @@ -395,19 +396,19 @@ Apple provider 继承 Roadmap Provider Gate,并增加交互式 CPU 目标: - 将通过 Gate 的 Apple runtime/provider、模型派生物、SBOM、licenses、provenance 和签名纳入由主 facade 自动取得的 Darwin native release set,不新增用户安装入口。 - 固化 device/OS/model compatibility manifest 和故障语义。 -- 在未安装任何额外 provider/runtime 的干净目标机上,对每个拟加入 allow-list 的设备族运行正式 corpus、禁网安装和 release qualification。 +- 在社区或维护者能够取得的新设备上复跑正式 corpus;通过后只提升 `deviceValidated` 证据状态,不阻塞此前的实验兼容运行。 退出条件:用户仅安装 `@arcships/light-ocr` 即可运行;从 `engine.info()` 和 qualification report 可以证明实际执行路径,且稳定 CPU fallback 保持可用。 ## 13. 已落地决策与剩余外部证据 1. 正式 backend 候选为 Direct Core ML;ORT 1.22 CoreML EP 在禁止 CPU fallback 时不能完整放置当前 graph,保留为未来对照而非产品路径。 -2. FP16 只对 manifest 明列且在真实本机独立通过资格的 Apple Silicon family 启用;当前包只列 M4。M1–M3 必须取得对应真实设备并完成同一套本地 Gate 后才能加入 allow-list;CI 虚拟 M1 不暴露 GPU/Neural Engine,不能作为加速证据。W8A8 仍不发布。 -3. Detector 使用 32–960 的受限 range MLProgram;interactive 使用资格内 ANE/MLCPU envelope,strict 使用全 GPU。 +2. FP16 生产 manifest 使用 `devicePolicy: open-macos` 和 `architectures: [arm64, x86_64]`。`validatedDeviceFamilies` 当前只列 M4,但仅表示性能证据;M1–M3、后续 Apple Silicon 和 Intel Mac 无需白名单即可尝试。CI 虚拟 M1 不暴露 GPU/Neural Engine,仍不能作为性能证据。W8A8 仍不发布。 +3. Detector 使用 32–960 的受限 range MLProgram;Apple Silicon interactive 使用 ANE/MLCPU envelope,strict 使用全 GPU;Intel 因无 ANE 使用 Core ML CPU+GPU,且不接受 strict `cpuPartition=forbid`。 4. Recognition 使用 320–3200、步长 32 的 91-function MLProgram 做全量资格审查;运行时向上取整到锁定的 20 个加权 bucket,≤1600 走 ANE envelope,>1600 走 GPU,LRU≤20。 5. 随包携带源 `.mlpackage`,首次运行离线编译并以 package/OS/device identity 缓存;不分发跨 OS 的预编译 `.mlmodelc`。 6. 质量、两 workload speedup、CPU-time 降幅、canary 的 3 次 cold start、30 次 warm、RSS、同 engine 100 页生命周期和 32 MiB 包增量阈值由 `tools/apple/acceptance.json` 锁定。 -7. M4 的 placement、质量、两 workload 性能、CPU-time、cache、RSS、100 页生命周期和未资格设备 CPU fallback 报告已完成,并由 accepted provider baseline `5ac8e117…2788` 锁定。旧 SHA `d9be…12c4` PDF 仅是无法复跑的历史 scoreboard,不阻塞当前 M4 实现;新增设备族时必须重新执行本地资格流程并审阅新 baseline。 +7. M4 的 placement、质量、两 workload 性能、CPU-time、cache、RSS 和 100 页生命周期报告已完成,并由 accepted provider baseline `5ac8e117…2788` 锁定;原未资格设备 fallback 报告现在只证明可选 `validated-only` 策略,不代表生产 `open-macos` 会拒绝其他 Mac。旧 SHA `d9be…12c4` PDF 仅是无法复跑的历史 scoreboard。 ## 14. 关联工作 diff --git a/docs/architecture.md b/docs/architecture.md index 8411fe0..7b1db3f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -111,7 +111,7 @@ Location: `src/inference/` The internal `InferenceSession` boundary accepts a float vector and shape, validates storage size with checked arithmetic, and returns a lifetime-owning validated float tensor view. `OnnxSession` implements that boundary with the bundled ONNX Runtime CPU Execution Provider. On Apple builds, `CoreMlSession` implements the same boundary with system Core ML, zero-copy Float32 inputs, checked Float16/strided output conversion, lazy multifunction loading and a bounded compiled-model cache. No backend type appears in a public header. -The interface owns no OCR algorithm. Each session exposes immutable execution metadata separately for detector and recognizer, including requested/actual provider chain, device family/OS, model hash, precision, shape policy, runtime/cache, qualification ID, and fallback status. Recognition diagnostics add the per-call model function and compute unit. Provider-chain configuration is not treated as proof of per-node accelerator placement; the release tool independently checks every Core ML function's Compute Plan. +The interface owns no OCR algorithm. Each session exposes immutable execution metadata separately for detector and recognizer, including requested/actual provider chain, device family/OS, device validation status, model hash, precision, shape policy, runtime/cache, qualification ID, and fallback status. Recognition diagnostics add the per-call model function and compute unit. An open-compatibility provider chain is not treated as proof of per-node accelerator placement; the release tool independently checks every Core ML function's Compute Plan on evidence devices. ### 3.6 Detection postprocessing diff --git a/docs/build-and-release.md b/docs/build-and-release.md index 0fce3b8..3d4e8e1 100644 --- a/docs/build-and-release.md +++ b/docs/build-and-release.md @@ -221,9 +221,9 @@ benchmark 结果是独立资格审查证据,不是每次发布的重复步骤 Apple provider 的模型派生、91-function placement、tensor parity、14-fixture 质量、两 workload 性能/CPU-time、并发空缓存、cold start/RSS 和 100 次生命周期 Gate 只在真实 Apple Silicon 本机执行,不进入 GitHub Actions。标准 hosted macOS runner 是虚拟 M1,不暴露可用于资格审查的 GPU/Neural Engine;普通 CI 只保留跨平台编译、契约和轻量单测。 -本地资格目录必须先用 `tools/apple/capture_identity.py` 记录真实设备身份,再依次运行 `qualify_models.py`、`quality_gate.py`、`cache_concurrency_gate.py`、`performance_gate.py`、`light_ocr_leak_check` 和 `fallback_gate.py`。完整命令及阈值以 [Apple Device 加速技术方案](apple-device-acceleration.md) 和 `tools/apple/acceptance.json` 为准。每个拟加入 allow-list 的设备族都必须单独保留五份正向报告;`fallback_gate.py` 通过临时排除当前设备族,证明 `apple_device_unqualified` 会稳定切到 ONNX Runtime CPU。 +本地资格目录必须先用 `tools/apple/capture_identity.py` 记录真实设备身份,再依次运行 `qualify_models.py`、`quality_gate.py`、`cache_concurrency_gate.py`、`performance_gate.py` 和 `light_ocr_leak_check`。完整命令及阈值以 [Apple Device 加速技术方案](apple-device-acceleration.md) 和 `tools/apple/acceptance.json` 为准。新增真实设备报告用于把该家族加入 `validatedDeviceFamilies`,但生产 `open-macos` 不以此作为运行前置条件。`fallback_gate.py` 仅配合测试专用 `--device-policy validated-only`,证明受控部署拒绝未验证设备时会稳定切到 ONNX Runtime CPU。 -`tools/apple/collect_qualification.py` 只从本地真机报告输出 candidate;它会验证设备身份、模型、质量、性能、缓存和生命周期报告自哈希。审阅后用 `tools/apple/accept_qualification.py` 生成并提交 `contracts/apple-provider-baselines.json`。npm release 会校验该文件的自身哈希、acceptance、模型身份与至少一个真实资格设备族,并只从 accepted contract 生成发布 bundle allow-list;当前只包含 `Apple M4`。 +`tools/apple/collect_qualification.py` 只从本地真机报告输出 candidate;它会验证设备身份、模型、质量、性能、缓存和生命周期报告自哈希。审阅后用 `tools/apple/accept_qualification.py` 生成并提交 `contracts/apple-provider-baselines.json`。npm release 会校验该文件的自身哈希、acceptance、模型身份与至少一个真实验证设备族,并把这些证据映射为 manifest 的 `validatedDeviceFamilies`;运行策略固定为 `devicePolicy: open-macos`,当前证据只包含 `Apple M4`。 `.github/workflows/npm-promote.yml` 只负责给已经发布且完整性已验证的 release set 更新 dist-tag。它必须引用原 `npm release` run 保存的 `light-ocr-npm-` artifact,逐包复核 registry integrity,并按 model/native 依赖优先、facade 最后的顺序更新;不会重新构建、测试或发布 tarball。该 workflow 用于人工分阶段 promotion,以及 npm metadata 最终一致性导致主发布 job 在 tag 校验阶段中断后的安全恢复。 diff --git a/docs/decisions.md b/docs/decisions.md index 7a62faa..100994a 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -128,10 +128,10 @@ Consequence: Six packages release in lockstep. The facade is published last, aft ### D111 — Freeze a provider-neutral execution contract before enabling accelerators -Status: Accepted for Perf-1A;Apple provider implementation complete locally, release qualification pending two-device CI
-Decision: `EngineOptions.execution` owns the stable provider policy. The default remains `cpu` with `sessionFallback=error`, `cpuPartition=allow`, `performanceHint=latency`, and `precision=auto`. The 0.2.1 source union adds `apple`: Direct Core ML FP16 routes detector and recognition widths through the qualified ANE/MLCPU envelope, sends recognition widths above 1600 to FP16 GPU, and uses all-GPU execution when CPU partitions are forbidden. Apple requires macOS 15, arm64, batch 1, bounded/960 detection, schema 1.1 provider payloads and an explicitly qualified device-family prefix. `sessionFallback=cpu` is a whole-session creation fallback with a stable reason; runtime inference never retries. Unsupported provider, device, 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, 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.
+Status: Accepted for Perf-1A;Apple provider implementation complete locally with open macOS compatibility
+Decision: `EngineOptions.execution` owns the stable provider policy. The default remains `cpu` with `sessionFallback=error`, `cpuPartition=allow`, `performanceHint=latency`, and `precision=auto`. The 0.2.1 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 are placement-qualified, while runtime inputs round up to 20 locked weighted width buckets under an LRU ceiling of 20. DirectML, OpenVINO, CUDA, QNN, provider `auto`, and throughput profiles remain unavailable. Apple release remains blocked until the locked quality/performance/RSS/cache gates pass on at least two target device families and their report hashes are accepted. +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. DirectML, OpenVINO, CUDA, QNN, provider `auto`, and throughput profiles remain unavailable. ## 3. Deferred decisions diff --git a/docs/implementation-status.md b/docs/implementation-status.md index 3711f57..b08a795 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -1,7 +1,7 @@ # C++ Core 与 Node-API 实施状态 -更新时间:2026-07-15
-结论:`@arcships/light-ocr@0.2.0` 已发布并提升为 npm `latest`。当前 0.2.1 源码候选已实现 Direct Core ML FP16 Apple provider、自包含模型派生物、ANE/GPU 混合路由、缓存/回退和 C++/Node 可观测性;CPU 与 bounded/960 仍是默认。M4 Max 本机全部锁定 Gate 与未资格设备 CPU fallback Gate 已通过,accepted allow-list 只包含 `Apple M4`;M1–M3 不宣称加速。Apple provider 尚未发布到 npm。 +更新时间:2026-07-16
+结论:`@arcships/light-ocr@0.2.0` 已发布并提升为 npm `latest`。当前 0.2.1 源码候选已实现 Direct Core ML FP16 Apple provider、自包含模型派生物、Apple Silicon ANE/GPU 路由、Intel CPU+GPU 路由、缓存/回退和 C++/Node 可观测性;CPU 与 bounded/960 仍是默认。生产策略为 macOS 15+ `open-macos`,不再以设备证据作运行白名单;M4 Max 已通过全部锁定 Gate,其他 Mac 明确报告 `deviceValidated=false`。Apple provider 尚未发布到 npm。 状态含义: @@ -25,7 +25,7 @@ | 无 network/shell/cwd/locale 运行依赖 | Done | sterile cwd/minimal env 与 Linux network namespace disabled 测试通过;npm release 另完成已安装 package 的禁网运行。 | | manifest、hash、licenses、SBOM、parity、benchmark | Done | Release commit 已重新生成并保存四平台 metadata、六个 npm tarballs 的 hashes/integrity、parity、quality 与 benchmark 证据。 | | N-API/npm 非本 Core milestone | Done / `0.2.0` published | raw Node-API v8、CJS/ESM、`.d.ts`、内置模型解析、四平台 prebuild、双重背压、AbortSignal 与生命周期均已完成;[npm release run 29340467784](https://github.com/arcships/light-ocr/actions/runs/29340467784) 与 [promotion run 29342178842](https://github.com/arcships/light-ocr/actions/runs/29342178842) 保存六包发布、registry 和禁网证据。 | -| Perf-1A / Apple execution | Done locally / M4 qualified | provider-neutral `InferenceSession` 已加入 Objective-C++ Direct Core ML;公开 union 为 `cpu | apple`。detector 使用 FP16 range model,recognizer 使用 91-function FP16 MLProgram 完成全宽度放置审查,运行时使用锁定的 20 个加权宽度桶;interactive 为 ANE + 宽文本 GPU,strict 为 GPU,整 session CPU fallback 有稳定原因。schema 1.1 bundle、哈希锁模型、离线编译缓存、跨进程锁、LRU≤20、device/OS/qualification/逐批 route 诊断和 Node 映射均已完成。重型资格只在真实本机运行;当前 M4 已通过,其他家族保持 CPU fallback。 | +| Perf-1A / Apple execution | Done locally / open macOS | provider-neutral `InferenceSession` 已加入 Objective-C++ Direct Core ML;公开 union 为 `cpu | apple`。detector 使用 FP16 range model,recognizer 使用 91-function FP16 MLProgram 和 20 个加权宽度桶;Apple Silicon interactive 为 ANE + 宽文本 GPU,strict 为 GPU,Intel 为 CPU+GPU。schema 1.1 provider contract 使用 `open-macos`、arm64/x86_64、`validatedDeviceFamilies` 和 `deviceValidated`;整 session CPU fallback、哈希锁模型、离线编译缓存、跨进程锁、LRU≤20 与 Node 映射均已完成。M4 有正式证据,其他 Mac 直接开放实验兼容。 | | Node.js JPEG/PNG 内存输入 | Done / `0.2.0` published | `recognizeEncoded(Uint8Array)` 在 engine worker 上使用固定 stb revision 解码,保持 Core raw-pixel 边界;格式、尺寸、pixels、临时内存、queue/snapshot budget、AbortSignal 与 `timingUs.decode` 均有四平台 Node 22/24 package 测试。 | | 高分辨率峰值内存 | Done | Release 原生独立进程本机参考:2048² 空白 `318.8 MiB ≤ 384 MiB`;xfund 密集表单 116 框 `400.5 MiB ≤ 640 MiB`。四平台 release jobs 的真实模型与 RSS gates 均通过。 | | Tiled 高分辨率准确模式 | Done / `0.2.0` published | 1280 tile、2048→4-pass row-major、全局 candidate ceiling、IoU/IOS greedy merge、原图 recognition、C++/Node contract、8-fixture/196-line corpus、独立 oracle、四平台 36-entry accepted baseline 与 package smoke 均已完成。 | @@ -52,8 +52,8 @@ | Apple performance | hello / xfund warm P50 为 8.599 / 331.011 ms,相对 CPU-fast 加速 2.300× / 2.851×,CPU time 降低 95.91% / 97.67%;canary cold cache miss 7.219 s、hit 1.275/1.278 s;warm peak RSS 最大 692.14 MiB,bundle 增量 25.42 MiB;`.2` 报告 `cecf7607…cd8d` | | Apple cache concurrency | 4 进程竞争通过;detector/recognizer 各恰好一个 miss、3 个 hit,结果哈希一致且无临时目录残留;`.2` 报告 `8356c20c…2f64` | | Apple 100-page lifecycle | 同一 interactive engine 预热 2 页后连续处理 100 个 xfund 密集页;RSS baseline/final/maximum 为 887.27/859.80/888.09 MiB,growth -27.47 MiB,通过 32 MiB 工具门槛和 64 MiB acceptance;`.2` 报告 `f695157a…6195` | -| Apple unqualified fallback | 本机以不含 M4 的临时 allow-list 请求 `apple_cpu_fallback`,detector/recognizer 均稳定落到 ONNX Runtime CPU,原因 `apple_device_unqualified`,canary 保持 `HELLO 123`;报告 `2e72ab7e…d823` | -| Apple provider baseline | qualification `apple-fp16-mixed-20260715.2` 已接受;allow-list 仅 `Apple M4`,candidate/accepted 自哈希链完整,accepted 报告 `5ac8e117…2788` | +| Apple policy fallback | 本机以测试专用 `validated-only` 策略排除 M4 时,detector/recognizer 均稳定落到 ONNX Runtime CPU,原因 `apple_device_unqualified`,canary 保持 `HELLO 123`;生产 `open-macos` 不执行该拦截;历史报告 `2e72ab7e…d823` | +| Apple provider baseline | qualification `apple-fp16-mixed-20260715.2` 已接受;`Apple M4` 是当前唯一 validated evidence,而非运行 allow-list;candidate/accepted 自哈希链完整,accepted 报告 `5ac8e117…2788` | | Tiled corpus | 八张 2048² locked fixtures 共 196 行:196 TP / 0 FP / 0 FN、CER 0、duplicate line 0;独立 oracle 与原生 pass tensor、candidate source、suppression、representative、crop、decode 和 final order 对齐;side override、tile ceiling、global candidate ceiling 均返回稳定错误 | | Tiled qualification | [run 29336329115](https://github.com/arcships/light-ocr/actions/runs/29336329115) 四个平台采样 jobs 成功;36 个 Core/Node 22/Node 24 entries 已受审。各平台最大 Core/Node 峰值:Linux x64 639.7/715.6 MiB、Windows x64 616.1/667.5 MiB、macOS arm64 667.4/733.6 MiB、macOS x64 623.1/672.8 MiB | diff --git a/docs/model-bundle.md b/docs/model-bundle.md index 6cff2dd..9cbced9 100644 --- a/docs/model-bundle.md +++ b/docs/model-bundle.md @@ -19,7 +19,8 @@ text-line orientation: unavailable The 0.2.1 Apple candidate is a self-contained superset named `ppocrv6-small-apple-20260715.1`. It preserves the same ONNX CPU payload and normalized configuration while adding hash-locked FP16 Core ML packages and a -qualified-device allow-list. +macOS-wide open compatibility policy. The reviewed M4 device list is evidence +metadata, not a runtime allow-list. PP-OCRv6 tiny is a future independent bundle. PP-OCRv6 medium is architecture-compatible but is not a release target until an official ONNX artifact is pinned and validated. @@ -180,12 +181,15 @@ The real manifest lists every payload file. Core `0.1.x` and `0.2.0` accept mani ### 6.1 Apple provider extension -Schema 1.1 adds a top-level `providers.apple` object. Its release contract fixes: +Schema 1.1 adds a top-level `providers.apple` object. The provider sub-contract is version 1.1 and fixes: -- `minimumMacOS: "15.0"`, `architecture: "arm64"`, a non-empty qualified - Apple Silicon family list, and `qualificationId: "apple-fp16-mixed-20260715.2"`; +- `minimumMacOS: "15.0"`, `devicePolicy: "open-macos"`, + `architectures: ["arm64", "x86_64"]`, a non-empty + `validatedDeviceFamilies` evidence list, and + `qualificationId: "apple-fp16-mixed-20260715.2"`; - detector package/model/hash/tensor/shape identities, interactive ANE and - strict GPU policies, plus the maximum qualified MLCPU operation envelope; + strict GPU policies, Intel CPU+GPU policy, plus the maximum reviewed MLCPU + operation envelope; - recognizer package identity, 32-pixel width multiple, ANE maximum width 1600, `w%04u` function mapping, all 91 qualified widths, the locked 20 runtime width buckets, and an LRU ceiling of 20 functions; @@ -195,12 +199,16 @@ Schema 1.1 adds a top-level `providers.apple` object. Its release contract fixes serializes every model protobuf, and replaces package entry UUIDs with stable UUIDv5 identifiers before checking the locked package hashes. -`qualifiedMLCPUOperations` is a maximum reviewed envelope, not a requirement +`qualifiedMLCPUOperations` is a maximum reviewed envelope on the M4 evidence +device, not a requirement that every shape use every listed CPU operation. Qualification rejects unknown or excess MLCPU operations, missing ANE placement below the boundary, any CPU operation on the strict GPU route, incomplete width coverage, or argmax parity -changes. A device family not present in `qualifiedDeviceFamilies` cannot start -Core ML; only an explicit session-level CPU fallback may continue. +changes. `validatedDeviceFamilies` does not gate production execution: +`open-macos` permits any listed architecture on macOS 15+, while +`deviceValidated=false` marks hardware without reviewed evidence. The +`validated-only` policy exists for controlled deployments and fallback testing, +but is not used by the npm production bundle. ## 7. Normalized configuration diff --git a/docs/napi-design.md b/docs/napi-design.md index 6d2709c..7b3dfa9 100644 --- a/docs/napi-design.md +++ b/docs/napi-design.md @@ -43,7 +43,7 @@ Decision:[decisions.md](decisions.md) D101、D105、D111 - install/postinstall 或运行时网络下载、默认目录扫描或模型自动更新。 - 无模型瘦包、按语言拆分模型、tiny/medium/orientation 模型。 - 对运行中的 ONNX Runtime inference 做硬中断或强制超时终止。 -- 发布 CUDA/DirectML 等其他 Execution Provider;本增量只实现 Apple Silicon 上受资格约束的 Core ML ANE/GPU 路由。 +- 发布 CUDA/DirectML 等其他 Execution Provider;本增量只实现 macOS 15+ 的 Core ML 路由:Apple Silicon 使用 ANE/GPU,Intel Mac 使用实验性 CPU+GPU。 - Electron、Bun、Deno 或浏览器支持声明。 - Linux musl、Linux arm64、Windows arm64。 - 跨进程共享 engine、跨 Node.js Environment 传递 engine。 @@ -329,11 +329,11 @@ export interface OcrEngine { export function createEngine(options?: CreateEngineOptions): Promise; ``` -`SessionExecutionInfo` 分别保存 requested provider、实际配置的 provider chain、device/device family/OS、有效 precision、shape policy、模型 ID/SHA-256、runtime/provider version、model cache status、qualification ID,以及是否发生 session fallback 和稳定原因。`recognitionBatchShapes` 进一步报告每个请求使用的 Core ML function bucket 和 ANE/GPU/CPU 路由。provider chain 只证明 session 配置,不能替代逐函数 Compute Plan 证据。 +`SessionExecutionInfo` 分别保存 requested provider、实际配置的 provider chain、device/device family/OS、有效 precision、shape policy、模型 ID/SHA-256、runtime/provider version、model cache status、qualification ID、`deviceValidated`,以及是否发生 session fallback 和稳定原因。`recognitionBatchShapes` 进一步报告每个请求使用的 Core ML function bucket 和 ANE/GPU/CPU 路由。provider chain 只证明 session 配置,不能替代逐函数 Compute Plan 证据。 `Buffer` 是 `Uint8Array` 的子类,因此可以直接作为 `RawImage.data` 或 `recognizeEncoded()` 输入。不接受 `DataView`、其他 TypedArray 或以 `SharedArrayBuffer` 为 backing store 的 `Uint8Array`。 -`OcrEngine` 没有 public constructor,只能由成功的 `createEngine` 创建。未传 `model`/`bundlePath` 时默认使用内置 `ppocrv6-small`;二者同时出现是 `invalid_argument`。`execution` 默认选择 CPU;Apple 需要自包含 Apple bundle,接受 `fp16`、`latency`、batch 1 和 bounded detection,并按 `sessionFallback` 决定稳定失败或整 session CPU 回退。`reducedLimits` 一旦提供就必须包含全部八个字段;适配器把 Core 固定的 `maxConcurrentCalls=1` 补入 native options。所有配置对象拒绝未知 own property,避免拼写错误被静默忽略。预期的参数、package、I/O、Core 和队列错误都通过 Promise rejection 返回 `OcrError`;取消按 `AbortSignal.reason` 拒绝,默认 `AbortController.abort()` 因而得到标准 `AbortError`。只有非法 receiver、Node-API 无法创建 Promise 或不可恢复的运行时故障可能同步抛出。 +`OcrEngine` 没有 public constructor,只能由成功的 `createEngine` 创建。未传 `model`/`bundlePath` 时默认使用内置 `ppocrv6-small`;二者同时出现是 `invalid_argument`。`execution` 默认选择 CPU;Apple 需要自包含 Apple bundle,接受 `fp16`、`latency`、batch 1 和 bounded detection,并按 `sessionFallback` 决定稳定失败或整 session CPU 回退。生产 bundle 对 macOS 15+ arm64/x86_64 开放,`deviceValidated` 标记当前硬件是否有已审阅证据;Intel 仅支持 `cpuPartition: 'allow'` 的 CPU+GPU 路由。`reducedLimits` 一旦提供就必须包含全部八个字段;适配器把 Core 固定的 `maxConcurrentCalls=1` 补入 native options。所有配置对象拒绝未知 own property,避免拼写错误被静默忽略。预期的参数、package、I/O、Core 和队列错误都通过 Promise rejection 返回 `OcrError`;取消按 `AbortSignal.reason` 拒绝,默认 `AbortController.abort()` 因而得到标准 `AbortError`。只有非法 receiver、Node-API 无法创建 Promise 或不可恢复的运行时故障可能同步抛出。 ### 3.1 使用示例 diff --git a/docs/native-api.md b/docs/native-api.md index a8ef9df..a42161b 100644 --- a/docs/native-api.md +++ b/docs/native-api.md @@ -329,8 +329,8 @@ Rules: - Thread counts are positive and fixed at creation. - CPU remains the default. It accepts `auto`/`fp32`, requires `cpuPartition=allow`, `sessionFallback=error`, and uses the existing ONNX Runtime path. -- The Apple provider accepts `auto`/`fp16`, bounded detection no larger than 960, recognition batch 1, and latency mode. `cpuPartition=allow` selects the qualified FP16 ANE path plus the width-based FP16 GPU route; `cpuPartition=forbid` selects the all-GPU strict path used for qualification. -- `sessionFallback=cpu` permits one explicit whole-session fallback only when the Apple build, device family, or Core ML initialization is unavailable. The chosen CPU sessions report `session_fallback=true` and one of the stable reasons `apple_provider_not_built`, `apple_device_unavailable`, `apple_device_unqualified`, or `apple_initialization_failed`. Inference-time failures never retry on CPU. +- The Apple provider accepts `auto`/`fp16`, bounded detection no larger than 960, recognition batch 1, and latency mode. Production bundles use open macOS compatibility: Apple Silicon with `cpuPartition=allow` selects FP16 ANE plus the width-based FP16 GPU route, while Intel Mac selects Core ML CPU+GPU. `cpuPartition=forbid` selects the all-GPU strict path and is accepted only on Apple Silicon. +- `sessionFallback=cpu` permits one explicit whole-session fallback when the Apple build, macOS version/architecture, an opt-in `validated-only` policy, or Core ML initialization prevents startup. The chosen CPU sessions report `session_fallback=true` and one of the stable reasons `apple_provider_not_built`, `apple_device_unavailable`, `apple_device_unqualified`, or `apple_initialization_failed`. Production `open-macos` bundles do not reject a Mac merely because it lacks reviewed device evidence. Inference-time failures never retry on CPU. - Apple execution requires a schema 1.1 bundle containing the hash-locked provider payload. Unsupported device IDs, throughput mode, precision/provider combinations, detection strategies, and batch sizes fail instead of being ignored. - Score thresholds are finite and in `[0, 1]`. - Batch sizes are positive and no larger than the effective limit. @@ -373,6 +373,7 @@ struct ProviderCapabilityInfo { std::string provider; bool package_included = false; bool device_available = false; + bool device_validated = false; }; struct SessionExecutionInfo { @@ -390,6 +391,7 @@ struct SessionExecutionInfo { std::string provider_version; std::string model_cache_status; std::string qualification_id; + bool device_validated = false; bool session_fallback = false; std::optional fallback_reason; }; @@ -430,7 +432,7 @@ struct EngineInfo { } // namespace light_ocr ``` -`info` is an immutable creation snapshot. The returned reference remains valid until the engine object is destroyed, including after `close`. `provider_capabilities` distinguishes a provider included in the package from one qualified on the current device; each session then records what was actually configured. `RecognitionBatchShape` adds the per-request model/function bucket and ANE/GPU/CPU route. A configured provider chain is not itself placement proof: the Apple release gate separately checks every Core ML function with Compute Plan evidence and binds the result through `qualification_id`. +`info` is an immutable creation snapshot. The returned reference remains valid until the engine object is destroyed, including after `close`. `provider_capabilities` separately reports package inclusion, runtime device availability, and whether the current hardware family has reviewed evidence. Each session repeats `device_validated` beside what was actually configured; `false` means the open compatibility path is experimental, not that Core ML was skipped. `RecognitionBatchShape` adds the per-request model/function bucket and ANE/GPU/CPU route. A configured provider chain is not itself placement proof: the Apple release gate separately checks every Core ML function with Compute Plan evidence and binds the result through `qualification_id`. ## 9. Engine API diff --git a/docs/npm-packaging.md b/docs/npm-packaging.md index 9855491..37ceec7 100644 --- a/docs/npm-packaging.md +++ b/docs/npm-packaging.md @@ -1,7 +1,7 @@ # @arcships/light-ocr npm Package Design 状态:六包设计与 `0.2.0` lockstep 发布已完成;0.2.1 Apple superset bundle release candidate 已接入同一流程
-更新时间:2026-07-15
+更新时间:2026-07-16
Authority:npm 包名、包拆分、依赖关系、内置模型、版本与发布门槛
Node API:[napi-design.md](napi-design.md)
Model contract:[model-bundle.md](model-bundle.md)
@@ -11,7 +11,7 @@ Decision:[decisions.md](decisions.md) D105 0.2.1 候选不增加第七个包或第二个安装入口。model package 改为 `ppocrv6-small-apple-20260715.1` 自包含 superset:所有平台继续使用其中的 -ONNX CPU payload,只有通过 allow-list 的 macOS arm64 才能显式请求 Core ML。 +ONNX CPU payload;macOS 15+ arm64/x86_64 使用 `open-macos` 策略,均可显式请求 Core ML。`validatedDeviceFamilies` 只标记已有真机证据,不阻塞其他 Mac 的实验兼容。 release workflow 在 macOS 以哈希锁 Python 3.12 工具链派生固定 Core ML 工件, Linux assemble job 只消费该 artifact;运行时和 postinstall 都不转换或下载模型。 diff --git a/docs/roadmap.md b/docs/roadmap.md index 80d2721..023088c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -126,7 +126,7 @@ flowchart LR | G2 模型 Pareto 验证 | D107 预先锁定的 tier profile;同机安装大小、质量、延迟、RSS 报告 | 候选包至少两个 prerelease | 每个 GA 杯型通过预注册阈值,且拥有不能被 small 同时满足的清晰受众 | tiny 没有显著降低总部署成本,或 medium 没有在目标 corpus 上产生实际质量收益 | | G3 文档入口验证 | S3 接受/缩减;至少 30 份、100 页、5 类文档的 corpus;公开反馈或集成记录 | preview 发布后 30–60 天 | 安全/资源 gates 全绿;接受分支有至少 3 名独立用户或 2 个外部集成使用 PDF,缩减分支达到同等数量的多页 page-image/Markdown 使用证据 | 接受分支的 renderer 成本不可接受,或任一分支的用户普遍更愿意自行完成文档编排 | | G4 结构化价值验证 | D109 预注册 Layout/reading-order profile;至少 100 页独立标注;preview 集成反馈 | preview 发布后至少 60 天 | 质量 gates 全绿;至少 2 个外部集成证明 Layout 改善 Markdown、chunking 或字段定位 | Layout 结果没有优于 OCR-order,或模型/标注/安装成本超过使用价值 | -| PG Provider Gate | 同机 CPU/EP 对照;simple、dense、tiled、medium 和 Layout 候选 workload;provider payload/driver inventory | 至少 3 次独立冷启动与 30 次 warm run;每个进入 allow-list 的设备族至少一台真实目标设备 | contract 100% 通过;在至少两个目标 workload 上 `CPU P50 latency / EP P50 latency ≥ 1.5`,或吞吐 ≥2× CPU;质量通过预注册容差;无隐式 session fallback;cold-start、安装大小和设备内存不超过预注册 ceiling | 只有 inference microbenchmark 加速、端到端无收益,或初始化/copy/package/质量成本抵消收益 | +| PG Provider Gate | 同机 CPU/EP 对照;simple、dense、tiled、medium 和 Layout 候选 workload;provider payload/driver inventory | 至少 3 次独立冷启动与 30 次 warm run;每个公开性能数字对应至少一台真实目标设备 | contract 100% 通过;在至少两个目标 workload 上 `CPU P50 latency / EP P50 latency ≥ 1.5`,或吞吐 ≥2× CPU;质量通过预注册容差;无隐式 session fallback;cold-start、安装大小和设备内存不超过预注册 ceiling | 只有 inference microbenchmark 加速、端到端无收益,或初始化/copy/package/质量成本抵消收益 | 阈值不得在看到最终候选结果后调整。模型质量、Layout 指标的具体数值由对应 decision 在运行正式评测前锁定;Roadmap 只规定证据类别和决策纪律。 @@ -554,7 +554,7 @@ interface ExecutionOptions { - provider/device/driver/runtime、线程、并发、batch、precision 和电源模式; - CER、Detection P/R/Hmean、空结果、重复行和 schema/坐标 contract。 -正式候选至少进行 3 次独立 cold start、30 次 warm run,并对每个进入 allow-list 的设备族使用至少一台真实目标设备复核;扩大到新的设备族时必须增加对应真机证据。设备内存无法可靠读取时必须标为 unavailable,不能用 host RSS 代替。性能改动不得放宽 bounded/tiled 资源上限或质量阈值。 +正式候选至少进行 3 次独立 cold start、30 次 warm run。开放兼容可以先于设备证据,但每个公开宣称性能或标记 `deviceValidated=true` 的设备族必须至少有一台真实目标设备复核;新增社区设备结果可逐步提升证据状态。设备内存无法可靠读取时必须标为 unavailable,不能用 host RSS 代替。性能改动不得放宽 bounded/tiled 资源上限或质量阈值。 ### 7.5 Perf-1B — CPU 基线与调优 diff --git a/include/light_ocr/types.hpp b/include/light_ocr/types.hpp index 5acb6db..52fb387 100644 --- a/include/light_ocr/types.hpp +++ b/include/light_ocr/types.hpp @@ -183,6 +183,7 @@ struct ProviderCapabilityInfo { std::string provider; bool package_included = false; bool device_available = false; + bool device_validated = false; }; struct SessionExecutionInfo { @@ -200,6 +201,7 @@ struct SessionExecutionInfo { std::string provider_version; std::string model_cache_status; std::string qualification_id; + bool device_validated = false; bool session_fallback = false; std::optional fallback_reason; }; diff --git a/src/core/engine.cpp b/src/core/engine.cpp index eef7e01..0e70be8 100644 --- a/src/core/engine.cpp +++ b/src/core/engine.cpp @@ -105,7 +105,9 @@ internal::AppleModelPackage make_apple_package( package.input_name = model.input_name; package.output_name = model.output_name; package.qualification_id = provider.qualification_id; - package.qualified_device_families = provider.qualified_device_families; + package.device_policy = provider.device_policy; + package.architectures = provider.architectures; + package.validated_device_families = provider.validated_device_families; const auto prefix = model.package_path + "/"; for (const auto& file : bundle.files) { if (file.first.compare(0, prefix.size(), prefix) == 0) { @@ -468,15 +470,17 @@ class EngineImpl final : public Engine { const auto width = static_cast(batch.shape[3]); const bool coreml = info_.execution.recognition.runtime == "Core ML"; - const bool gpu = + const bool ane = coreml && - (info_.execution.cpu_partition == CpuPartition::forbid || - width > bundle_->apple_provider->recognition_ane_maximum_width); + info_.execution.recognition.device.find("ane") != + std::string::npos && + info_.execution.cpu_partition == CpuPartition::allow && + width <= bundle_->apple_provider->recognition_ane_maximum_width; recognition_batch_shapes.push_back( RecognitionBatchShape{static_cast(batch.shape[0]), static_cast(batch.shape[2]), width, - coreml ? (gpu ? "gpu" : "ane") : "cpu", + coreml ? (ane ? "ane" : "gpu") : "cpu", info_.execution.recognition.model_id, coreml ? apple_recognition_function_name(width) @@ -690,13 +694,21 @@ Result> Engine::create(ModelBundle bundle, std::vector recognition_width_buckets; std::uint32_t maximum_backend_batch_size = bundle.data_->recognition.maximum_batch_size; - bool apple_device_qualified = false; + bool apple_device_available = false; + bool apple_device_validated = false; + bool apple_device_allowed = false; #if defined(LIGHT_OCR_HAS_COREML) - const bool apple_device_available = internal::coreml_device_available(); - apple_device_qualified = + apple_device_available = internal::coreml_device_available(); + apple_device_validated = apple_device_available && bundle.data_->apple_provider && - internal::coreml_device_is_qualified( - bundle.data_->apple_provider->qualified_device_families); + internal::coreml_device_is_validated( + bundle.data_->apple_provider->validated_device_families); + apple_device_allowed = + apple_device_available && bundle.data_->apple_provider && + internal::coreml_device_is_allowed( + bundle.data_->apple_provider->device_policy, + bundle.data_->apple_provider->architectures, + bundle.data_->apple_provider->validated_device_families); #endif auto create_cpu_sessions = [&](bool fallback, @@ -740,7 +752,7 @@ Result> Engine::create(ModelBundle bundle, } else { #if defined(LIGHT_OCR_HAS_COREML) std::optional apple_error; - if (apple_device_qualified) { + if (apple_device_allowed) { const auto& apple = *bundle.data_->apple_provider; detection_config.model_id = apple.detection.model_id; detection_config.model_sha256 = apple.detection.package_sha256; @@ -780,7 +792,7 @@ Result> Engine::create(ModelBundle bundle, return Result>::failure(*apple_error); } const auto fallback_reason = - apple_device_qualified + apple_device_allowed ? "apple_initialization_failed" : apple_device_available ? "apple_device_unqualified" : "apple_device_unavailable"; @@ -822,10 +834,11 @@ Result> Engine::create(ModelBundle bundle, info.execution.performance_hint = options.execution.performance_hint; info.execution.requested_precision = options.execution.precision; info.execution.provider_capabilities = { - ProviderCapabilityInfo{"cpu", true, true}}; + ProviderCapabilityInfo{"cpu", true, true, true}}; if (bundle.data_->apple_provider) { info.execution.provider_capabilities.push_back( - ProviderCapabilityInfo{"apple", true, apple_device_qualified}); + ProviderCapabilityInfo{"apple", true, apple_device_available, + apple_device_validated}); } info.execution.detection = detection->execution_info(); info.execution.recognition = recognition->execution_info(); diff --git a/src/inference/backend.hpp b/src/inference/backend.hpp index fbaae6a..99f2ee4 100644 --- a/src/inference/backend.hpp +++ b/src/inference/backend.hpp @@ -27,7 +27,9 @@ struct AppleModelPackage { std::string input_name; std::string output_name; std::string qualification_id; - std::vector qualified_device_families; + std::string device_policy; + std::vector architectures; + std::vector validated_device_families; std::vector files; std::uint32_t recognition_width_multiple = 1; std::uint32_t recognition_ane_maximum_width = 0; diff --git a/src/inference/coreml/backend.hpp b/src/inference/coreml/backend.hpp index e12d64e..f969ef1 100644 --- a/src/inference/coreml/backend.hpp +++ b/src/inference/coreml/backend.hpp @@ -9,8 +9,14 @@ namespace light_ocr::internal { bool coreml_device_available() noexcept; -bool coreml_device_is_qualified( +bool coreml_device_has_neural_engine() noexcept; +std::string coreml_device_architecture() noexcept; +bool coreml_device_is_validated( const std::vector& device_families) noexcept; +bool coreml_device_is_allowed( + const std::string& device_policy, + const std::vector& architectures, + const std::vector& validated_device_families) noexcept; std::string coreml_device_description() noexcept; class CoreMlSession final : public InferenceSession { diff --git a/src/inference/coreml/backend.mm b/src/inference/coreml/backend.mm index 81e48ef..8a4bc19 100644 --- a/src/inference/coreml/backend.mm +++ b/src/inference/coreml/backend.mm @@ -293,17 +293,21 @@ SessionExecutionInfo make_execution_info( const PreparedPackage& prepared) { SessionExecutionInfo info; info.requested_provider = "apple"; - if (kind == ModelKind::detection) { + const bool has_neural_engine = coreml_device_has_neural_engine(); + if (!has_neural_engine) { + info.actual_provider_chain = {"CoreML(MLCPU,MLGPU)"}; + info.device = "cpu+gpu"; + } else if (kind == ModelKind::detection) { info.actual_provider_chain = { config.cpu_partition == CpuPartition::forbid ? "CoreML(MLGPU)" - : "CoreML(MLNeuralEngine,qualified-MLCPU)"}; + : "CoreML(MLNeuralEngine,MLCPU)"}; info.device = config.cpu_partition == CpuPartition::forbid ? "gpu" : "ane"; } else { info.actual_provider_chain = config.cpu_partition == CpuPartition::forbid ? std::vector{"CoreML(MLGPU)"} : std::vector{ - "CoreML(MLNeuralEngine,qualified-MLCPU)", + "CoreML(MLNeuralEngine,MLCPU)", "CoreML(MLGPU)"}; info.device = config.cpu_partition == CpuPartition::forbid ? "gpu" : "ane+gpu"; } @@ -318,6 +322,8 @@ SessionExecutionInfo make_execution_info( info.provider_version = info.runtime_version; info.model_cache_status = prepared.cache_hit ? "compiled_cache_hit" : "compiled_cache_miss"; info.qualification_id = config.apple_package->qualification_id; + info.device_validated = coreml_device_is_validated( + config.apple_package->validated_device_families); return info; } @@ -388,7 +394,8 @@ SessionExecutionInfo make_execution_info( : "w" + std::string(4 - std::to_string(shape[3]).size(), '0') + std::to_string(shape[3]); MLComputeUnits compute_units = MLComputeUnitsCPUAndGPU; - if (config_.cpu_partition == CpuPartition::allow && + if (coreml_device_has_neural_engine() && + config_.cpu_partition == CpuPartition::allow && (kind_ == ModelKind::detection || shape[3] <= config_.apple_package->recognition_ane_maximum_width)) { compute_units = MLComputeUnitsCPUAndNeuralEngine; @@ -600,25 +607,43 @@ void touch(const std::string& key) { bool coreml_device_available() noexcept { @autoreleasepool { -#if defined(__arm64__) if (@available(macOS 15.0, *)) { return true; } -#endif return false; } } +bool coreml_device_has_neural_engine() noexcept { +#if defined(__arm64__) + return true; +#else + return false; +#endif +} + +std::string coreml_device_architecture() noexcept { +#if defined(__arm64__) + return "arm64"; +#elif defined(__x86_64__) + return "x86_64"; +#else + return "unknown"; +#endif +} + std::string coreml_device_description() noexcept { + const auto fallback = + coreml_device_has_neural_engine() ? "Apple Silicon" : "Intel Mac"; try { auto description = sysctl_string("machdep.cpu.brand_string"); - return description.empty() ? "Apple Silicon" : description; + return description.empty() ? fallback : description; } catch (...) { - return "Apple Silicon"; + return fallback; } } -bool coreml_device_is_qualified( +bool coreml_device_is_validated( const std::vector& device_families) noexcept { try { const auto device = coreml_device_description(); @@ -634,6 +659,20 @@ bool coreml_device_is_qualified( } } +bool coreml_device_is_allowed( + const std::string& device_policy, + const std::vector& architectures, + const std::vector& validated_device_families) noexcept { + if (!coreml_device_available() || + std::find(architectures.begin(), architectures.end(), + coreml_device_architecture()) == architectures.end()) { + return false; + } + if (device_policy == "open-macos") return true; + return device_policy == "validated-only" && + coreml_device_is_validated(validated_device_families); +} + CoreMlSession::CoreMlSession(std::unique_ptr impl, SessionExecutionInfo execution_info) : impl_(std::move(impl)), execution_info_(std::move(execution_info)) {} @@ -646,7 +685,7 @@ bool coreml_device_is_qualified( if (!coreml_device_available()) { return failure>( ErrorCode::unsupported_capability, - "The qualified Apple provider requires Apple Silicon and macOS 15"); + "The Apple provider requires macOS 15 or newer"); } if (config.provider != ExecutionProvider::apple || !config.apple_package || (config.precision != Precision::automatic && @@ -658,11 +697,19 @@ bool coreml_device_is_qualified( ErrorCode::invalid_argument, "Apple Core ML session options are invalid"); } - if (!coreml_device_is_qualified( - config.apple_package->qualified_device_families)) { + if (!coreml_device_is_allowed( + config.apple_package->device_policy, + config.apple_package->architectures, + config.apple_package->validated_device_families)) { return failure>( ErrorCode::unsupported_capability, - "The Apple device family has not passed this model qualification"); + "The Apple provider device policy does not allow this Mac"); + } + if (!coreml_device_has_neural_engine() && + config.cpu_partition == CpuPartition::forbid) { + return failure>( + ErrorCode::invalid_argument, + "Intel Mac requires cpuPartition=allow for Core ML CPU+GPU routing"); } const auto prepared = prepare_package(*config.apple_package); auto info = make_execution_info(config, kind, prepared); diff --git a/src/inference/onnxruntime/backend.cpp b/src/inference/onnxruntime/backend.cpp index 910639c..fcd5d35 100644 --- a/src/inference/onnxruntime/backend.cpp +++ b/src/inference/onnxruntime/backend.cpp @@ -113,6 +113,7 @@ SessionExecutionInfo make_execution_info(const InferenceSessionConfig& config) { info.runtime_version = Ort::GetVersionString(); info.provider_version = info.runtime_version; info.model_cache_status = "not_applicable"; + info.device_validated = true; info.session_fallback = config.session_fallback_used; info.fallback_reason = config.fallback_reason; return info; diff --git a/src/model/bundle_data.hpp b/src/model/bundle_data.hpp index ac36297..c83bb80 100644 --- a/src/model/bundle_data.hpp +++ b/src/model/bundle_data.hpp @@ -74,8 +74,9 @@ struct AppleModelConfig { struct AppleProviderConfig { std::string minimum_macos; - std::string architecture; - std::vector qualified_device_families; + std::string device_policy; + std::vector architectures; + std::vector validated_device_families; std::string qualification_id; AppleModelConfig detection; AppleModelConfig recognition; diff --git a/src/model/model_bundle.cpp b/src/model/model_bundle.cpp index f5dd3b4..8a21b3e 100644 --- a/src/model/model_bundle.cpp +++ b/src/model/model_bundle.cpp @@ -334,28 +334,34 @@ std::optional parse_apple_provider( const auto& providers = manifest.at("providers"); if (!providers.contains("apple")) return std::nullopt; const auto& apple = providers.at("apple"); - require_string(apple, "schemaVersion", "1.0", "providers.apple"); + require_string(apple, "schemaVersion", "1.1", "providers.apple"); internal::AppleProviderConfig result; result.minimum_macos = required(apple, "minimumMacOS", "providers.apple"); - result.architecture = - required(apple, "architecture", "providers.apple"); - result.qualified_device_families = required>( - apple, "qualifiedDeviceFamilies", "providers.apple"); + result.device_policy = + required(apple, "devicePolicy", "providers.apple"); + result.architectures = required>( + apple, "architectures", "providers.apple"); + result.validated_device_families = required>( + apple, "validatedDeviceFamilies", "providers.apple"); result.qualification_id = required(apple, "qualificationId", "providers.apple"); const std::unordered_set supported_device_families = { "Apple M1", "Apple M2", "Apple M3", "Apple M4"}; std::unordered_set declared_device_families; - for (const auto& family : result.qualified_device_families) { + for (const auto& family : result.validated_device_families) { require(supported_device_families.count(family) == 1 && declared_device_families.insert(family).second, - "Apple provider contains an unsupported or duplicate device family", + "Apple provider contains an unsupported or duplicate validated family", family); } - require(result.minimum_macos == "15.0" && result.architecture == "arm64" && - !result.qualified_device_families.empty() && - result.qualified_device_families.size() <= 4 && + require(result.minimum_macos == "15.0" && + (result.device_policy == "open-macos" || + result.device_policy == "validated-only") && + result.architectures == + std::vector{"arm64", "x86_64"} && + !result.validated_device_families.empty() && + result.validated_device_families.size() <= 4 && !result.qualification_id.empty() && result.qualification_id.size() <= 128, "Apple provider platform contract is unsupported"); @@ -368,6 +374,8 @@ std::optional parse_apple_provider( "providers.apple.detection") == "ane" && required(detection, "strictComputeUnit", "providers.apple.detection") == "gpu" && + required(detection, "intelComputeUnit", + "providers.apple.detection") == "cpu+gpu" && required>( detection, "qualifiedMLCPUOperations", "providers.apple.detection") == @@ -404,6 +412,8 @@ std::optional parse_apple_provider( {"ios18.relu", 3}, {"pad", 3}} && required(recognition, "functionFormat", "providers.apple.recognition") == "w%04u" && + required(recognition, "intelComputeUnit", + "providers.apple.recognition") == "cpu+gpu" && result.recognition.shape_policy == "nchw-static-width-multiple-32-v1", "Apple recognition routing contract is unsupported"); diff --git a/tests/integration/apple.cpp b/tests/integration/apple.cpp index 09c4f89..06167d0 100644 --- a/tests/integration/apple.cpp +++ b/tests/integration/apple.cpp @@ -98,8 +98,11 @@ void require_wide_recognizer(const fs::path& bundle_path) { package.input_name = recognition.at("inputName").get(); package.output_name = recognition.at("outputName").get(); package.qualification_id = provider.at("qualificationId").get(); - package.qualified_device_families = - provider.at("qualifiedDeviceFamilies").get>(); + package.device_policy = provider.at("devicePolicy").get(); + package.architectures = + provider.at("architectures").get>(); + package.validated_device_families = + provider.at("validatedDeviceFamilies").get>(); package.recognition_width_multiple = recognition.at("widthMultiple").get(); package.recognition_ane_maximum_width = @@ -172,6 +175,10 @@ int main() { auto interactive = create_engine(bundle_path, CpuPartition::allow, SessionFallback::error); const auto& interactive_info = interactive->info(); + const bool has_neural_engine = + light_ocr::internal::coreml_device_has_neural_engine(); + const bool expected_validated = + interactive_info.execution.detection.device_family.rfind("Apple M4", 0) == 0; require(interactive_info.execution_provider == "CoreML", "Interactive engine did not select Core ML"); require(interactive_info.execution.provider_capabilities.size() == 2 && @@ -180,35 +187,59 @@ int main() { interactive_info.execution.provider_capabilities[1] .package_included && interactive_info.execution.provider_capabilities[1] - .device_available, + .device_available && + interactive_info.execution.provider_capabilities[1] + .device_validated == expected_validated, "Apple capability report is invalid"); require(interactive_info.execution.detection.actual_provider_chain == - std::vector{ - "CoreML(MLNeuralEngine,qualified-MLCPU)"}, + (has_neural_engine + ? std::vector{"CoreML(MLNeuralEngine,MLCPU)"} + : std::vector{"CoreML(MLCPU,MLGPU)"}), "Interactive detector routing is invalid"); require(interactive_info.execution.recognition.actual_provider_chain == - std::vector{ - "CoreML(MLNeuralEngine,qualified-MLCPU)", - "CoreML(MLGPU)"}, + (has_neural_engine + ? std::vector{ + "CoreML(MLNeuralEngine,MLCPU)", "CoreML(MLGPU)"} + : std::vector{"CoreML(MLCPU,MLGPU)"}), "Interactive recognizer routing is invalid"); require(!interactive_info.execution.detection.qualification_id.empty() && interactive_info.execution.detection.qualification_id == interactive_info.execution.recognition.qualification_id, "Apple qualification identity is missing or inconsistent"); - require(interactive_info.execution.detection.device_family.find("Apple M") == 0 && + require(interactive_info.execution.detection.device_validated == + expected_validated && + interactive_info.execution.recognition.device_validated == + expected_validated, + "Apple validation status is not observable"); + require(!interactive_info.execution.detection.device_family.empty() && !interactive_info.execution.detection.operating_system.empty(), "Apple device family or operating system is not observable"); - require_hello(interactive.get(), pixels_path, "ane"); + require_hello(interactive.get(), pixels_path, + has_neural_engine ? "ane" : "gpu"); interactive->close(); - auto strict = create_engine(bundle_path, CpuPartition::forbid, - SessionFallback::error); - require(strict->info().execution.detection.actual_provider_chain == - std::vector{"CoreML(MLGPU)"} && - strict->info().execution.recognition.actual_provider_chain == - std::vector{"CoreML(MLGPU)"}, - "Strict Apple profile did not select full GPU routing"); - require_hello(strict.get(), pixels_path, "gpu"); + if (has_neural_engine) { + auto strict = create_engine(bundle_path, CpuPartition::forbid, + SessionFallback::error); + require(strict->info().execution.detection.actual_provider_chain == + std::vector{"CoreML(MLGPU)"} && + strict->info().execution.recognition.actual_provider_chain == + std::vector{"CoreML(MLGPU)"}, + "Strict Apple profile did not select full GPU routing"); + require_hello(strict.get(), pixels_path, "gpu"); + } else { + bool strict_rejected = false; + try { + auto strict = create_engine(bundle_path, CpuPartition::forbid, + SessionFallback::error); + } catch (const std::exception& exception) { + strict_rejected = + std::string(exception.what()).find("Intel Mac requires") != + std::string::npos; + } + require(strict_rejected, + "Intel Mac unexpectedly accepted the strict GPU-only profile"); + } return 0; } catch (const std::exception& exception) { std::cerr << exception.what() << '\n'; diff --git a/tests/integration/main.cpp b/tests/integration/main.cpp index 75ca714..acdaafb 100644 --- a/tests/integration/main.cpp +++ b/tests/integration/main.cpp @@ -108,6 +108,7 @@ int main() { execution.provider_capabilities.front().provider != "cpu" || !execution.provider_capabilities.front().package_included || !execution.provider_capabilities.front().device_available || + !execution.provider_capabilities.front().device_validated || execution.detection.actual_provider_chain != std::vector{"CPUExecutionProvider"} || execution.recognition.actual_provider_chain != @@ -116,6 +117,8 @@ int main() { execution.recognition.model_id != "PP-OCRv6_small_rec_onnx" || execution.detection.model_sha256.size() != 64 || execution.recognition.model_sha256.size() != 64 || + !execution.detection.device_validated || + !execution.recognition.device_validated || execution.detection.precision != "fp32" || execution.recognition.shape_policy != "dynamic" || execution.detection.session_fallback || diff --git a/tests/python/test_apple_qualification.py b/tests/python/test_apple_qualification.py index e07044b..901bccb 100644 --- a/tests/python/test_apple_qualification.py +++ b/tests/python/test_apple_qualification.py @@ -34,11 +34,13 @@ def apple_execution() -> dict[str, object]: "detection": { "modelSha256": "det", "qualificationId": "apple-test", + "deviceValidated": True, "sessionFallback": False, }, "recognition": { "modelSha256": "rec", "qualificationId": "apple-test", + "deviceValidated": True, "sessionFallback": False, }, } @@ -169,6 +171,14 @@ def test_rejects_execution_from_a_different_model(self) -> None: [{"execution": execution}], "apple-test", ("det", "rec"), "test" ) + def test_rejects_unvalidated_execution_as_reviewed_evidence(self) -> None: + execution = self.apple_execution() + execution["recognition"]["deviceValidated"] = False + with self.assertRaisesRegex(RuntimeError, "validated device"): + collect_qualification.validate_execution_models( + [{"execution": execution}], "apple-test", ("det", "rec"), "test" + ) + def test_validates_the_locked_unqualified_device_fallback(self) -> None: execution = {"requestedProvider": "apple"} for stage, model_hash in (("detection", "cpu-det"), diff --git a/tests/python/test_npm_release.py b/tests/python/test_npm_release.py index abda8d5..870a31e 100644 --- a/tests/python/test_npm_release.py +++ b/tests/python/test_npm_release.py @@ -105,9 +105,10 @@ def test_stages_and_deterministically_packs_six_packages(self) -> None: "normalizedConfigPath": "normalized-config.json", "providers": { "apple": { - "schemaVersion": "1.0", - "architecture": "arm64", - "qualifiedDeviceFamilies": ["Apple M4"], + "schemaVersion": "1.1", + "devicePolicy": "open-macos", + "architectures": ["arm64", "x86_64"], + "validatedDeviceFamilies": ["Apple M4"], } }, }) + "\n", "utf-8" diff --git a/tests/unit/test_model_bundle.cpp b/tests/unit/test_model_bundle.cpp index de8e4e9..3763582 100644 --- a/tests/unit/test_model_bundle.cpp +++ b/tests/unit/test_model_bundle.cpp @@ -252,10 +252,11 @@ std::vector valid_apple_bundle_files() { widths.push_back(width); } manifest["providers"]["apple"] = { - {"schemaVersion", "1.0"}, + {"schemaVersion", "1.1"}, {"minimumMacOS", "15.0"}, - {"architecture", "arm64"}, - {"qualifiedDeviceFamilies", {"Apple M4"}}, + {"devicePolicy", "open-macos"}, + {"architectures", {"arm64", "x86_64"}}, + {"validatedDeviceFamilies", {"Apple M4"}}, {"qualificationId", "apple-test-qualification"}, {"detection", {{"modelId", "detector-fp16"}, @@ -266,6 +267,7 @@ std::vector valid_apple_bundle_files() { {"shapePolicy", "nchw-bounded-range-32-960-v1"}, {"preferredComputeUnit", "ane"}, {"strictComputeUnit", "gpu"}, + {"intelComputeUnit", "cpu+gpu"}, {"qualifiedMLCPUOperations", {{"ios18.relu", 1}, {"pad", 1}}}}}, {"recognition", {{"modelId", "recognizer-fp16"}, @@ -283,6 +285,7 @@ std::vector valid_apple_bundle_files() { 1056, 1184, 1248, 1376, 1600, 1984, 2240, 2560, 2880, 3200}}, {"maximumCachedFunctions", 20}, + {"intelComputeUnit", "cpu+gpu"}, {"qualifiedMLCPUOperations", {{"ios18.cast", 1}, {"ios18.conv", 3}, {"ios18.relu", 3}, {"pad", 3}}}}}, @@ -336,12 +339,42 @@ LIGHT_OCR_TEST(model_bundle_rejects_schema_1_1_without_apple_provider) { EXPECT_EQ(result.error().code, ErrorCode::invalid_model_bundle); } -LIGHT_OCR_TEST(model_bundle_rejects_unknown_apple_device_family) { +LIGHT_OCR_TEST(model_bundle_rejects_unknown_validated_apple_device_family) { auto files = valid_apple_bundle_files(); for (auto& file : files) { if (file.path != "manifest.json") continue; auto manifest = Json::parse(std::string(file.bytes->begin(), file.bytes->end())); - manifest["providers"]["apple"]["qualifiedDeviceFamilies"] = {"Apple M9"}; + manifest["providers"]["apple"]["validatedDeviceFamilies"] = {"Apple M9"}; + file.bytes = bytes(manifest.dump()); + break; + } + refresh_checksums(&files); + auto result = ModelBundle::create(std::move(files)); + EXPECT_FALSE(result); + EXPECT_EQ(result.error().code, ErrorCode::invalid_model_bundle); +} + +LIGHT_OCR_TEST(model_bundle_rejects_unknown_apple_device_policy) { + auto files = valid_apple_bundle_files(); + for (auto& file : files) { + if (file.path != "manifest.json") continue; + auto manifest = Json::parse(std::string(file.bytes->begin(), file.bytes->end())); + manifest["providers"]["apple"]["devicePolicy"] = "future-policy"; + file.bytes = bytes(manifest.dump()); + break; + } + refresh_checksums(&files); + auto result = ModelBundle::create(std::move(files)); + EXPECT_FALSE(result); + EXPECT_EQ(result.error().code, ErrorCode::invalid_model_bundle); +} + +LIGHT_OCR_TEST(model_bundle_rejects_incomplete_apple_architectures) { + auto files = valid_apple_bundle_files(); + for (auto& file : files) { + if (file.path != "manifest.json") continue; + auto manifest = Json::parse(std::string(file.bytes->begin(), file.bytes->end())); + manifest["providers"]["apple"]["architectures"] = {"arm64"}; file.bytes = bytes(manifest.dump()); break; } diff --git a/tools/apple/collect_qualification.py b/tools/apple/collect_qualification.py index c9d0afe..b1eb122 100644 --- a/tools/apple/collect_qualification.py +++ b/tools/apple/collect_qualification.py @@ -59,10 +59,11 @@ def validate_execution_models( if ( session.get("modelSha256") != expected_hash or session.get("qualificationId") != qualification_id + or session.get("deviceValidated") is not True or session.get("sessionFallback") is not False ): raise RuntimeError( - f"{context} {stage} execution is not bound to the locked model" + f"{context} {stage} execution is not bound to the locked model and validated device" ) diff --git a/tools/apple/fallback_gate.py b/tools/apple/fallback_gate.py index 14c41f4..50a689a 100644 --- a/tools/apple/fallback_gate.py +++ b/tools/apple/fallback_gate.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Prove that an unqualified Apple family takes the stable CPU fallback path.""" +"""Prove that the opt-in validated-only policy takes the CPU fallback path.""" from __future__ import annotations @@ -70,13 +70,16 @@ def main() -> int: bundle = arguments.bundle.resolve() manifest = read_json(bundle / "manifest.json") provider = manifest.get("providers", {}).get("apple", {}) - accelerated_families = provider.get("qualifiedDeviceFamilies", []) + validated_families = provider.get("validatedDeviceFamilies", []) if ( - not isinstance(accelerated_families, list) - or not accelerated_families - or arguments.expected_device_family in accelerated_families + provider.get("devicePolicy") != "validated-only" + or not isinstance(validated_families, list) + or not validated_families + or arguments.expected_device_family in validated_families ): - parser.error("fallback bundle must exclude the expected device family") + parser.error( + "fallback bundle must use validated-only and exclude the expected family" + ) if ( provider.get("qualificationId") != acceptance["qualificationId"] or provider.get("detection", {}).get("packageSha256") @@ -118,7 +121,7 @@ def main() -> int: "expectedDeviceFamily": arguments.expected_device_family, "passed": True, "bundleId": manifest["bundleId"], - "acceleratedDeviceFamilies": sorted(accelerated_families), + "validatedDeviceFamilies": sorted(validated_families), "models": { "detectionPackageSha256": models["detectionPackageSha256"], "recognitionPackageSha256": models["recognitionPackageSha256"], diff --git a/tools/apple/package_bundle.py b/tools/apple/package_bundle.py index b2bfc7e..90c2704 100644 --- a/tools/apple/package_bundle.py +++ b/tools/apple/package_bundle.py @@ -123,29 +123,35 @@ def main() -> int: help="Reviewed contracts/apple-provider-baselines.json used by release packaging", ) parser.add_argument( - "--qualified-device-family", + "--validated-device-family", action="append", - dest="qualified_device_families", + dest="validated_device_families", choices=("Apple M1", "Apple M2", "Apple M3", "Apple M4"), default=None, - help="Qualified Core ML CPU family prefix; may be repeated", + help="Device family with reviewed performance evidence; may be repeated", + ) + parser.add_argument( + "--device-policy", + choices=("open-macos", "validated-only"), + default="open-macos", + help="Runtime device policy; production defaults to open macOS compatibility", ) arguments = parser.parse_args() - if arguments.qualification_report and arguments.qualified_device_families: + if arguments.qualification_report and arguments.validated_device_families: parser.error( - "--qualification-report and --qualified-device-family are mutually exclusive" + "--qualification-report and --validated-device-family are mutually exclusive" ) acceptance_bytes = ACCEPTANCE.read_bytes() acceptance = json.loads(acceptance_bytes) if arguments.qualification_report: - qualified_device_families = accepted_device_families( + validated_device_families = accepted_device_families( arguments.qualification_report.resolve(), acceptance, hashlib.sha256(acceptance_bytes).hexdigest(), ) else: - qualified_device_families = arguments.qualified_device_families or ["Apple M4"] - if len(qualified_device_families) != len(set(qualified_device_families)): - parser.error("--qualified-device-family values must be unique") + validated_device_families = arguments.validated_device_families or ["Apple M4"] + if len(validated_device_families) != len(set(validated_device_families)): + parser.error("--validated-device-family values must be unique") base = arguments.base.resolve() apple = arguments.apple.resolve() output = arguments.output.resolve() @@ -186,16 +192,18 @@ def main() -> int: manifest["coreCompatibility"]["minimum"] = "0.2.1" manifest["providers"] = { "apple": { - "schemaVersion": "1.0", + "schemaVersion": "1.1", "minimumMacOS": "15.0", - "architecture": "arm64", - "qualifiedDeviceFamilies": qualified_device_families, + "devicePolicy": arguments.device_policy, + "architectures": ["arm64", "x86_64"], + "validatedDeviceFamilies": validated_device_families, "qualificationId": qualification_id, "detection": { **provenance["detection"], "packagePath": "apple/" + provenance["detection"]["package"], "preferredComputeUnit": "ane", "strictComputeUnit": "gpu", + "intelComputeUnit": "cpu+gpu", "qualifiedMLCPUOperations": {"ios18.relu": 1, "pad": 1}, }, "recognition": { @@ -205,6 +213,7 @@ def main() -> int: "aneMaximumWidth": routing["recognitionAneMaximumWidth"], "runtimeWidthBuckets": routing["recognitionRuntimeWidthBuckets"], "maximumCachedFunctions": routing["maximumCachedFunctions"], + "intelComputeUnit": "cpu+gpu", "qualifiedMLCPUOperations": { "ios18.cast": 1, "ios18.conv": 3, diff --git a/tools/benchmark/main.cpp b/tools/benchmark/main.cpp index cf90d16..e463e1c 100644 --- a/tools/benchmark/main.cpp +++ b/tools/benchmark/main.cpp @@ -79,6 +79,7 @@ nlohmann::json session_execution_json( {"providerVersion", info.provider_version}, {"modelCacheStatus", info.model_cache_status}, {"qualificationId", info.qualification_id}, + {"deviceValidated", info.device_validated}, {"sessionFallback", info.session_fallback}, }; if (info.fallback_reason) result["fallbackReason"] = *info.fallback_reason; diff --git a/tools/leak_check/main.cpp b/tools/leak_check/main.cpp index e99015a..cea9376 100644 --- a/tools/leak_check/main.cpp +++ b/tools/leak_check/main.cpp @@ -52,6 +52,7 @@ nlohmann::json session_identity( const light_ocr::SessionExecutionInfo& session) { return {{"modelSha256", session.model_sha256}, {"qualificationId", session.qualification_id}, + {"deviceValidated", session.device_validated}, {"sessionFallback", session.session_fallback}}; } diff --git a/tools/npm_release.py b/tools/npm_release.py index 96a9c90..cc53a3d 100644 --- a/tools/npm_release.py +++ b/tools/npm_release.py @@ -258,17 +258,18 @@ def assemble(arguments: argparse.Namespace) -> None: normalized_config = read_json(bundle / manifest["normalizedConfigPath"]) tiled_contract = normalized_config.get("runtimeProfiles", {}).get("tiled", {}) apple_provider = manifest.get("providers", {}).get("apple", {}) - qualified_families = apple_provider.get("qualifiedDeviceFamilies", []) + validated_families = apple_provider.get("validatedDeviceFamilies", []) if (manifest.get("schemaVersion") != "1.1" or normalized_config.get("schemaVersion") != "1.2" or tiled_contract.get("contractVersion") != "tiled-v1" or - apple_provider.get("schemaVersion") != "1.0" or - apple_provider.get("architecture") != "arm64" or - not isinstance(qualified_families, list) or - len(qualified_families) < 1 or - len(qualified_families) != len(set(qualified_families)) or + apple_provider.get("schemaVersion") != "1.1" or + apple_provider.get("devicePolicy") != "open-macos" or + apple_provider.get("architectures") != ["arm64", "x86_64"] or + not isinstance(validated_families, list) or + len(validated_families) < 1 or + len(validated_families) != len(set(validated_families)) or any(family not in {"Apple M1", "Apple M2", "Apple M3", "Apple M4"} - for family in qualified_families)): + for family in validated_families)): raise RuntimeError( "model bundle does not contain the tiled-v1 Apple release contract" ) From d610e126fbc8356f894a8c0c623837bad00689af Mon Sep 17 00:00:00 2001 From: eric8810 Date: Thu, 16 Jul 2026 10:52:52 +0800 Subject: [PATCH 9/9] =?UTF-8?q?fix(build):=20=E6=97=A0=E8=8A=AF=E4=B9=8B?= =?UTF-8?q?=E5=A2=83=EF=BC=8C=E6=94=B6=E5=8D=B4=E8=99=9A=E6=97=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 仅在 Core ML 构建中声明 Apple 设备准入状态,修复 Linux 等 CPU-only 平台的 Werror 未使用变量失败。 --- src/core/engine.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/engine.cpp b/src/core/engine.cpp index 0e70be8..64c59ca 100644 --- a/src/core/engine.cpp +++ b/src/core/engine.cpp @@ -696,8 +696,8 @@ Result> Engine::create(ModelBundle bundle, bundle.data_->recognition.maximum_batch_size; bool apple_device_available = false; bool apple_device_validated = false; - bool apple_device_allowed = false; #if defined(LIGHT_OCR_HAS_COREML) + bool apple_device_allowed = false; apple_device_available = internal::coreml_device_available(); apple_device_validated = apple_device_available && bundle.data_->apple_provider &&