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 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 ebb60c4..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` 已发布;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` 已发布;当前 0.2.1 源码候选加入开放 macOS 兼容的 Apple/Core ML provider。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})` 接受 `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)。 @@ -51,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 @@ -65,7 +67,17 @@ 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: 'apple', + precision: 'fp16', + sessionFallback: 'cpu', + }, +}); + +console.log(engine.info.execution.sessions.detection.actualProviderChain); +console.log(engine.info.execution.sessions.detection.deviceValidated); ``` 当前源码开发用法仍需显式 bundle: 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 8534f85..cc2a415 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' | 'apple'; +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 { @@ -67,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; @@ -121,13 +140,55 @@ export interface TiledDetectionInfo { readonly mergeIouThreshold: 0.5; readonly mergeIosThreshold: 0.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; + readonly actualProviderChain: readonly string[]; + readonly device: string; + readonly deviceFamily: string; + readonly operatingSystem: 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 qualificationId: string; + /** False means the open macOS compatibility path is experimental on this device. */ + readonly deviceValidated: boolean; + 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/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 e87d450..4ea7768 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; + 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) { + 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) { @@ -1114,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), @@ -1166,6 +1243,125 @@ napi_value create_resource_limits(napi_env env, const ResourceLimits& limits) { return object; } +const char* execution_provider_string(ExecutionProvider provider) { + return provider == ExecutionProvider::apple ? "apple" : "cpu"; +} + +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, "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)); + 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, "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) { + 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)); + set_named(env, entry, "deviceValidated", + boolean_value(env, capability.device_validated)); + 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 +1374,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..58d586f 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', @@ -108,8 +111,43 @@ 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, + deviceValidated: 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.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)); 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); @@ -143,6 +181,80 @@ test('loads PP-OCRv6, snapshots pixels, maps results, and closes idempotently', ); }); +test('exposes open Apple routing and validation status', { + 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,MLCPU)'], + ); + assert.deepEqual( + interactive.info.execution.sessions.recognition.actualProviderChain, + ['CoreML(MLNeuralEngine,MLCPU)', 'CoreML(MLGPU)'], + ); + assert.match( + interactive.info.execution.sessions.detection.qualificationId, + /^apple-/, + ); + assert.match( + 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, + ); + 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); @@ -261,6 +373,38 @@ 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: { 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', + ); + 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/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/contracts/apple-provider-baselines.schema.json b/contracts/apple-provider-baselines.schema.json new file mode 100644 index 0000000..d368ef1 --- /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": 1, + "uniqueItems": true, + "items": { "enum": ["Apple M1", "Apple M2", "Apple M3", "Apple M4"] } + }, + "devices": { "type": "array", "minItems": 1, "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 e1f747e..07fa521 100644 --- a/docs/apple-device-acceleration.md +++ b/docs/apple-device-acceleration.md @@ -1,10 +1,12 @@ # Apple Device 加速技术方案 -状态:Draft;用于方案讨论,不代表已经批准实现或发布 +状态: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 与 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--性能与宿主加速线) @@ -89,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,不能被本次实现冒充为可复跑的正式证据,也不作为当前 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 `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.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 增长 | -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`。 + #### CPU 与 FP16 GPU | 模式 | 测量范围 | 结果 | 解释 | @@ -110,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。 @@ -133,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. 推荐运行时架构 @@ -205,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. 量化设计 @@ -254,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 ``` @@ -270,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. 公共策略与可观测性 @@ -300,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。产品文案只能描述已经通过资格审查的执行路径。 @@ -328,13 +352,15 @@ Apple provider 继承 Roadmap Provider Gate,并增加交互式 CPU 目标: - 无未声明的 ORT CPU、MLCPU 或整 session fallback; - 公共 contract 100% 通过;FP16/W8A8 的质量容差在查看最终 benchmark 前锁定; - reference PDF 不允许出现预注册的关键文本漏检; -- cold start、RSS、包增量和缓存行为通过预注册 ceiling; -- 至少在两台目标设备上复核,其中 W8A8 必须覆盖计划宣称支持的硬件代际。 +- 固定启动 canary 的 cold start、warm RSS、包增量和缓存行为通过预注册 ceiling;其他 workload 仍报告首次整页耗时; +- `open-macos` 兼容设备无需先进入白名单即可运行,但公开性能数字只能来自 `validatedDeviceFamilies`;当前只有 `Apple M4`。W8A8 若未来启用,仍必须覆盖计划宣称支持的每个硬件代际。 ## 12. 分阶段落地 ### Phase A — 固化证据和决策 +状态:已完成。Direct Core ML、shape contract、质量/性能/RSS/cache 阈值和 D111 addendum 已锁定;缺失的旧 15 页 PDF 只保留为历史 spike 复跑项。 + - 统一 CPU/CoreML 的 15 页 PDF benchmark harness。 - 固定测试 corpus、质量指标和最简 scoreboard。 - 对照 Direct CoreML 与新版 ORT CoreML EP 的静态/enumerated shape placement。 @@ -344,6 +370,8 @@ Apple provider 继承 Roadmap Provider Gate,并增加交互式 CPU 目标: ### Phase B — FP16 Apple interactive preview +状态:实现完成,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。 - 加入 lazy load、缓存、严格 placement 诊断和显式 fallback。 @@ -353,6 +381,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 明确支持并由项目复核的设备族启用。 @@ -362,21 +392,23 @@ Apple provider 继承 Roadmap Provider Gate,并增加交互式 CPU 目标: ### Phase D — 稳定自包含分发 +状态:代码与发布流程已实现;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。 +- 在社区或维护者能够取得的新设备上复跑正式 corpus;通过后只提升 `deviceValidated` 证据状态,不阻塞此前的实验兼容运行。 退出条件:用户仅安装 `@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 使用 `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 页生命周期报告已完成,并由 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 e745ddb..7b1db3f 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. 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. 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, 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 @@ -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/build-and-release.md b/docs/build-and-release.md index 4dc94fd..3d4e8e1 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,12 @@ gh workflow run tiled-qualification.yml --ref main -f run_benchmark=true benchmark 结果是独立资格审查证据,不是每次发布的重复步骤。需要建立或更新 accepted baseline 时,仍须人工 review 并作为源码提交;脚本不会自动接受当前值。`promote_latest` 默认为 `false`,需要在 registry evidence 人工核对后显式选择。 +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`。完整命令及阈值以 [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、模型身份与至少一个真实验证设备族,并把这些证据映射为 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 校验阶段中断后的安全恢复。 Actions 均固定到 commit SHA。D013 之前的四平台 workflow 已通过;bounded/streaming 变更后的发布候选必须重新保留每个 job 的不可变 run/artifact 证据,旧 run 不能替代当前代码。 diff --git a/docs/decisions.md b/docs/decisions.md index 55723d6..100994a 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;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 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 ### 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..b08a795 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -1,7 +1,7 @@ # C++ Core 与 Node-API 实施状态 -更新时间:2026-07-14 -结论:`@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 中也仍是默认策略。 +更新时间: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,13 +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 / 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 均已完成。 | ## 本机最终验证快照 -环境: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。 | 验证 | 结果 | | --- | --- | @@ -45,6 +46,14 @@ | 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 合约 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 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 7e66ebf..9cbced9 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 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) @@ -16,6 +16,12 @@ 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 +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. ## 2. Upstream snapshot @@ -80,6 +86,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 +117,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 +177,38 @@ 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. The provider sub-contract is version 1.1 and fixes: + +- `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, 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; +- 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 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. `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 ab15475..7b3dfa9 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` 已发布;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) -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。 +- 发布 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。 @@ -86,6 +86,21 @@ export interface DetectionOptions { readonly maxSide?: number; } +export type ExecutionProvider = "cpu" | "apple"; +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. */ @@ -166,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; }[]; } @@ -193,13 +212,50 @@ export interface OcrResult { readonly diagnostics?: Diagnostics; } +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; + readonly modelSha256: string; + readonly runtime: string; + readonly runtimeVersion: string; + readonly providerVersion: string; + readonly modelCacheStatus: string; + readonly qualificationId: 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 +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、`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`。`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 使用示例 @@ -317,6 +375,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..a42161b 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 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) -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,6 +289,21 @@ struct DetectionOptions { std::optional max_side; }; +enum class ExecutionProvider { cpu, apple }; +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 +311,7 @@ struct EngineOptions { std::optional recognition_batch_size; std::optional reduced_limits; DetectionOptions detection; + ExecutionOptions execution; }; struct RecognizeOptions { @@ -309,6 +328,10 @@ struct RecognizeOptions { 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. 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. - `bounded` defaults to side 960; its side is a positive 32 multiple no larger than the effective detection ceiling. @@ -346,13 +369,54 @@ struct TiledDetectionInfo { float merge_ios_threshold = 0; }; +struct ProviderCapabilityInfo { + std::string provider; + bool package_included = false; + bool device_available = false; + bool device_validated = false; +}; + +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; + std::string model_sha256; + std::string runtime; + std::string runtime_version; + 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; +}; + +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 +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`. +`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 @@ -409,8 +473,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/docs/npm-packaging.md b/docs/npm-packaging.md index ab5a960..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 发布已完成;[发布证据](releases/npm-0.2.0.md)
-更新时间:2026-07-14
+状态:六包设计与 `0.2.0` lockstep 发布已完成;0.2.1 Apple superset bundle release candidate 已接入同一流程
+更新时间:2026-07-16
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;macOS 15+ arm64/x86_64 使用 `open-macos` 策略,均可显式请求 Core ML。`validatedDeviceFamilies` 只标记已有真机证据,不阻塞其他 Mac 的实验兼容。 +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/docs/roadmap.md b/docs/roadmap.md index 563ebdf..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;两个目标设备 | 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,并在两个目标设备上复核。设备内存无法可靠读取时必须标为 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 612ba86..52fb387 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, apple }; + +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; @@ -53,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 { @@ -121,6 +134,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 +150,7 @@ struct EngineOptions { std::optional recognition_batch_size; std::optional reduced_limits; DetectionOptions detection; + ExecutionOptions execution; }; struct RecognizeOptions { @@ -156,6 +179,45 @@ struct TiledDetectionInfo { float merge_ios_threshold = 0; }; +struct ProviderCapabilityInfo { + std::string provider; + bool package_included = false; + bool device_available = false; + bool device_validated = false; +}; + +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; + std::string model_sha256; + std::string runtime; + std::string runtime_version; + 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; +}; + +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 +225,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..64c59ca 100644 --- a/src/core/engine.cpp +++ b/src/core/engine.cpp @@ -16,6 +16,10 @@ #include "detection/db_postprocess.hpp" #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" @@ -38,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)}); @@ -64,15 +73,78 @@ bool valid_limits(const ResourceLimits& value, const ResourceLimits& ceiling) { value.max_concurrent_calls == 1; } +bool valid_execution_options(const ExecutionOptions& options) { + 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::fp16); +} + +#if defined(LIGHT_OCR_HAS_COREML) +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.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) { + 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; +} +#endif + 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, 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(); } @@ -115,6 +187,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 && @@ -334,7 +407,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) { @@ -384,7 +458,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) { @@ -392,10 +467,24 @@ 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 ane = + coreml && + 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]), - static_cast(batch.shape[3])}); + width, + coreml ? (ane ? "ane" : "gpu") : "cpu", + info_.execution.recognition.model_id, + coreml + ? apple_recognition_function_name(width) + : "dynamic"}); } std::vector().swap(crops); @@ -476,9 +565,12 @@ 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_; + 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; @@ -500,6 +592,17 @@ 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"); + } + 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)) { return failure>(ErrorCode::invalid_argument, @@ -511,7 +614,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"); } @@ -523,6 +628,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 @@ -554,16 +665,155 @@ 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); - auto detection = internal::OnnxSession::create( - detection_bytes, options.intra_op_threads, options.inter_op_threads, - 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); - if (!recognition) return Result>::failure(recognition.error()); + 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; + 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_available = false; + bool apple_device_validated = 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 && + 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, + 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_allowed) { + 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_allowed + ? "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; @@ -571,8 +821,27 @@ 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.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; + 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, true}}; + if (bundle.data_->apple_provider) { + info.execution.provider_capabilities.push_back( + ProviderCapabilityInfo{"apple", true, apple_device_available, + apple_device_validated}); + } + 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; @@ -589,9 +858,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 new file mode 100644 index 0000000..99f2ee4 --- /dev/null +++ b/src/inference/backend.hpp @@ -0,0 +1,88 @@ +#pragma once + +#include +#include +#include +#include +#include +#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::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; + std::uint32_t maximum_cached_functions = 1; +}; + +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; + std::optional apple_package; + std::string requested_provider_override; + bool session_fallback_used = false; + std::optional fallback_reason; +}; + +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/coreml/backend.hpp b/src/inference/coreml/backend.hpp new file mode 100644 index 0000000..f969ef1 --- /dev/null +++ b/src/inference/coreml/backend.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include + +#include "inference/backend.hpp" + +namespace light_ocr::internal { + +bool coreml_device_available() noexcept; +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 { + 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..8a4bc19 --- /dev/null +++ b/src/inference/coreml/backend.mm @@ -0,0 +1,753 @@ +#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"; + 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,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,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; + info.device_validated = coreml_device_is_validated( + config.apple_package->validated_device_families); + 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 (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; + } + 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 (@available(macOS 15.0, *)) { + return true; + } + 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() ? fallback : description; + } catch (...) { + return fallback; + } +} + +bool coreml_device_is_validated( + 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; + } +} + +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)) {} + +CoreMlSession::~CoreMlSession() noexcept = default; + +Result> CoreMlSession::create( + const InferenceSessionConfig& config, ModelKind kind) { + try { + if (!coreml_device_available()) { + return failure>( + ErrorCode::unsupported_capability, + "The Apple provider requires macOS 15 or newer"); + } + 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_allowed( + config.apple_package->device_policy, + config.apple_package->architectures, + config.apple_package->validated_device_families)) { + return failure>( + ErrorCode::unsupported_capability, + "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); + 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 d943da4..fcd5d35 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,79 @@ 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 = config.requested_provider_override.empty() + ? "cpu" + : config.requested_provider_override; + 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"; + info.device_validated = true; + info.session_fallback = config.session_fallback_used; + info.fallback_reason = config.fallback_reason; + 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 +152,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 +208,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..e698c07 100644 --- a/src/inference/onnxruntime/backend.hpp +++ b/src/inference/onnxruntime/backend.hpp @@ -7,55 +7,35 @@ #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..c83bb80 100644 --- a/src/model/bundle_data.hpp +++ b/src/model/bundle_data.hpp @@ -63,12 +63,39 @@ 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 device_policy; + std::vector architectures; + std::vector validated_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; 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; @@ -76,6 +103,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 8f61b96..8a21b3e 100644 --- a/src/model/model_bundle.cpp +++ b/src/model/model_bundle.cpp @@ -264,6 +264,170 @@ 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.1", "providers.apple"); + internal::AppleProviderConfig result; + result.minimum_macos = + required(apple, "minimumMacOS", "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.validated_device_families) { + require(supported_device_families.count(family) == 1 && + declared_device_families.insert(family).second, + "Apple provider contains an unsupported or duplicate validated family", + family); + } + 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"); + 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, "intelComputeUnit", + "providers.apple.detection") == "cpu+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" && + required(recognition, "intelComputeUnit", + "providers.apple.recognition") == "cpu+gpu" && + 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 +730,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", @@ -601,12 +766,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 +817,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 +842,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 = @@ -681,6 +859,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..06167d0 --- /dev/null +++ b/tests/integration/apple.cpp @@ -0,0 +1,248 @@ +#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.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 = + 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(); + 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 && + interactive_info.execution.provider_capabilities[1].provider == + "apple" && + interactive_info.execution.provider_capabilities[1] + .package_included && + interactive_info.execution.provider_capabilities[1] + .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 == + (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 == + (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_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, + has_neural_engine ? "ane" : "gpu"); + interactive->close(); + + 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'; + return 1; + } +} diff --git a/tests/integration/main.cpp b/tests/integration/main.cpp index ea642f0..acdaafb 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,34 @@ 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.provider_capabilities.front().device_validated || + 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.device_validated || + !execution.recognition.device_validated || + 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 +315,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/tests/python/test_apple_qualification.py b/tests/python/test_apple_qualification.py new file mode 100644 index 0000000..901bccb --- /dev/null +++ b/tests/python/test_apple_qualification.py @@ -0,0 +1,303 @@ +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, + fallback_gate, + 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", + "deviceValidated": True, + "sessionFallback": False, + }, + "recognition": { + "modelSha256": "rec", + "qualificationId": "apple-test", + "deviceValidated": True, + "sessionFallback": False, + }, + } + + def test_collects_a_locally_qualified_device(self) -> None: + with tempfile.TemporaryDirectory() as work: + root = Path(work) + acceptance = { + "qualificationId": "apple-test", + "models": { + "artifactId": "artifact", + "detectionPackageSha256": "det", + "recognitionPackageSha256": "rec", + "detectionCpuSha256": "cpu-det", + "recognitionCpuSha256": "cpu-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": 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 (("m4", "Apple M4"),): + 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 M4"]) + 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_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"), + ("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", + "models": { + "artifactId": "artifact", + "detectionPackageSha256": "d" * 64, + "recognitionPackageSha256": "r" * 64, + "detectionCpuSha256": "c" * 64, + "recognitionCpuSha256": "e" * 64, + }, + "compatibility": { + "minimumQualifiedDevices": 1, + }, + } + 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 M4"], + "devices": [ + {"deviceFamily": "Apple M4"}, + ], + } + 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 M4"], + ) + + 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..870a31e 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,17 @@ 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.1", + "devicePolicy": "open-macos", + "architectures": ["arm64", "x86_64"], + "validatedDeviceFamilies": ["Apple M4"], + } + }, }) + "\n", "utf-8" ) (bundle / "normalized-config.json").write_text( @@ -117,19 +125,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 +148,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..3763582 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,84 @@ 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.1"}, + {"minimumMacOS", "15.0"}, + {"devicePolicy", "open-macos"}, + {"architectures", {"arm64", "x86_64"}}, + {"validatedDeviceFamilies", {"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"}, + {"intelComputeUnit", "cpu+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}, + {"intelComputeUnit", "cpu+gpu"}, + {"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 +314,92 @@ 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_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"]["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; + } + 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..1d3f7f6 --- /dev/null +++ b/tools/apple/acceptance.json @@ -0,0 +1,56 @@ +{ + "schemaVersion": "1.1", + "qualificationId": "apple-fp16-mixed-20260715.2", + "models": { + "artifactId": "apple-fp16-20260715.1", + "detectionPackageSha256": "2097bd785947c6bc239bfcb27599362c48ec78bab72f439583e41a585b727f76", + "recognitionPackageSha256": "c54a0719cbde2d93e65eb40dd01fff5b78373b5aaaaa648fee614d3ef3615f4b", + "detectionCpuSha256": "d73e0058b7a8086bbd57f3d10b8bcd4ff95363f67e06e2762b5e814fe9c9410e", + "recognitionCpuSha256": "5435fd747c9e0efe15a96d0b378d5bd157e9492ed8fd80edf08f30d02fa24634" + }, + "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": 1 + } +} 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/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/collect_qualification.py b/tools/apple/collect_qualification.py new file mode 100644 index 0000000..b1eb122 --- /dev/null +++ b/tools/apple/collect_qualification.py @@ -0,0 +1,257 @@ +#!/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("deviceValidated") is not True + or session.get("sessionFallback") is not False + ): + raise RuntimeError( + f"{context} {stage} execution is not bound to the locked model and validated device" + ) + + +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/fallback_gate.py b/tools/apple/fallback_gate.py new file mode 100644 index 0000000..50a689a --- /dev/null +++ b/tools/apple/fallback_gate.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Prove that the opt-in validated-only policy takes the 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", {}) + validated_families = provider.get("validatedDeviceFamilies", []) + if ( + 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 use validated-only and exclude the expected 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"], + "validatedDeviceFamilies": sorted(validated_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/package_bundle.py b/tools/apple/package_bundle.py new file mode 100644 index 0000000..90c2704 --- /dev/null +++ b/tools/apple/package_bundle.py @@ -0,0 +1,243 @@ +#!/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( + "--validated-device-family", + action="append", + dest="validated_device_families", + choices=("Apple M1", "Apple M2", "Apple M3", "Apple M4"), + default=None, + 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.validated_device_families: + parser.error( + "--qualification-report and --validated-device-family are mutually exclusive" + ) + acceptance_bytes = ACCEPTANCE.read_bytes() + acceptance = json.loads(acceptance_bytes) + if arguments.qualification_report: + validated_device_families = accepted_device_families( + arguments.qualification_report.resolve(), acceptance, + hashlib.sha256(acceptance_bytes).hexdigest(), + ) + else: + 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() + 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.1", + "minimumMacOS": "15.0", + "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": { + **provenance["recognition"], + "packagePath": "apple/" + provenance["recognition"]["package"], + "widthMultiple": routing["recognitionWidthMultiple"], + "aneMaximumWidth": routing["recognitionAneMaximumWidth"], + "runtimeWidthBuckets": routing["recognitionRuntimeWidthBuckets"], + "maximumCachedFunctions": routing["maximumCachedFunctions"], + "intelComputeUnit": "cpu+gpu", + "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..ed61c6c --- /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.2", + ) + 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..e463e1c 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,34 @@ 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}, + {"deviceValidated", info.device_validated}, + {"sessionFallback", info.session_fallback}, + }; + if (info.fallback_reason) result["fallbackReason"] = *info.fallback_reason; + return result; +} + } // namespace int main(int argc, char** argv) { @@ -79,6 +108,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 +144,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 +180,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 +212,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 +236,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 +258,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..cea9376 100644 --- a/tools/leak_check/main.cpp +++ b/tools/leak_check/main.cpp @@ -13,19 +13,47 @@ #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}, + {"deviceValidated", session.device_validated}, + {"sessionFallback", session.session_fallback}}; } } // namespace @@ -39,18 +67,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 +106,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..cc53a3d 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,22 @@ 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", {}) + 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"): - raise RuntimeError("model bundle does not contain the tiled-v1 release contract") + tiled_contract.get("contractVersion") != "tiled-v1" 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 validated_families)): + raise RuntimeError( + "model bundle does not contain the tiled-v1 Apple release contract" + ) facade = output / "facade" facade.mkdir() 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"); 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;