Expose raster top-4 strokes and add end-to-end release lineage gate
Browse files- MANIFEST.json +39 -29
- README.md +3 -1
- android/README.md +17 -0
- android/aiflow-math-ink-runtime/src/main/java/ai/aiflow/mathink/AIFlowMathInk.kt +15 -0
- android/aiflow-math-ink-runtime/src/main/java/ai/aiflow/mathink/CompiledModelLiteRtSession.kt +61 -2
- android/aiflow-math-ink-runtime/src/main/java/ai/aiflow/mathink/InkContracts.kt +39 -1
- android/aiflow-math-ink-runtime/src/test/java/ai/aiflow/mathink/AIFlowMathInkTest.kt +30 -0
- colab/aiflow_math_ink_06_litert_bundle.manifest.json +60 -10
- colab/aiflow_math_ink_06_litert_bundle.zip +2 -2
- colab/aiflow_math_ink_06_litert_bundle.zip.manifest.json +58 -8
- scripts/build_math_ink_06_litert_colab_bundle.py +25 -0
- scripts/build_math_ink_06_product_release_manifest.py +132 -0
- scripts/export_math_ink_06_litert.py +19 -2
- scripts/export_math_ink_06_p_formula_student.py +8 -1
- scripts/package_math_ink_06_mobile_models.py +163 -0
- src/ink06_export.py +44 -0
MANIFEST.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
| 1 |
{
|
| 2 |
-
"schema": "aiflow-hf-research-snapshot-
|
| 3 |
-
"generated_at": "2026-07-
|
| 4 |
"track": "R_noncommercial_plus_rejected_P_proxy",
|
| 5 |
"product_validation": false,
|
| 6 |
"public_release": true,
|
| 7 |
"contains_raw_dataset": false,
|
| 8 |
-
"tests": "
|
| 9 |
"retracted_paths": [
|
| 10 |
"models/auxiliary/boundary_auxiliary_head.pt",
|
| 11 |
"models/auxiliary/seed17/boundary_joint_delta.pt",
|
|
@@ -55,8 +55,8 @@
|
|
| 55 |
},
|
| 56 |
{
|
| 57 |
"path": "scripts/build_math_ink_06_litert_colab_bundle.py",
|
| 58 |
-
"bytes":
|
| 59 |
-
"sha256": "
|
| 60 |
},
|
| 61 |
{
|
| 62 |
"path": "scripts/calibrate_math_ink_06_online_family_fusion.py",
|
|
@@ -160,8 +160,8 @@
|
|
| 160 |
},
|
| 161 |
{
|
| 162 |
"path": "src/ink06_export.py",
|
| 163 |
-
"bytes":
|
| 164 |
-
"sha256": "
|
| 165 |
},
|
| 166 |
{
|
| 167 |
"path": "scripts/train_math_ink_06_boundary_behavior_guard.py",
|
|
@@ -215,8 +215,8 @@
|
|
| 215 |
},
|
| 216 |
{
|
| 217 |
"path": "scripts/export_math_ink_06_p_formula_student.py",
|
| 218 |
-
"bytes":
|
| 219 |
-
"sha256": "
|
| 220 |
},
|
| 221 |
{
|
| 222 |
"path": "scripts/materialize_math_ink_06_p_formula.py",
|
|
@@ -240,8 +240,8 @@
|
|
| 240 |
},
|
| 241 |
{
|
| 242 |
"path": "scripts/export_math_ink_06_litert.py",
|
| 243 |
-
"bytes":
|
| 244 |
-
"sha256": "
|
| 245 |
},
|
| 246 |
{
|
| 247 |
"path": "scripts/run_math_ink_06_p_formula_release.py",
|
|
@@ -285,8 +285,8 @@
|
|
| 285 |
},
|
| 286 |
{
|
| 287 |
"path": "android/aiflow-math-ink-runtime/src/test/java/ai/aiflow/mathink/AIFlowMathInkTest.kt",
|
| 288 |
-
"bytes":
|
| 289 |
-
"sha256": "
|
| 290 |
},
|
| 291 |
{
|
| 292 |
"path": "artifacts/boundary_behavior_guard.joblib",
|
|
@@ -305,13 +305,13 @@
|
|
| 305 |
},
|
| 306 |
{
|
| 307 |
"path": "android/aiflow-math-ink-runtime/src/main/java/ai/aiflow/mathink/CompiledModelLiteRtSession.kt",
|
| 308 |
-
"bytes":
|
| 309 |
-
"sha256": "
|
| 310 |
},
|
| 311 |
{
|
| 312 |
"path": "android/aiflow-math-ink-runtime/src/main/java/ai/aiflow/mathink/InkContracts.kt",
|
| 313 |
-
"bytes":
|
| 314 |
-
"sha256": "
|
| 315 |
},
|
| 316 |
{
|
| 317 |
"path": "assets/stroke_encoding.svg",
|
|
@@ -320,8 +320,8 @@
|
|
| 320 |
},
|
| 321 |
{
|
| 322 |
"path": "colab/aiflow_math_ink_06_litert_bundle.zip.manifest.json",
|
| 323 |
-
"bytes":
|
| 324 |
-
"sha256": "
|
| 325 |
},
|
| 326 |
{
|
| 327 |
"path": "configs/MATH-INK-06-P-FORMULA-v1.json",
|
|
@@ -340,13 +340,13 @@
|
|
| 340 |
},
|
| 341 |
{
|
| 342 |
"path": "colab/aiflow_math_ink_06_litert_bundle.manifest.json",
|
| 343 |
-
"bytes":
|
| 344 |
-
"sha256": "
|
| 345 |
},
|
| 346 |
{
|
| 347 |
"path": "colab/aiflow_math_ink_06_litert_bundle.zip",
|
| 348 |
-
"bytes":
|
| 349 |
-
"sha256": "
|
| 350 |
},
|
| 351 |
{
|
| 352 |
"path": "pyproject.snapshot.toml",
|
|
@@ -355,8 +355,8 @@
|
|
| 355 |
},
|
| 356 |
{
|
| 357 |
"path": "README.md",
|
| 358 |
-
"bytes":
|
| 359 |
-
"sha256": "
|
| 360 |
},
|
| 361 |
{
|
| 362 |
"path": "android/build.gradle.kts",
|
|
@@ -395,13 +395,13 @@
|
|
| 395 |
},
|
| 396 |
{
|
| 397 |
"path": "android/aiflow-math-ink-runtime/src/main/java/ai/aiflow/mathink/AIFlowMathInk.kt",
|
| 398 |
-
"bytes":
|
| 399 |
-
"sha256": "
|
| 400 |
},
|
| 401 |
{
|
| 402 |
"path": "android/README.md",
|
| 403 |
-
"bytes":
|
| 404 |
-
"sha256": "
|
| 405 |
},
|
| 406 |
{
|
| 407 |
"path": "android/settings.gradle.kts",
|
|
@@ -557,6 +557,16 @@
|
|
| 557 |
"path": "scripts/summarize_math_ink_06_android_benchmarks.py",
|
| 558 |
"bytes": 5572,
|
| 559 |
"sha256": "41d5485edb4dadf738fde70d602e4b04f2a53add11729d401711846d3cada906"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 560 |
}
|
| 561 |
]
|
| 562 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"schema": "aiflow-hf-research-snapshot-v24",
|
| 3 |
+
"generated_at": "2026-07-23T23:05:59.1183762Z",
|
| 4 |
"track": "R_noncommercial_plus_rejected_P_proxy",
|
| 5 |
"product_validation": false,
|
| 6 |
"public_release": true,
|
| 7 |
"contains_raw_dataset": false,
|
| 8 |
+
"tests": "348 Python passed + 8 Android passed",
|
| 9 |
"retracted_paths": [
|
| 10 |
"models/auxiliary/boundary_auxiliary_head.pt",
|
| 11 |
"models/auxiliary/seed17/boundary_joint_delta.pt",
|
|
|
|
| 55 |
},
|
| 56 |
{
|
| 57 |
"path": "scripts/build_math_ink_06_litert_colab_bundle.py",
|
| 58 |
+
"bytes": 8003,
|
| 59 |
+
"sha256": "1fb8f75047adb833a36a9354bf6c1f87629053ecd1b487447642b282c9a1f020"
|
| 60 |
},
|
| 61 |
{
|
| 62 |
"path": "scripts/calibrate_math_ink_06_online_family_fusion.py",
|
|
|
|
| 160 |
},
|
| 161 |
{
|
| 162 |
"path": "src/ink06_export.py",
|
| 163 |
+
"bytes": 8989,
|
| 164 |
+
"sha256": "544f9d3d8a594e006ac7df25498cd85bd391a56ee3a06f650f2a96469a49312d"
|
| 165 |
},
|
| 166 |
{
|
| 167 |
"path": "scripts/train_math_ink_06_boundary_behavior_guard.py",
|
|
|
|
| 215 |
},
|
| 216 |
{
|
| 217 |
"path": "scripts/export_math_ink_06_p_formula_student.py",
|
| 218 |
+
"bytes": 8851,
|
| 219 |
+
"sha256": "94c8a12e935471312120649ba239a7d555dc1bbb718cb7fb8a900c5774dee6c7"
|
| 220 |
},
|
| 221 |
{
|
| 222 |
"path": "scripts/materialize_math_ink_06_p_formula.py",
|
|
|
|
| 240 |
},
|
| 241 |
{
|
| 242 |
"path": "scripts/export_math_ink_06_litert.py",
|
| 243 |
+
"bytes": 11912,
|
| 244 |
+
"sha256": "ab69c74d3d6f19d49962b5d75c364a68881cc597e86221507b4f7948a3ea58e7"
|
| 245 |
},
|
| 246 |
{
|
| 247 |
"path": "scripts/run_math_ink_06_p_formula_release.py",
|
|
|
|
| 285 |
},
|
| 286 |
{
|
| 287 |
"path": "android/aiflow-math-ink-runtime/src/test/java/ai/aiflow/mathink/AIFlowMathInkTest.kt",
|
| 288 |
+
"bytes": 8609,
|
| 289 |
+
"sha256": "c277bbb2ee20b050fc403ce007bd4ab604d63aae616c67596315412126ecb768"
|
| 290 |
},
|
| 291 |
{
|
| 292 |
"path": "artifacts/boundary_behavior_guard.joblib",
|
|
|
|
| 305 |
},
|
| 306 |
{
|
| 307 |
"path": "android/aiflow-math-ink-runtime/src/main/java/ai/aiflow/mathink/CompiledModelLiteRtSession.kt",
|
| 308 |
+
"bytes": 7102,
|
| 309 |
+
"sha256": "f790dc8b79074a9145aa6d541664dae994064c9d5ded54ec808859452ebc7825"
|
| 310 |
},
|
| 311 |
{
|
| 312 |
"path": "android/aiflow-math-ink-runtime/src/main/java/ai/aiflow/mathink/InkContracts.kt",
|
| 313 |
+
"bytes": 5063,
|
| 314 |
+
"sha256": "cd03889545f4ea93eabd75ca86f9b3507f5fbc347f23218cfb47c9ae524adf73"
|
| 315 |
},
|
| 316 |
{
|
| 317 |
"path": "assets/stroke_encoding.svg",
|
|
|
|
| 320 |
},
|
| 321 |
{
|
| 322 |
"path": "colab/aiflow_math_ink_06_litert_bundle.zip.manifest.json",
|
| 323 |
+
"bytes": 3823,
|
| 324 |
+
"sha256": "1afb103d331b37b44ab1fa50927c0a68fcf328a4cd5710c27fe325f9dbebe3b8"
|
| 325 |
},
|
| 326 |
{
|
| 327 |
"path": "configs/MATH-INK-06-P-FORMULA-v1.json",
|
|
|
|
| 340 |
},
|
| 341 |
{
|
| 342 |
"path": "colab/aiflow_math_ink_06_litert_bundle.manifest.json",
|
| 343 |
+
"bytes": 3823,
|
| 344 |
+
"sha256": "1afb103d331b37b44ab1fa50927c0a68fcf328a4cd5710c27fe325f9dbebe3b8"
|
| 345 |
},
|
| 346 |
{
|
| 347 |
"path": "colab/aiflow_math_ink_06_litert_bundle.zip",
|
| 348 |
+
"bytes": 17277868,
|
| 349 |
+
"sha256": "1260829d1ba7215e0b857fa51da503ba67f7ffd65f1ba6fb07abd94e13b497bd"
|
| 350 |
},
|
| 351 |
{
|
| 352 |
"path": "pyproject.snapshot.toml",
|
|
|
|
| 355 |
},
|
| 356 |
{
|
| 357 |
"path": "README.md",
|
| 358 |
+
"bytes": 22965,
|
| 359 |
+
"sha256": "8219ef8e76e6b69cd7ed5294222633301ef0534373880a88183f43611e185b97"
|
| 360 |
},
|
| 361 |
{
|
| 362 |
"path": "android/build.gradle.kts",
|
|
|
|
| 395 |
},
|
| 396 |
{
|
| 397 |
"path": "android/aiflow-math-ink-runtime/src/main/java/ai/aiflow/mathink/AIFlowMathInk.kt",
|
| 398 |
+
"bytes": 6421,
|
| 399 |
+
"sha256": "0ad2d9da85a1bc01bb7881625591217df31755247da56b27d7f9a28dd3ce8ab5"
|
| 400 |
},
|
| 401 |
{
|
| 402 |
"path": "android/README.md",
|
| 403 |
+
"bytes": 4176,
|
| 404 |
+
"sha256": "aede1dd3c30bd1c337e0744ae30bb8d477e5b1ea62a742a82b42be8a9960b019"
|
| 405 |
},
|
| 406 |
{
|
| 407 |
"path": "android/settings.gradle.kts",
|
|
|
|
| 557 |
"path": "scripts/summarize_math_ink_06_android_benchmarks.py",
|
| 558 |
"bytes": 5572,
|
| 559 |
"sha256": "41d5485edb4dadf738fde70d602e4b04f2a53add11729d401711846d3cada906"
|
| 560 |
+
},
|
| 561 |
+
{
|
| 562 |
+
"path": "scripts/package_math_ink_06_mobile_models.py",
|
| 563 |
+
"bytes": 6877,
|
| 564 |
+
"sha256": "5db9b3495977a9316d7b47e666c28d57d019cab0d9e6c2fb1fb10d8928e6b1b3"
|
| 565 |
+
},
|
| 566 |
+
{
|
| 567 |
+
"path": "scripts/build_math_ink_06_product_release_manifest.py",
|
| 568 |
+
"bytes": 6345,
|
| 569 |
+
"sha256": "17e657b607ed6b0b10d8712fd5a9427f3570ea3fd1fe776b42e4868c1332b6a7"
|
| 570 |
}
|
| 571 |
]
|
| 572 |
}
|
README.md
CHANGED
|
@@ -324,7 +324,9 @@ Student export는 `online adapter → formula adapter → shared classifier →
|
|
| 324 |
|
| 325 |
Android canonicalizer는 Python 대표 4행×19채널과 절대오차 `1e-4` 이내 parity를 통과했다. 최신 standalone LiteRT `CompiledModel` 2.1.6으로 APK asset의 online/raster graph를 직접 실행하며 ML Kit custom model API를 사용하지 않는다.
|
| 326 |
|
| 327 |
-
|
|
|
|
|
|
|
| 328 |
|
| 329 |
## 알려진 한계
|
| 330 |
|
|
|
|
| 324 |
|
| 325 |
Android canonicalizer는 Python 대표 4행×19채널과 절대오차 `1e-4` 이내 parity를 통과했다. 최신 standalone LiteRT `CompiledModel` 2.1.6으로 APK asset의 online/raster graph를 직접 실행하며 ML Kit custom model API를 사용하지 않는다.
|
| 326 |
|
| 327 |
+
Raster 배포 graph는 exact logits뿐 아니라 top-4 coordinates/state logits/progress/hypothesis scores를 포함한 정확히 5개 output을 요구한다. `recognizeRasterDebug`는 이 값을 로컬에만 노출하고 server payload에는 포함하지 않는다. 실제 seed-17 378-label·76 representative strict export에서 online/raster top-1 76/76, 최대 오차 0.0과 다섯 output shape를 확인했다.
|
| 328 |
+
|
| 329 |
+
3-tier benchmark runner는 동일 online/raster SHA-256 쌍의 representative로 warm-up 후 각 100회, PSS, battery charge-counter delta를 측정한다. 개별 hash는 순서가 고정된 bundle SHA-256으로도 묶는다. Python 요약기는 low/mid/high가 정확히 하나씩이고 version·개별 hash·bundle hash가 동일할 때만 online p95 50ms, raster p95 200ms, PSS 100MiB, battery 계측 gate를 AND로 결합한다. 최종 product manifest는 P 3-seed, 배포 raster 품질, LiteRT model pair, Android 3-tier의 동일 lineage를 다시 확인한다. Kotlin unit test 8개와 전체 Python 회귀 348개, release AAR build가 통과했다. AAR은 84,436 bytes, SHA-256 `2c65d4af59af1204fad66eff15bec5ef125dfa242da82d05b0e85cf99504c44d`이며 모델을 포함하지 않는다. 실제 flatbuffer·기기 benchmark 값은 아직 없다.
|
| 330 |
|
| 331 |
## 알려진 한계
|
| 332 |
|
android/README.md
CHANGED
|
@@ -16,6 +16,9 @@ val recognizer = AIFlowMathInk(session, labels)
|
|
| 16 |
|
| 17 |
val online: SymbolResult = recognizer.recognizeOnline(strokes, InkCanvas(width, height))
|
| 18 |
val raster: SymbolResult = recognizer.recognizeRaster(bitmap)
|
|
|
|
|
|
|
|
|
|
| 19 |
```
|
| 20 |
|
| 21 |
`SymbolResult.toServerPayload()`에는 다음 값만 들어간다.
|
|
@@ -27,6 +30,8 @@ val raster: SymbolResult = recognizer.recognizeRaster(bitmap)
|
|
| 27 |
|
| 28 |
원본 touch event, canonical tap, raster, virtual stroke는 payload에 들어가지 않는다.
|
| 29 |
|
|
|
|
|
|
|
| 30 |
## 입력 계약
|
| 31 |
|
| 32 |
- 원본 touch event는 `AIFlowInkV2.rawStrokes`에 로컬 보존
|
|
@@ -56,6 +61,7 @@ gradle :aiflow-math-ink-runtime:assembleRelease
|
|
| 56 |
- gate: online p95 ≤50ms, raster p95 ≤200ms, process PSS ≤100MiB
|
| 57 |
- 배터리: charge counter의 측정 가능 여부와 실행 전후 차이를 기록
|
| 58 |
- 결과: `AndroidBenchmarkReport.toMap()`을 앱 계층에서 UTF-8 JSON으로 저장
|
|
|
|
| 59 |
|
| 60 |
세 기기 JSON은 다음 명령으로 합친다.
|
| 61 |
|
|
@@ -67,6 +73,17 @@ python scripts/summarize_math_ink_06_android_benchmarks.py `
|
|
| 67 |
|
| 68 |
요약기는 `low`·`mid`·`high`가 정확히 하나씩 존재하고 세 report의 `model_version`, online/raster 개별 SHA-256, bundle SHA-256이 같은지 확인한다. 모든 metric이 세 기기에서 통과해야 `android_release_gate_passed=true`가 된다. 이 값만으로 제품 승인을 만들 수 없도록 `product_validation=false`는 고정한다.
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
공식 참고:
|
| 71 |
|
| 72 |
- https://developers.google.com/edge/litert/android
|
|
|
|
| 16 |
|
| 17 |
val online: SymbolResult = recognizer.recognizeOnline(strokes, InkCanvas(width, height))
|
| 18 |
val raster: SymbolResult = recognizer.recognizeRaster(bitmap)
|
| 19 |
+
val debug: RasterDebugResult = recognizer.recognizeRasterDebug(
|
| 20 |
+
RasterInput(128, 128, normalizedInk),
|
| 21 |
+
)
|
| 22 |
```
|
| 23 |
|
| 24 |
`SymbolResult.toServerPayload()`에는 다음 값만 들어간다.
|
|
|
|
| 30 |
|
| 31 |
원본 touch event, canonical tap, raster, virtual stroke는 payload에 들어가지 않는다.
|
| 32 |
|
| 33 |
+
`recognizeRasterDebug`는 같은 로컬 inference에서 top-4의 128×2 좌표, 128×3 pen-state logits, progress, hypothesis log-probability를 반환한다. `RasterDebugResult`에는 server payload 변환 함수가 없으며 일반 `SymbolResult.toServerPayload()`에도 이 값은 포함되지 않는다.
|
| 34 |
+
|
| 35 |
## 입력 계약
|
| 36 |
|
| 37 |
- 원본 touch event는 `AIFlowInkV2.rawStrokes`에 로컬 보존
|
|
|
|
| 61 |
- gate: online p95 ≤50ms, raster p95 ≤200ms, process PSS ≤100MiB
|
| 62 |
- 배터리: charge counter의 측정 가능 여부와 실행 전후 차이를 기록
|
| 63 |
- 결과: `AndroidBenchmarkReport.toMap()`을 앱 계층에서 UTF-8 JSON으로 저장
|
| 64 |
+
- raster graph output: exact logits, coordinates, state logits, progress, hypothesis scores의 정확히 5개
|
| 65 |
|
| 66 |
세 기기 JSON은 다음 명령으로 합친다.
|
| 67 |
|
|
|
|
| 73 |
|
| 74 |
요약기는 `low`·`mid`·`high`가 정확히 하나씩 존재하고 세 report의 `model_version`, online/raster 개별 SHA-256, bundle SHA-256이 같은지 확인한다. 모든 metric이 세 기기에서 통과해야 `android_release_gate_passed=true`가 된다. 이 값만으로 제품 승인을 만들 수 없도록 `product_validation=false`는 고정한다.
|
| 75 |
|
| 76 |
+
최종 제품 manifest는 P writer/device-disjoint release, 배포 raster 모델에 귀속된 vectorization 지표, LiteRT parity/model bundle, Android 3-tier summary를 모두 결합한다.
|
| 77 |
+
|
| 78 |
+
```powershell
|
| 79 |
+
python scripts/build_math_ink_06_product_release_manifest.py `
|
| 80 |
+
--p-release release_report.json `
|
| 81 |
+
--raster-validation raster-validation.json `
|
| 82 |
+
--model-bundle mobile-model-bundle.json `
|
| 83 |
+
--android-summary android-3tier-summary.json `
|
| 84 |
+
--output product-release.json
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
공식 참고:
|
| 88 |
|
| 89 |
- https://developers.google.com/edge/litert/android
|
android/aiflow-math-ink-runtime/src/main/java/ai/aiflow/mathink/AIFlowMathInk.kt
CHANGED
|
@@ -49,6 +49,21 @@ class AIFlowMathInk(
|
|
| 49 |
return result(logits, started)
|
| 50 |
}
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
/** 필요 변수: Android Bitmap. 작동 원리: 실제 ink bbox의 종횡비를 보존해 8px 여백의 128×128로 중앙 배치한다. */
|
| 53 |
fun recognizeRaster(bitmap: Bitmap): SymbolResult {
|
| 54 |
val sourcePixels = IntArray(bitmap.width * bitmap.height)
|
|
|
|
| 49 |
return result(logits, started)
|
| 50 |
}
|
| 51 |
|
| 52 |
+
/**
|
| 53 |
+
* 필요 변수: 128×128 raster.
|
| 54 |
+
* 작동 원리: 동일 inference에서 기호와 top-4 가상 stroke를 가져오되 서버 payload 기능은 제공하지 않는다.
|
| 55 |
+
*/
|
| 56 |
+
fun recognizeRasterDebug(raster: RasterInput): RasterDebugResult {
|
| 57 |
+
val started = SystemClock.elapsedRealtimeNanos()
|
| 58 |
+
val inference = inferenceLock.withLock {
|
| 59 |
+
session.runRasterDebug(raster.ink)
|
| 60 |
+
}
|
| 61 |
+
return RasterDebugResult(
|
| 62 |
+
symbol = result(inference.exactLogits, started),
|
| 63 |
+
virtualHypotheses = inference.virtualHypotheses,
|
| 64 |
+
)
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
/** 필요 변수: Android Bitmap. 작동 원리: 실제 ink bbox의 종횡비를 보존해 8px 여백의 128×128로 중앙 배치한다. */
|
| 68 |
fun recognizeRaster(bitmap: Bitmap): SymbolResult {
|
| 69 |
val sourcePixels = IntArray(bitmap.width * bitmap.height)
|
android/aiflow-math-ink-runtime/src/main/java/ai/aiflow/mathink/CompiledModelLiteRtSession.kt
CHANGED
|
@@ -3,6 +3,8 @@ package ai.aiflow.mathink
|
|
| 3 |
import android.content.Context
|
| 4 |
import com.google.ai.edge.litert.Accelerator
|
| 5 |
import com.google.ai.edge.litert.CompiledModel
|
|
|
|
|
|
|
| 6 |
|
| 7 |
/**
|
| 8 |
* 필요 변수: online/raster LiteRT flatbuffer·label 수·가속기.
|
|
@@ -16,7 +18,10 @@ class CompiledModelLiteRtSession private constructor(
|
|
| 16 |
) : LiteRtSession {
|
| 17 |
init {
|
| 18 |
require(exactLabelCount > 0) { "Exact label 수는 양수여야 합니다." }
|
| 19 |
-
require(online.outputCount >= 1
|
|
|
|
|
|
|
|
|
|
| 20 |
}
|
| 21 |
|
| 22 |
/** 필요 변수: 128×19 feature. 작동 원리: online graph의 첫 출력 exact logit을 복사해 반환한다. */
|
|
@@ -27,8 +32,54 @@ class CompiledModelLiteRtSession private constructor(
|
|
| 27 |
|
| 28 |
/** 필요 변수: 128×128 ink. 작동 원리: raster graph의 첫 출력 exact logit을 복사해 반환한다. */
|
| 29 |
override fun runRaster(rasterInk: FloatArray): FloatArray {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
require(rasterInk.size == 128 * 128) { "Raster input은 128×128이어야 합니다." }
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
}
|
| 33 |
|
| 34 |
/** 필요 변수: 두 CompiledModel. 작동 원리: tensor buffer와 native graph를 모두 명시적으로 해제한다. */
|
|
@@ -95,6 +146,14 @@ class CompiledModelLiteRtSession private constructor(
|
|
| 95 |
return exact
|
| 96 |
}
|
| 97 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
/** 필요 변수: 모델 buffer. 작동 원리: buffer를 먼저 닫고 native model을 마지막에 해제한다. */
|
| 99 |
override fun close() {
|
| 100 |
inputs.forEach { it.close() }
|
|
|
|
| 3 |
import android.content.Context
|
| 4 |
import com.google.ai.edge.litert.Accelerator
|
| 5 |
import com.google.ai.edge.litert.CompiledModel
|
| 6 |
+
import kotlin.math.exp
|
| 7 |
+
import kotlin.math.ln
|
| 8 |
|
| 9 |
/**
|
| 10 |
* 필요 변수: online/raster LiteRT flatbuffer·label 수·가속기.
|
|
|
|
| 18 |
) : LiteRtSession {
|
| 19 |
init {
|
| 20 |
require(exactLabelCount > 0) { "Exact label 수는 양수여야 합니다." }
|
| 21 |
+
require(online.outputCount >= 1) { "Online graph에 exact logit output이 없습니다." }
|
| 22 |
+
require(raster.outputCount == 5) {
|
| 23 |
+
"Raster graph는 exact·coordinates·states·progress·scores 5개 output이어야 합니다."
|
| 24 |
+
}
|
| 25 |
}
|
| 26 |
|
| 27 |
/** 필요 변수: 128×19 feature. 작동 원리: online graph의 첫 출력 exact logit을 복사해 반환한다. */
|
|
|
|
| 32 |
|
| 33 |
/** 필요 변수: 128×128 ink. 작동 원리: raster graph의 첫 출력 exact logit을 복사해 반환한다. */
|
| 34 |
override fun runRaster(rasterInk: FloatArray): FloatArray {
|
| 35 |
+
return runRasterDebug(rasterInk).exactLogits
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/**
|
| 39 |
+
* 필요 변수: 128×128 ink.
|
| 40 |
+
* 작동 원리: 배포 graph의 다섯 출력을 검증하고 top-4 가상 stroke로 분리한다.
|
| 41 |
+
*/
|
| 42 |
+
override fun runRasterDebug(rasterInk: FloatArray): RasterInference {
|
| 43 |
require(rasterInk.size == 128 * 128) { "Raster input은 128×128이어야 합니다." }
|
| 44 |
+
val outputs = raster.runAll(rasterInk)
|
| 45 |
+
require(outputs[0].size == exactLabelCount) {
|
| 46 |
+
"Raster exact output ${outputs[0].size}가 label $exactLabelCount 개와 다릅니다."
|
| 47 |
+
}
|
| 48 |
+
require(outputs[1].size == 4 * 128 * 2) { "Raster coordinates output shape가 다릅니다." }
|
| 49 |
+
require(outputs[2].size == 4 * 128 * 3) { "Raster states output shape가 다릅니다." }
|
| 50 |
+
require(outputs[3].size == 4 * 128) { "Raster progress output shape가 다릅니다." }
|
| 51 |
+
require(outputs[4].size == 4) { "Raster hypothesis score output shape가 다릅니다." }
|
| 52 |
+
val logProbabilities = logSoftmax(outputs[4])
|
| 53 |
+
return RasterInference(
|
| 54 |
+
exactLogits = outputs[0],
|
| 55 |
+
virtualHypotheses = List(4) { hypothesis ->
|
| 56 |
+
VirtualStrokeHypothesis(
|
| 57 |
+
points = outputs[1].copyOfRange(
|
| 58 |
+
hypothesis * 128 * 2,
|
| 59 |
+
(hypothesis + 1) * 128 * 2,
|
| 60 |
+
),
|
| 61 |
+
stateLogits = outputs[2].copyOfRange(
|
| 62 |
+
hypothesis * 128 * 3,
|
| 63 |
+
(hypothesis + 1) * 128 * 3,
|
| 64 |
+
),
|
| 65 |
+
progress = outputs[3].copyOfRange(
|
| 66 |
+
hypothesis * 128,
|
| 67 |
+
(hypothesis + 1) * 128,
|
| 68 |
+
),
|
| 69 |
+
logProbability = logProbabilities[hypothesis],
|
| 70 |
+
)
|
| 71 |
+
},
|
| 72 |
+
)
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
/** 필요 변수: 네 개 score logit. 작동 원리: overflow 없이 정규화된 log probability를 계산한다. */
|
| 76 |
+
private fun logSoftmax(values: FloatArray): FloatArray {
|
| 77 |
+
require(values.isNotEmpty() && values.all(Float::isFinite))
|
| 78 |
+
val maximum = values.max()
|
| 79 |
+
val logDenominator = maximum + ln(
|
| 80 |
+
values.sumOf { exp((it - maximum).toDouble()) },
|
| 81 |
+
).toFloat()
|
| 82 |
+
return FloatArray(values.size) { index -> values[index] - logDenominator }
|
| 83 |
}
|
| 84 |
|
| 85 |
/** 필요 변수: 두 CompiledModel. 작동 원리: tensor buffer와 native graph를 모두 명시적으로 해제한다. */
|
|
|
|
| 146 |
return exact
|
| 147 |
}
|
| 148 |
|
| 149 |
+
/** 필요 변수: flat input. 작동 원리: 모든 graph output을 한 번 실행한 같은 snapshot에서 복사한다. */
|
| 150 |
+
fun runAll(input: FloatArray): List<FloatArray> {
|
| 151 |
+
require(inputs.size == 1) { "AIFlow graph는 단일 input이어야 합니다." }
|
| 152 |
+
inputs[0].writeFloat(input)
|
| 153 |
+
model.run(inputs, outputs)
|
| 154 |
+
return outputs.map { it.readFloat() }
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
/** 필요 변수: 모델 buffer. 작동 원리: buffer를 먼저 닫고 native model을 마지막에 해제한다. */
|
| 158 |
override fun close() {
|
| 159 |
inputs.forEach { it.close() }
|
android/aiflow-math-ink-runtime/src/main/java/ai/aiflow/mathink/InkContracts.kt
CHANGED
|
@@ -39,7 +39,7 @@ data class AIFlowInkV2(
|
|
| 39 |
val features: FloatArray,
|
| 40 |
val timestampMode: TimestampMode,
|
| 41 |
val sourceModality: SourceModality,
|
| 42 |
-
val virtualHypotheses: List<
|
| 43 |
)
|
| 44 |
|
| 45 |
/** 필요 변수: 128×128 grayscale ink. 작동 원리: Bitmap 종속 없이 raster runtime을 시험 가능하게 한다. */
|
|
@@ -59,6 +59,38 @@ data class RasterInput(
|
|
| 59 |
|
| 60 |
data class SymbolCandidate(val token: String, val probability: Float)
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
/** 필요 변수: top-k·confidence·버전·지연. 작동 원리: 이미지와 stroke 없이 공개 결과만 반환한다. */
|
| 63 |
data class SymbolResult(
|
| 64 |
val candidates: List<SymbolCandidate>,
|
|
@@ -87,4 +119,10 @@ interface LiteRtSession : AutoCloseable {
|
|
| 87 |
|
| 88 |
fun runOnline(features: FloatArray): FloatArray
|
| 89 |
fun runRaster(rasterInk: FloatArray): FloatArray
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
}
|
|
|
|
| 39 |
val features: FloatArray,
|
| 40 |
val timestampMode: TimestampMode,
|
| 41 |
val sourceModality: SourceModality,
|
| 42 |
+
val virtualHypotheses: List<VirtualStrokeHypothesis> = emptyList(),
|
| 43 |
)
|
| 44 |
|
| 45 |
/** 필요 변수: 128×128 grayscale ink. 작동 원리: Bitmap 종속 없이 raster runtime을 시험 가능하게 한다. */
|
|
|
|
| 59 |
|
| 60 |
data class SymbolCandidate(val token: String, val probability: Float)
|
| 61 |
|
| 62 |
+
/** 필요 변수: 128개 좌표·state logit·progress·가설 log probability. 작동 원리: raster 필순 가설을 로컬 debug에서만 보존한다. */
|
| 63 |
+
data class VirtualStrokeHypothesis(
|
| 64 |
+
val points: FloatArray,
|
| 65 |
+
val stateLogits: FloatArray,
|
| 66 |
+
val progress: FloatArray,
|
| 67 |
+
val logProbability: Float,
|
| 68 |
+
) {
|
| 69 |
+
init {
|
| 70 |
+
require(points.size == 128 * 2) { "가상 stroke 좌표는 128×2여야 합니다." }
|
| 71 |
+
require(stateLogits.size == 128 * 3) { "가상 stroke state는 128×3이어야 합니다." }
|
| 72 |
+
require(progress.size == 128) { "가상 stroke progress는 128개여야 합니다." }
|
| 73 |
+
require(
|
| 74 |
+
points.all(Float::isFinite)
|
| 75 |
+
&& stateLogits.all(Float::isFinite)
|
| 76 |
+
&& progress.all(Float::isFinite)
|
| 77 |
+
&& logProbability.isFinite()
|
| 78 |
+
) { "가상 stroke 출력은 모두 유한해야 합니다." }
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
/** 필요 변수: raster exact logits·top-4 trajectory. 작동 원리: 일반 인식과 로컬 debug가 같은 inference 결과를 공유한다. */
|
| 83 |
+
data class RasterInference(
|
| 84 |
+
val exactLogits: FloatArray,
|
| 85 |
+
val virtualHypotheses: List<VirtualStrokeHypothesis>,
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
/** 필요 변수: 일반 SymbolResult·가상 stroke. 작동 원리: 서버 payload 기능 없이 로컬 검증 결과만 노출한다. */
|
| 89 |
+
data class RasterDebugResult(
|
| 90 |
+
val symbol: SymbolResult,
|
| 91 |
+
val virtualHypotheses: List<VirtualStrokeHypothesis>,
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
/** 필요 변수: top-k·confidence·버전·지연. 작동 원리: 이미지와 stroke 없이 공개 결과만 반환한다. */
|
| 95 |
data class SymbolResult(
|
| 96 |
val candidates: List<SymbolCandidate>,
|
|
|
|
| 119 |
|
| 120 |
fun runOnline(features: FloatArray): FloatArray
|
| 121 |
fun runRaster(rasterInk: FloatArray): FloatArray
|
| 122 |
+
|
| 123 |
+
/** 필요 변수: raster ink. 작동 원리: 기존 test session은 logits만, 배포 session은 top-4를 함께 반환한다. */
|
| 124 |
+
fun runRasterDebug(rasterInk: FloatArray): RasterInference = RasterInference(
|
| 125 |
+
exactLogits = runRaster(rasterInk),
|
| 126 |
+
virtualHypotheses = emptyList(),
|
| 127 |
+
)
|
| 128 |
}
|
android/aiflow-math-ink-runtime/src/test/java/ai/aiflow/mathink/AIFlowMathInkTest.kt
CHANGED
|
@@ -25,6 +25,22 @@ class AIFlowMathInkTest {
|
|
| 25 |
return floatArrayOf(2.0f, 0.0f, 1.0f)
|
| 26 |
}
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
override fun close() = Unit
|
| 29 |
}
|
| 30 |
|
|
@@ -131,6 +147,20 @@ class AIFlowMathInkTest {
|
|
| 131 |
assertEquals(1, session.rasterCalls)
|
| 132 |
}
|
| 133 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
@Test
|
| 135 |
fun androidBenchmarkUsesNearestRankAndAllReleaseChecks() {
|
| 136 |
class Probe : AndroidBenchmarkProbe {
|
|
|
|
| 25 |
return floatArrayOf(2.0f, 0.0f, 1.0f)
|
| 26 |
}
|
| 27 |
|
| 28 |
+
override fun runRasterDebug(rasterInk: FloatArray): RasterInference {
|
| 29 |
+
assertEquals(128 * 128, rasterInk.size)
|
| 30 |
+
rasterCalls += 1
|
| 31 |
+
return RasterInference(
|
| 32 |
+
exactLogits = floatArrayOf(2.0f, 0.0f, 1.0f),
|
| 33 |
+
virtualHypotheses = List(4) { hypothesis ->
|
| 34 |
+
VirtualStrokeHypothesis(
|
| 35 |
+
points = FloatArray(128 * 2) { hypothesis.toFloat() },
|
| 36 |
+
stateLogits = FloatArray(128 * 3),
|
| 37 |
+
progress = FloatArray(128) { it / 127.0f },
|
| 38 |
+
logProbability = -hypothesis.toFloat(),
|
| 39 |
+
)
|
| 40 |
+
},
|
| 41 |
+
)
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
override fun close() = Unit
|
| 45 |
}
|
| 46 |
|
|
|
|
| 147 |
assertEquals(1, session.rasterCalls)
|
| 148 |
}
|
| 149 |
|
| 150 |
+
@Test
|
| 151 |
+
fun rasterDebugReturnsFourLocalHypothesesWithoutServerPayload() {
|
| 152 |
+
val session = FakeSession()
|
| 153 |
+
val runtime = AIFlowMathInk(session, listOf("0", "x", "+"), topK = 2)
|
| 154 |
+
val debug = runtime.recognizeRasterDebug(
|
| 155 |
+
RasterInput(128, 128, FloatArray(128 * 128)),
|
| 156 |
+
)
|
| 157 |
+
assertEquals("0", debug.symbol.candidates.first().token)
|
| 158 |
+
assertEquals(4, debug.virtualHypotheses.size)
|
| 159 |
+
assertEquals(128 * 2, debug.virtualHypotheses.first().points.size)
|
| 160 |
+
assertEquals(1, session.rasterCalls)
|
| 161 |
+
assertFalse(debug.symbol.toServerPayload().containsKey("virtualHypotheses"))
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
@Test
|
| 165 |
fun androidBenchmarkUsesNearestRankAndAllReleaseChecks() {
|
| 166 |
class Probe : AndroidBenchmarkProbe {
|
colab/aiflow_math_ink_06_litert_bundle.manifest.json
CHANGED
|
@@ -1,9 +1,59 @@
|
|
| 1 |
{
|
| 2 |
"schema": "aiflow-math-ink-06-litert-colab-bundle-v1",
|
| 3 |
-
"generated_at": "2026-07-
|
| 4 |
"seed": 17,
|
| 5 |
"litert_torch_version": "0.9.1",
|
| 6 |
"representative_samples": 76,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
"files": [
|
| 8 |
{
|
| 9 |
"path": "pyproject.toml",
|
|
@@ -27,13 +77,13 @@
|
|
| 27 |
},
|
| 28 |
{
|
| 29 |
"path": "src/math_grid_drawer/research/ink06_export.py",
|
| 30 |
-
"bytes":
|
| 31 |
-
"sha256": "
|
| 32 |
},
|
| 33 |
{
|
| 34 |
"path": "src/math_grid_drawer/research/math_ink_06.py",
|
| 35 |
-
"bytes":
|
| 36 |
-
"sha256": "
|
| 37 |
},
|
| 38 |
{
|
| 39 |
"path": "src/math_grid_drawer/research/raster_skeleton06.py",
|
|
@@ -52,8 +102,8 @@
|
|
| 52 |
},
|
| 53 |
{
|
| 54 |
"path": "scripts/export_math_ink_06_litert.py",
|
| 55 |
-
"bytes":
|
| 56 |
-
"sha256": "
|
| 57 |
},
|
| 58 |
{
|
| 59 |
"path": "artifacts/base_378.pt",
|
|
@@ -72,9 +122,9 @@
|
|
| 72 |
}
|
| 73 |
],
|
| 74 |
"product_validation": false,
|
| 75 |
-
"bundle": "research\\runs\\
|
| 76 |
-
"bundle_bytes":
|
| 77 |
-
"bundle_sha256": "
|
| 78 |
"verification": {
|
| 79 |
"schema": "aiflow-math-ink-06-litert-colab-bundle-v1",
|
| 80 |
"files": 13,
|
|
|
|
| 1 |
{
|
| 2 |
"schema": "aiflow-math-ink-06-litert-colab-bundle-v1",
|
| 3 |
+
"generated_at": "2026-07-23T23:00:04.923577+00:00",
|
| 4 |
"seed": 17,
|
| 5 |
"litert_torch_version": "0.9.1",
|
| 6 |
"representative_samples": 76,
|
| 7 |
+
"raster_output_contract": {
|
| 8 |
+
"outputs": [
|
| 9 |
+
{
|
| 10 |
+
"index": 0,
|
| 11 |
+
"name": "exact_logits",
|
| 12 |
+
"shape": [
|
| 13 |
+
1,
|
| 14 |
+
378
|
| 15 |
+
]
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
"index": 1,
|
| 19 |
+
"name": "coordinates",
|
| 20 |
+
"shape": [
|
| 21 |
+
1,
|
| 22 |
+
4,
|
| 23 |
+
128,
|
| 24 |
+
2
|
| 25 |
+
]
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
"index": 2,
|
| 29 |
+
"name": "state_logits",
|
| 30 |
+
"shape": [
|
| 31 |
+
1,
|
| 32 |
+
4,
|
| 33 |
+
128,
|
| 34 |
+
3
|
| 35 |
+
]
|
| 36 |
+
},
|
| 37 |
+
{
|
| 38 |
+
"index": 3,
|
| 39 |
+
"name": "progress",
|
| 40 |
+
"shape": [
|
| 41 |
+
1,
|
| 42 |
+
4,
|
| 43 |
+
128
|
| 44 |
+
]
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
"index": 4,
|
| 48 |
+
"name": "hypothesis_scores",
|
| 49 |
+
"shape": [
|
| 50 |
+
1,
|
| 51 |
+
4
|
| 52 |
+
]
|
| 53 |
+
}
|
| 54 |
+
],
|
| 55 |
+
"direct_raster_label_shortcut": false
|
| 56 |
+
},
|
| 57 |
"files": [
|
| 58 |
{
|
| 59 |
"path": "pyproject.toml",
|
|
|
|
| 77 |
},
|
| 78 |
{
|
| 79 |
"path": "src/math_grid_drawer/research/ink06_export.py",
|
| 80 |
+
"bytes": 8989,
|
| 81 |
+
"sha256": "544f9d3d8a594e006ac7df25498cd85bd391a56ee3a06f650f2a96469a49312d"
|
| 82 |
},
|
| 83 |
{
|
| 84 |
"path": "src/math_grid_drawer/research/math_ink_06.py",
|
| 85 |
+
"bytes": 59569,
|
| 86 |
+
"sha256": "1972ad82e7694e5c3c64776ef90568473bfdaf40b7ab5c46c973e482b98e48ad"
|
| 87 |
},
|
| 88 |
{
|
| 89 |
"path": "src/math_grid_drawer/research/raster_skeleton06.py",
|
|
|
|
| 102 |
},
|
| 103 |
{
|
| 104 |
"path": "scripts/export_math_ink_06_litert.py",
|
| 105 |
+
"bytes": 11239,
|
| 106 |
+
"sha256": "d40a8464491721712d712d5476fa2cb26f3d9886a8f05e9ce46f3c281ec14e50"
|
| 107 |
},
|
| 108 |
{
|
| 109 |
"path": "artifacts/base_378.pt",
|
|
|
|
| 122 |
}
|
| 123 |
],
|
| 124 |
"product_validation": false,
|
| 125 |
+
"bundle": "research\\runs\\math_ink_06_litert_colab_debug5_20260724\\aiflow_math_ink_06_litert_bundle.zip",
|
| 126 |
+
"bundle_bytes": 17277868,
|
| 127 |
+
"bundle_sha256": "1260829d1ba7215e0b857fa51da503ba67f7ffd65f1ba6fb07abd94e13b497bd",
|
| 128 |
"verification": {
|
| 129 |
"schema": "aiflow-math-ink-06-litert-colab-bundle-v1",
|
| 130 |
"files": 13,
|
colab/aiflow_math_ink_06_litert_bundle.zip
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1260829d1ba7215e0b857fa51da503ba67f7ffd65f1ba6fb07abd94e13b497bd
|
| 3 |
+
size 17277868
|
colab/aiflow_math_ink_06_litert_bundle.zip.manifest.json
CHANGED
|
@@ -1,9 +1,59 @@
|
|
| 1 |
{
|
| 2 |
"schema": "aiflow-math-ink-06-litert-colab-bundle-v1",
|
| 3 |
-
"generated_at": "2026-07-
|
| 4 |
"seed": 17,
|
| 5 |
"litert_torch_version": "0.9.1",
|
| 6 |
"representative_samples": 76,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
"files": [
|
| 8 |
{
|
| 9 |
"path": "pyproject.toml",
|
|
@@ -27,8 +77,8 @@
|
|
| 27 |
},
|
| 28 |
{
|
| 29 |
"path": "src/math_grid_drawer/research/ink06_export.py",
|
| 30 |
-
"bytes":
|
| 31 |
-
"sha256": "
|
| 32 |
},
|
| 33 |
{
|
| 34 |
"path": "src/math_grid_drawer/research/math_ink_06.py",
|
|
@@ -52,8 +102,8 @@
|
|
| 52 |
},
|
| 53 |
{
|
| 54 |
"path": "scripts/export_math_ink_06_litert.py",
|
| 55 |
-
"bytes":
|
| 56 |
-
"sha256": "
|
| 57 |
},
|
| 58 |
{
|
| 59 |
"path": "artifacts/base_378.pt",
|
|
@@ -72,9 +122,9 @@
|
|
| 72 |
}
|
| 73 |
],
|
| 74 |
"product_validation": false,
|
| 75 |
-
"bundle": "research\\runs\\
|
| 76 |
-
"bundle_bytes":
|
| 77 |
-
"bundle_sha256": "
|
| 78 |
"verification": {
|
| 79 |
"schema": "aiflow-math-ink-06-litert-colab-bundle-v1",
|
| 80 |
"files": 13,
|
|
|
|
| 1 |
{
|
| 2 |
"schema": "aiflow-math-ink-06-litert-colab-bundle-v1",
|
| 3 |
+
"generated_at": "2026-07-23T23:00:04.923577+00:00",
|
| 4 |
"seed": 17,
|
| 5 |
"litert_torch_version": "0.9.1",
|
| 6 |
"representative_samples": 76,
|
| 7 |
+
"raster_output_contract": {
|
| 8 |
+
"outputs": [
|
| 9 |
+
{
|
| 10 |
+
"index": 0,
|
| 11 |
+
"name": "exact_logits",
|
| 12 |
+
"shape": [
|
| 13 |
+
1,
|
| 14 |
+
378
|
| 15 |
+
]
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
"index": 1,
|
| 19 |
+
"name": "coordinates",
|
| 20 |
+
"shape": [
|
| 21 |
+
1,
|
| 22 |
+
4,
|
| 23 |
+
128,
|
| 24 |
+
2
|
| 25 |
+
]
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
"index": 2,
|
| 29 |
+
"name": "state_logits",
|
| 30 |
+
"shape": [
|
| 31 |
+
1,
|
| 32 |
+
4,
|
| 33 |
+
128,
|
| 34 |
+
3
|
| 35 |
+
]
|
| 36 |
+
},
|
| 37 |
+
{
|
| 38 |
+
"index": 3,
|
| 39 |
+
"name": "progress",
|
| 40 |
+
"shape": [
|
| 41 |
+
1,
|
| 42 |
+
4,
|
| 43 |
+
128
|
| 44 |
+
]
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
"index": 4,
|
| 48 |
+
"name": "hypothesis_scores",
|
| 49 |
+
"shape": [
|
| 50 |
+
1,
|
| 51 |
+
4
|
| 52 |
+
]
|
| 53 |
+
}
|
| 54 |
+
],
|
| 55 |
+
"direct_raster_label_shortcut": false
|
| 56 |
+
},
|
| 57 |
"files": [
|
| 58 |
{
|
| 59 |
"path": "pyproject.toml",
|
|
|
|
| 77 |
},
|
| 78 |
{
|
| 79 |
"path": "src/math_grid_drawer/research/ink06_export.py",
|
| 80 |
+
"bytes": 8989,
|
| 81 |
+
"sha256": "544f9d3d8a594e006ac7df25498cd85bd391a56ee3a06f650f2a96469a49312d"
|
| 82 |
},
|
| 83 |
{
|
| 84 |
"path": "src/math_grid_drawer/research/math_ink_06.py",
|
|
|
|
| 102 |
},
|
| 103 |
{
|
| 104 |
"path": "scripts/export_math_ink_06_litert.py",
|
| 105 |
+
"bytes": 11239,
|
| 106 |
+
"sha256": "d40a8464491721712d712d5476fa2cb26f3d9886a8f05e9ce46f3c281ec14e50"
|
| 107 |
},
|
| 108 |
{
|
| 109 |
"path": "artifacts/base_378.pt",
|
|
|
|
| 122 |
}
|
| 123 |
],
|
| 124 |
"product_validation": false,
|
| 125 |
+
"bundle": "research\\runs\\math_ink_06_litert_colab_debug5_20260724\\aiflow_math_ink_06_litert_bundle.zip",
|
| 126 |
+
"bundle_bytes": 17277868,
|
| 127 |
+
"bundle_sha256": "1260829d1ba7215e0b857fa51da503ba67f7ffd65f1ba6fb07abd94e13b497bd",
|
| 128 |
"verification": {
|
| 129 |
"schema": "aiflow-math-ink-06-litert-colab-bundle-v1",
|
| 130 |
"files": 13,
|
scripts/build_math_ink_06_litert_colab_bundle.py
CHANGED
|
@@ -68,6 +68,16 @@ def build_litert_colab_bundle06(
|
|
| 68 |
"seed": 17,
|
| 69 |
"litert_torch_version": "0.9.1",
|
| 70 |
"representative_samples": 76,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
"files": entries,
|
| 72 |
"product_validation": False,
|
| 73 |
}
|
|
@@ -99,6 +109,21 @@ def verify_litert_colab_bundle06(bundle_path: Path) -> dict:
|
|
| 99 |
failures = []
|
| 100 |
with ZipFile(bundle_path) as bundle:
|
| 101 |
manifest = json.loads(bundle.read(MANIFEST_NAME_06).decode("utf-8"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
names = set(bundle.namelist())
|
| 103 |
for row in manifest["files"]:
|
| 104 |
name = str(row["path"])
|
|
|
|
| 68 |
"seed": 17,
|
| 69 |
"litert_torch_version": "0.9.1",
|
| 70 |
"representative_samples": 76,
|
| 71 |
+
"raster_output_contract": {
|
| 72 |
+
"outputs": [
|
| 73 |
+
{"index": 0, "name": "exact_logits", "shape": [1, 378]},
|
| 74 |
+
{"index": 1, "name": "coordinates", "shape": [1, 4, 128, 2]},
|
| 75 |
+
{"index": 2, "name": "state_logits", "shape": [1, 4, 128, 3]},
|
| 76 |
+
{"index": 3, "name": "progress", "shape": [1, 4, 128]},
|
| 77 |
+
{"index": 4, "name": "hypothesis_scores", "shape": [1, 4]},
|
| 78 |
+
],
|
| 79 |
+
"direct_raster_label_shortcut": False,
|
| 80 |
+
},
|
| 81 |
"files": entries,
|
| 82 |
"product_validation": False,
|
| 83 |
}
|
|
|
|
| 109 |
failures = []
|
| 110 |
with ZipFile(bundle_path) as bundle:
|
| 111 |
manifest = json.loads(bundle.read(MANIFEST_NAME_06).decode("utf-8"))
|
| 112 |
+
contract = manifest.get("raster_output_contract") or {}
|
| 113 |
+
output_names = [
|
| 114 |
+
str(row.get("name") or "")
|
| 115 |
+
for row in contract.get("outputs", [])
|
| 116 |
+
]
|
| 117 |
+
if output_names != [
|
| 118 |
+
"exact_logits",
|
| 119 |
+
"coordinates",
|
| 120 |
+
"state_logits",
|
| 121 |
+
"progress",
|
| 122 |
+
"hypothesis_scores",
|
| 123 |
+
]:
|
| 124 |
+
failures.append({"path": MANIFEST_NAME_06, "reason": "raster_output_contract"})
|
| 125 |
+
if contract.get("direct_raster_label_shortcut") is not False:
|
| 126 |
+
failures.append({"path": MANIFEST_NAME_06, "reason": "raster_shortcut_contract"})
|
| 127 |
names = set(bundle.namelist())
|
| 128 |
for row in manifest["files"]:
|
| 129 |
name = str(row["path"])
|
scripts/build_math_ink_06_product_release_manifest.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""P 성능·raster 품질·LiteRT 모델 쌍·Android 3-tier를 최종 제품 gate로 결합한다."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
from datetime import datetime, timezone
|
| 7 |
+
import json
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
import re
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def build_product_release_manifest06(
|
| 14 |
+
*,
|
| 15 |
+
p_release: dict[str, Any],
|
| 16 |
+
raster_validation: dict[str, Any],
|
| 17 |
+
model_bundle: dict[str, Any],
|
| 18 |
+
android_summary: dict[str, Any],
|
| 19 |
+
) -> dict[str, Any]:
|
| 20 |
+
"""필요 변수: 네 독립 release 증거. 작동 원리: 동일 data/model lineage와 모든 hard gate를 AND로 결합한다."""
|
| 21 |
+
|
| 22 |
+
if p_release.get("schema") != "aiflow-math-ink-06-p-formula-release-plan-v1":
|
| 23 |
+
raise ValueError("지원하지 않는 P release report입니다.")
|
| 24 |
+
if (
|
| 25 |
+
p_release.get("completed") is not True
|
| 26 |
+
or p_release.get("torch_export_gate_passed") is not True
|
| 27 |
+
):
|
| 28 |
+
raise ValueError("P writer/device-disjoint 3-seed release가 완료되지 않았습니다.")
|
| 29 |
+
if raster_validation.get("schema") != "aiflow-math-ink-06-raster-release-validation-v1":
|
| 30 |
+
raise ValueError("지원하지 않는 raster validation report입니다.")
|
| 31 |
+
if set(int(seed) for seed in raster_validation.get("seeds", [])) != {17, 31, 47}:
|
| 32 |
+
raise ValueError("Raster validation은 seed 17·31·47을 모두 포함해야 합니다.")
|
| 33 |
+
metrics = raster_validation.get("metrics") or {}
|
| 34 |
+
metric_checks = {
|
| 35 |
+
"downstream_label_preservation": float(
|
| 36 |
+
metrics.get("downstream_label_preservation", 0.0),
|
| 37 |
+
) >= 0.90,
|
| 38 |
+
"skeleton_dice": float(metrics.get("skeleton_dice", 0.0)) >= 0.90,
|
| 39 |
+
"chamfer": float(metrics.get("chamfer_px", float("inf"))) <= 1.50,
|
| 40 |
+
}
|
| 41 |
+
if raster_validation.get("gate_passed") is not True or not all(metric_checks.values()):
|
| 42 |
+
raise ValueError("Raster vectorization release gate가 통과하지 않았습니다.")
|
| 43 |
+
if model_bundle.get("schema") != "aiflow-math-ink-06-mobile-model-bundle-v1":
|
| 44 |
+
raise ValueError("지원하지 않는 mobile model bundle입니다.")
|
| 45 |
+
if model_bundle.get("package_gate_passed") is not True:
|
| 46 |
+
raise ValueError("Mobile model bundle gate가 통과하지 않았습니다.")
|
| 47 |
+
if android_summary.get("schema") != "aiflow-math-ink-06-android-3tier-summary-v1":
|
| 48 |
+
raise ValueError("지원하지 않는 Android summary입니다.")
|
| 49 |
+
if (
|
| 50 |
+
android_summary.get("android_hardware_validation") is not True
|
| 51 |
+
or android_summary.get("android_release_gate_passed") is not True
|
| 52 |
+
):
|
| 53 |
+
raise ValueError("Android low/mid/high gate가 통과하지 않았습니다.")
|
| 54 |
+
for evidence in (p_release, raster_validation, model_bundle, android_summary):
|
| 55 |
+
if evidence.get("product_validation") is not False:
|
| 56 |
+
raise ValueError("하위 증거가 product_validation을 직접 선언할 수 없습니다.")
|
| 57 |
+
data_hashes = {
|
| 58 |
+
str(p_release.get("data_sha256") or ""),
|
| 59 |
+
str(model_bundle.get("data_sha256") or ""),
|
| 60 |
+
}
|
| 61 |
+
if (
|
| 62 |
+
len(data_hashes) != 1
|
| 63 |
+
or re.fullmatch(r"[0-9a-f]{64}", next(iter(data_hashes))) is None
|
| 64 |
+
):
|
| 65 |
+
raise ValueError("P release와 mobile bundle의 data SHA-256이 같아야 합니다.")
|
| 66 |
+
artifacts = model_bundle.get("artifacts") or {}
|
| 67 |
+
online_hash = str((artifacts.get("online") or {}).get("sha256") or "")
|
| 68 |
+
raster_hash = str((artifacts.get("raster") or {}).get("sha256") or "")
|
| 69 |
+
bundle_hash = str(model_bundle.get("model_bundle_sha256") or "")
|
| 70 |
+
if raster_validation.get("raster_model_sha256") != raster_hash:
|
| 71 |
+
raise ValueError("Raster validation이 실제 배포 raster model과 다릅니다.")
|
| 72 |
+
if (
|
| 73 |
+
android_summary.get("online_model_sha256") != online_hash
|
| 74 |
+
or android_summary.get("raster_model_sha256") != raster_hash
|
| 75 |
+
or android_summary.get("model_bundle_sha256") != bundle_hash
|
| 76 |
+
):
|
| 77 |
+
raise ValueError("Android 측정 모델과 배포 bundle이 다릅니다.")
|
| 78 |
+
if android_summary.get("model_version") != model_bundle.get("model_version"):
|
| 79 |
+
raise ValueError("Android 측정 model version과 bundle version이 다릅니다.")
|
| 80 |
+
return {
|
| 81 |
+
"schema": "aiflow-math-ink-06-product-release-v1",
|
| 82 |
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
| 83 |
+
"model_version": model_bundle["model_version"],
|
| 84 |
+
"data_sha256": next(iter(data_hashes)),
|
| 85 |
+
"online_model_sha256": online_hash,
|
| 86 |
+
"raster_model_sha256": raster_hash,
|
| 87 |
+
"model_bundle_sha256": bundle_hash,
|
| 88 |
+
"checks": {
|
| 89 |
+
"p_writer_device_disjoint": True,
|
| 90 |
+
"raster_downstream_label_preservation": metric_checks[
|
| 91 |
+
"downstream_label_preservation"
|
| 92 |
+
],
|
| 93 |
+
"raster_skeleton_dice": metric_checks["skeleton_dice"],
|
| 94 |
+
"raster_chamfer": metric_checks["chamfer"],
|
| 95 |
+
"litert_parity_and_size": True,
|
| 96 |
+
"android_low_mid_high": True,
|
| 97 |
+
"same_data_and_model_lineage": True,
|
| 98 |
+
},
|
| 99 |
+
"product_validation": True,
|
| 100 |
+
"release_gate_passed": True,
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def main() -> None:
|
| 105 |
+
"""필요 변수: 네 UTF-8 JSON·출력. 작동 원리: 최종 release manifest를 원자적으로 기록한다."""
|
| 106 |
+
|
| 107 |
+
parser = argparse.ArgumentParser(description="Build Math Ink 0.6 product release")
|
| 108 |
+
parser.add_argument("--p-release", type=Path, required=True)
|
| 109 |
+
parser.add_argument("--raster-validation", type=Path, required=True)
|
| 110 |
+
parser.add_argument("--model-bundle", type=Path, required=True)
|
| 111 |
+
parser.add_argument("--android-summary", type=Path, required=True)
|
| 112 |
+
parser.add_argument("--output", type=Path, required=True)
|
| 113 |
+
args = parser.parse_args()
|
| 114 |
+
load = lambda path: json.loads(path.read_text(encoding="utf-8"))
|
| 115 |
+
result = build_product_release_manifest06(
|
| 116 |
+
p_release=load(args.p_release),
|
| 117 |
+
raster_validation=load(args.raster_validation),
|
| 118 |
+
model_bundle=load(args.model_bundle),
|
| 119 |
+
android_summary=load(args.android_summary),
|
| 120 |
+
)
|
| 121 |
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
| 122 |
+
temporary = args.output.with_suffix(args.output.suffix + ".part")
|
| 123 |
+
temporary.write_text(
|
| 124 |
+
json.dumps(result, ensure_ascii=False, indent=2) + "\n",
|
| 125 |
+
encoding="utf-8",
|
| 126 |
+
)
|
| 127 |
+
temporary.replace(args.output)
|
| 128 |
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
if __name__ == "__main__":
|
| 132 |
+
main()
|
scripts/export_math_ink_06_litert.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import argparse
|
|
|
|
| 6 |
import importlib.util
|
| 7 |
import json
|
| 8 |
from pathlib import Path
|
|
@@ -18,12 +19,23 @@ if str(SOURCE_ROOT) not in sys.path:
|
|
| 18 |
|
| 19 |
from math_grid_drawer.research.ink06_canonical import canonicalize_ink06, render_canonical_ink
|
| 20 |
from math_grid_drawer.research.ink06_export import (
|
| 21 |
-
OnlineExportWrapper06,
|
| 22 |
)
|
| 23 |
from math_grid_drawer.research.math_ink_06 import MathInk06Engine
|
| 24 |
from math_grid_drawer.research.skeleton_adapter06 import DualModalityTrajectoryAdapter06
|
| 25 |
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
def _representative_inputs(baseline_report: Path, data_path: Path) -> tuple[list[tuple[torch.Tensor, ...]], list[tuple[torch.Tensor, ...]]]:
|
| 28 |
"""필요 변수: strict baseline·HWRT JSONL. 작동 원리: 고정 76개를 128×19와 128×128 대표 입력으로 재구성한다."""
|
| 29 |
|
|
@@ -170,7 +182,7 @@ def main() -> None:
|
|
| 170 |
family_weight=engine.online_family_fusion_weight,
|
| 171 |
exact_family_index=engine.exact_family_index,
|
| 172 |
).eval()
|
| 173 |
-
raster =
|
| 174 |
engine.model, adapter=raster_adapter,
|
| 175 |
fusion_mode=str(fusion["mode"]), score_weight=float(fusion["score_weight"]),
|
| 176 |
).eval()
|
|
@@ -190,9 +202,14 @@ def main() -> None:
|
|
| 190 |
_save_exported_program06(online_export, online_path)
|
| 191 |
_save_exported_program06(raster_export, raster_path)
|
| 192 |
report = {
|
|
|
|
| 193 |
"checkpoint": str(args.checkpoint), "adapter_checkpoint": str(args.adapter_checkpoint),
|
| 194 |
"adapter_architecture": str(adapter_payload["adapter_architecture"]),
|
| 195 |
"shared_state_applied": bool(adapter_payload.get("shared_state_dict")),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
"online_family_fusion_weight": engine.online_family_fusion_weight,
|
| 197 |
"torch_version": torch.__version__,
|
| 198 |
"torch_export": {
|
|
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import argparse
|
| 6 |
+
from hashlib import sha256
|
| 7 |
import importlib.util
|
| 8 |
import json
|
| 9 |
from pathlib import Path
|
|
|
|
| 19 |
|
| 20 |
from math_grid_drawer.research.ink06_canonical import canonicalize_ink06, render_canonical_ink
|
| 21 |
from math_grid_drawer.research.ink06_export import (
|
| 22 |
+
OnlineExportWrapper06, RasterDebugExportWrapper06, exported_equivalence06,
|
| 23 |
)
|
| 24 |
from math_grid_drawer.research.math_ink_06 import MathInk06Engine
|
| 25 |
from math_grid_drawer.research.skeleton_adapter06 import DualModalityTrajectoryAdapter06
|
| 26 |
|
| 27 |
|
| 28 |
+
def _vocabulary_sha25606(labels: tuple[str, ...] | list[str]) -> str:
|
| 29 |
+
"""필요 변수: 순서가 고정된 exact labels. 작동 원리: Android label table과 graph의 동일성을 위한 SHA-256을 만든다."""
|
| 30 |
+
|
| 31 |
+
payload = json.dumps(
|
| 32 |
+
list(labels),
|
| 33 |
+
ensure_ascii=False,
|
| 34 |
+
separators=(",", ":"),
|
| 35 |
+
).encode("utf-8")
|
| 36 |
+
return sha256(payload).hexdigest()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
def _representative_inputs(baseline_report: Path, data_path: Path) -> tuple[list[tuple[torch.Tensor, ...]], list[tuple[torch.Tensor, ...]]]:
|
| 40 |
"""필요 변수: strict baseline·HWRT JSONL. 작동 원리: 고정 76개를 128×19와 128×128 대표 입력으로 재구성한다."""
|
| 41 |
|
|
|
|
| 182 |
family_weight=engine.online_family_fusion_weight,
|
| 183 |
exact_family_index=engine.exact_family_index,
|
| 184 |
).eval()
|
| 185 |
+
raster = RasterDebugExportWrapper06(
|
| 186 |
engine.model, adapter=raster_adapter,
|
| 187 |
fusion_mode=str(fusion["mode"]), score_weight=float(fusion["score_weight"]),
|
| 188 |
).eval()
|
|
|
|
| 202 |
_save_exported_program06(online_export, online_path)
|
| 203 |
_save_exported_program06(raster_export, raster_path)
|
| 204 |
report = {
|
| 205 |
+
"schema": "aiflow-math-ink-06-dual-export-v1",
|
| 206 |
"checkpoint": str(args.checkpoint), "adapter_checkpoint": str(args.adapter_checkpoint),
|
| 207 |
"adapter_architecture": str(adapter_payload["adapter_architecture"]),
|
| 208 |
"shared_state_applied": bool(adapter_payload.get("shared_state_dict")),
|
| 209 |
+
"model_version": engine.model_version,
|
| 210 |
+
"exact_label_count": len(engine.labels),
|
| 211 |
+
"vocabulary_sha256": _vocabulary_sha25606(list(engine.labels)),
|
| 212 |
+
"raster_output_count": 5,
|
| 213 |
"online_family_fusion_weight": engine.online_family_fusion_weight,
|
| 214 |
"torch_version": torch.__version__,
|
| 215 |
"torch_export": {
|
scripts/export_math_ink_06_p_formula_student.py
CHANGED
|
@@ -29,7 +29,11 @@ from math_grid_drawer.research.skeleton_adapter06 import (
|
|
| 29 |
DualModalityTrajectoryAdapter06,
|
| 30 |
SkeletonTrajectoryAdapter06,
|
| 31 |
)
|
| 32 |
-
from scripts.export_math_ink_06_litert import
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
from scripts.train_math_ink_06_p_formula_adapter import _file_sha25606
|
| 34 |
|
| 35 |
|
|
@@ -154,6 +158,9 @@ def main() -> None:
|
|
| 154 |
size_gate = program_path.stat().st_size <= MAXIMUM_MODEL_BYTES06
|
| 155 |
report: dict[str, Any] = {
|
| 156 |
"schema": "aiflow-math-ink-06-p-formula-student-export-v1",
|
|
|
|
|
|
|
|
|
|
| 157 |
"student_checkpoint": str(args.student_checkpoint),
|
| 158 |
"data_sha256": data_sha256,
|
| 159 |
"teacher_seeds": [17, 31, 47],
|
|
|
|
| 29 |
DualModalityTrajectoryAdapter06,
|
| 30 |
SkeletonTrajectoryAdapter06,
|
| 31 |
)
|
| 32 |
+
from scripts.export_math_ink_06_litert import (
|
| 33 |
+
_convert_litert,
|
| 34 |
+
_save_exported_program06,
|
| 35 |
+
_vocabulary_sha25606,
|
| 36 |
+
)
|
| 37 |
from scripts.train_math_ink_06_p_formula_adapter import _file_sha25606
|
| 38 |
|
| 39 |
|
|
|
|
| 158 |
size_gate = program_path.stat().st_size <= MAXIMUM_MODEL_BYTES06
|
| 159 |
report: dict[str, Any] = {
|
| 160 |
"schema": "aiflow-math-ink-06-p-formula-student-export-v1",
|
| 161 |
+
"model_version": f"{engine.model_version}+p-formula-student",
|
| 162 |
+
"exact_label_count": len(labels),
|
| 163 |
+
"vocabulary_sha256": _vocabulary_sha25606(list(labels)),
|
| 164 |
"student_checkpoint": str(args.student_checkpoint),
|
| 165 |
"data_sha256": data_sha256,
|
| 166 |
"teacher_seeds": [17, 31, 47],
|
scripts/package_math_ink_06_mobile_models.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""통과한 online/raster LiteRT를 하나의 Android 모델 쌍으로 묶는다."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
from datetime import datetime, timezone
|
| 7 |
+
from hashlib import sha256
|
| 8 |
+
import json
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
import re
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
MAXIMUM_BUNDLE_BYTES06 = 25 * 1024 * 1024
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _file_sha256_06(path: Path) -> str:
|
| 18 |
+
"""필요 변수: 모델 파일. 작동 원리: 파일 전체를 streaming SHA-256으로 식별한다."""
|
| 19 |
+
|
| 20 |
+
digest = sha256()
|
| 21 |
+
with path.open("rb") as stream:
|
| 22 |
+
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
| 23 |
+
digest.update(chunk)
|
| 24 |
+
return digest.hexdigest()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def model_bundle_sha25606(online_sha256: str, raster_sha256: str) -> str:
|
| 28 |
+
"""필요 변수: online/raster hash. 작동 원리: Android와 동일한 ordered bundle 지문을 만든다."""
|
| 29 |
+
|
| 30 |
+
payload = f"online:{online_sha256}\nraster:{raster_sha256}\n".encode("utf-8")
|
| 31 |
+
return sha256(payload).hexdigest()
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _require_litert_row06(
|
| 35 |
+
report: dict[str, Any],
|
| 36 |
+
*,
|
| 37 |
+
branch: str,
|
| 38 |
+
) -> dict[str, Any]:
|
| 39 |
+
"""필요 변수: export report·branch. 작동 원리: 변환·parity gate가 모두 통과한 LiteRT 행만 반환한다."""
|
| 40 |
+
|
| 41 |
+
litert = report.get("litert") or {}
|
| 42 |
+
row = litert if branch == "online" and "online" not in litert else litert.get(branch)
|
| 43 |
+
if not isinstance(row, dict):
|
| 44 |
+
raise ValueError(f"{branch} LiteRT 결과가 없습니다.")
|
| 45 |
+
if row.get("converted") is not True or row.get("gate_passed") is not True:
|
| 46 |
+
raise ValueError(f"{branch} LiteRT 변환/parity gate가 통과하지 않았습니다.")
|
| 47 |
+
if float(row.get("top1_agreement", 0.0)) != 1.0:
|
| 48 |
+
raise ValueError(f"{branch} LiteRT top-1 agreement가 100%가 아닙니다.")
|
| 49 |
+
if float(row.get("max_absolute_logit_error", float("inf"))) > 0.02:
|
| 50 |
+
raise ValueError(f"{branch} LiteRT logit 오차가 0.02를 초과했습니다.")
|
| 51 |
+
return row
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def package_mobile_models06(
|
| 55 |
+
*,
|
| 56 |
+
online_report: dict[str, Any],
|
| 57 |
+
raster_report: dict[str, Any],
|
| 58 |
+
online_model: Path,
|
| 59 |
+
raster_model: Path,
|
| 60 |
+
) -> dict[str, Any]:
|
| 61 |
+
"""필요 변수: 두 export report와 실제 flatbuffer. 작동 원리: vocabulary·parity·파일을 검증해 불변 모델 쌍을 만든다."""
|
| 62 |
+
|
| 63 |
+
if online_report.get("schema") != "aiflow-math-ink-06-p-formula-student-export-v1":
|
| 64 |
+
raise ValueError("Online은 통과한 P Formula student export여야 합니다.")
|
| 65 |
+
if raster_report.get("schema") != "aiflow-math-ink-06-dual-export-v1":
|
| 66 |
+
raise ValueError("Raster는 0.6 dual export여야 합니다.")
|
| 67 |
+
if online_report.get("torch_export_gate_passed") is not True:
|
| 68 |
+
raise ValueError("Online torch.export gate가 통과하지 않았습니다.")
|
| 69 |
+
if raster_report.get("torch_export_gate_passed") is not True:
|
| 70 |
+
raise ValueError("Raster torch.export gate가 통과하지 않았습니다.")
|
| 71 |
+
if int(raster_report.get("raster_output_count", 0)) != 5:
|
| 72 |
+
raise ValueError("Raster graph는 top-4 debug를 포함한 5-output이어야 합니다.")
|
| 73 |
+
counts = {
|
| 74 |
+
int(online_report.get("exact_label_count", 0)),
|
| 75 |
+
int(raster_report.get("exact_label_count", 0)),
|
| 76 |
+
}
|
| 77 |
+
vocabularies = {
|
| 78 |
+
str(online_report.get("vocabulary_sha256") or ""),
|
| 79 |
+
str(raster_report.get("vocabulary_sha256") or ""),
|
| 80 |
+
}
|
| 81 |
+
if counts != {378}:
|
| 82 |
+
raise ValueError("Online/raster 모두 378 exact labels여야 합니다.")
|
| 83 |
+
if (
|
| 84 |
+
len(vocabularies) != 1
|
| 85 |
+
or re.fullmatch(r"[0-9a-f]{64}", next(iter(vocabularies))) is None
|
| 86 |
+
):
|
| 87 |
+
raise ValueError("Online/raster vocabulary SHA-256이 같아야 합니다.")
|
| 88 |
+
online_row = _require_litert_row06(online_report, branch="online")
|
| 89 |
+
raster_row = _require_litert_row06(raster_report, branch="raster")
|
| 90 |
+
artifacts = {}
|
| 91 |
+
for name, path, row in (
|
| 92 |
+
("online", online_model, online_row),
|
| 93 |
+
("raster", raster_model, raster_row),
|
| 94 |
+
):
|
| 95 |
+
if not path.is_file():
|
| 96 |
+
raise FileNotFoundError(f"{name} LiteRT 파일이 없습니다: {path}")
|
| 97 |
+
size = path.stat().st_size
|
| 98 |
+
if Path(str(row.get("path") or "")).name != path.name:
|
| 99 |
+
raise ValueError(f"{name} report path와 실제 파일명이 다릅니다.")
|
| 100 |
+
if int(row.get("bytes", -1)) != size:
|
| 101 |
+
raise ValueError(f"{name} report byte 수와 실제 파일이 다릅니다.")
|
| 102 |
+
artifacts[name] = {
|
| 103 |
+
"path": path.name,
|
| 104 |
+
"bytes": size,
|
| 105 |
+
"sha256": _file_sha256_06(path),
|
| 106 |
+
}
|
| 107 |
+
total_bytes = sum(row["bytes"] for row in artifacts.values())
|
| 108 |
+
size_gate = total_bytes <= MAXIMUM_BUNDLE_BYTES06
|
| 109 |
+
bundle_hash = model_bundle_sha25606(
|
| 110 |
+
artifacts["online"]["sha256"],
|
| 111 |
+
artifacts["raster"]["sha256"],
|
| 112 |
+
)
|
| 113 |
+
return {
|
| 114 |
+
"schema": "aiflow-math-ink-06-mobile-model-bundle-v1",
|
| 115 |
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
| 116 |
+
"model_version": str(online_report["model_version"]),
|
| 117 |
+
"data_sha256": str(online_report.get("data_sha256") or ""),
|
| 118 |
+
"exact_label_count": 378,
|
| 119 |
+
"vocabulary_sha256": next(iter(vocabularies)),
|
| 120 |
+
"artifacts": artifacts,
|
| 121 |
+
"model_bundle_sha256": bundle_hash,
|
| 122 |
+
"total_bytes": total_bytes,
|
| 123 |
+
"maximum_bundle_bytes": MAXIMUM_BUNDLE_BYTES06,
|
| 124 |
+
"checks": {
|
| 125 |
+
"online_litert_parity": True,
|
| 126 |
+
"raster_litert_parity": True,
|
| 127 |
+
"raster_five_outputs": True,
|
| 128 |
+
"same_vocabulary": True,
|
| 129 |
+
"size": size_gate,
|
| 130 |
+
},
|
| 131 |
+
"package_gate_passed": size_gate,
|
| 132 |
+
"product_validation": False,
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def main() -> None:
|
| 137 |
+
"""필요 변수: report·flatbuffer·출력. 작동 원리: 검증된 UTF-8 bundle manifest를 원자적으로 기록한다."""
|
| 138 |
+
|
| 139 |
+
parser = argparse.ArgumentParser(description="Package Math Ink 0.6 mobile models")
|
| 140 |
+
parser.add_argument("--online-report", type=Path, required=True)
|
| 141 |
+
parser.add_argument("--raster-report", type=Path, required=True)
|
| 142 |
+
parser.add_argument("--online-model", type=Path, required=True)
|
| 143 |
+
parser.add_argument("--raster-model", type=Path, required=True)
|
| 144 |
+
parser.add_argument("--output", type=Path, required=True)
|
| 145 |
+
args = parser.parse_args()
|
| 146 |
+
result = package_mobile_models06(
|
| 147 |
+
online_report=json.loads(args.online_report.read_text(encoding="utf-8")),
|
| 148 |
+
raster_report=json.loads(args.raster_report.read_text(encoding="utf-8")),
|
| 149 |
+
online_model=args.online_model,
|
| 150 |
+
raster_model=args.raster_model,
|
| 151 |
+
)
|
| 152 |
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
| 153 |
+
temporary = args.output.with_suffix(args.output.suffix + ".part")
|
| 154 |
+
temporary.write_text(
|
| 155 |
+
json.dumps(result, ensure_ascii=False, indent=2) + "\n",
|
| 156 |
+
encoding="utf-8",
|
| 157 |
+
)
|
| 158 |
+
temporary.replace(args.output)
|
| 159 |
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
if __name__ == "__main__":
|
| 163 |
+
main()
|
src/ink06_export.py
CHANGED
|
@@ -126,6 +126,50 @@ class RasterExportWrapper06(nn.Module):
|
|
| 126 |
return fused
|
| 127 |
|
| 128 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
def exported_equivalence06(
|
| 130 |
eager: nn.Module, exported: torch.export.ExportedProgram, inputs: Iterable[tuple[Tensor, ...]],
|
| 131 |
) -> dict[str, float | int | bool]:
|
|
|
|
| 126 |
return fused
|
| 127 |
|
| 128 |
|
| 129 |
+
class RasterDebugExportWrapper06(RasterExportWrapper06):
|
| 130 |
+
"""필요 변수: raster model·adapter·fusion. 작동 원리: logits와 top-4 가상 stroke 검증 출력을 함께 고정한다."""
|
| 131 |
+
|
| 132 |
+
def forward(
|
| 133 |
+
self,
|
| 134 |
+
raster: Tensor,
|
| 135 |
+
) -> tuple[Tensor, Tensor, Tensor, Tensor, Tensor]:
|
| 136 |
+
"""필요 변수: B×1×128×128 raster. 작동 원리: direct shortcut 없이 분류하고 trajectory 원시 출력을 보존한다."""
|
| 137 |
+
|
| 138 |
+
coordinates, states, progress, hypothesis_scores = (
|
| 139 |
+
self.model.decode_raster_trajectories(raster)
|
| 140 |
+
)
|
| 141 |
+
features = virtual_features06(
|
| 142 |
+
coordinates,
|
| 143 |
+
states,
|
| 144 |
+
None if self.model.raster_architecture == "spatial_flat_v1" else progress,
|
| 145 |
+
contract=self.model.virtual_contract,
|
| 146 |
+
)
|
| 147 |
+
batch, hypotheses, steps, channels = features.shape
|
| 148 |
+
if self.model.use_virtual_adapter:
|
| 149 |
+
raw_features = features
|
| 150 |
+
internal = self.model.virtual_adapter(
|
| 151 |
+
features.reshape(batch * hypotheses, steps, channels),
|
| 152 |
+
).reshape(batch, hypotheses, steps, channels)
|
| 153 |
+
features = raw_features + self.model.virtual_adapter_weight * (
|
| 154 |
+
internal - raw_features
|
| 155 |
+
)
|
| 156 |
+
flat_features = self.adapter(
|
| 157 |
+
features.reshape(batch * hypotheses, steps, channels),
|
| 158 |
+
)
|
| 159 |
+
exact, family = self.model.classify_trajectory(flat_features)
|
| 160 |
+
output = {
|
| 161 |
+
"hypothesis_scores": hypothesis_scores,
|
| 162 |
+
"exact_logits": exact.reshape(batch, hypotheses, -1),
|
| 163 |
+
"family_logits": family.reshape(batch, hypotheses, -1),
|
| 164 |
+
}
|
| 165 |
+
fused, _selected = fuse_raster_logits06(
|
| 166 |
+
output,
|
| 167 |
+
mode=self.fusion_mode,
|
| 168 |
+
score_weight=self.score_weight,
|
| 169 |
+
)
|
| 170 |
+
return fused, coordinates, states, progress, hypothesis_scores
|
| 171 |
+
|
| 172 |
+
|
| 173 |
def exported_equivalence06(
|
| 174 |
eager: nn.Module, exported: torch.export.ExportedProgram, inputs: Iterable[tuple[Tensor, ...]],
|
| 175 |
) -> dict[str, float | int | bool]:
|