cwLeeDev commited on
Commit
c228b1d
·
verified ·
1 Parent(s): 7d39c29

Expose raster top-4 strokes and add end-to-end release lineage gate

Browse files
MANIFEST.json CHANGED
@@ -1,11 +1,11 @@
1
  {
2
- "schema": "aiflow-hf-research-snapshot-v23",
3
- "generated_at": "2026-07-23T22:50:29.5564804Z",
4
  "track": "R_noncommercial_plus_rejected_P_proxy",
5
  "product_validation": false,
6
  "public_release": true,
7
  "contains_raw_dataset": false,
8
- "tests": "334 Python passed + 7 Android passed",
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": 6862,
59
- "sha256": "f008635ab59344005f65f1394f56275e4c92610cd25781a2d95243d060010718"
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": 7095,
164
- "sha256": "8c215219801c7817dbf1fde5890b8c722b4d0cb6eda968714dd0a6fc5f4ab318"
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": 8635,
219
- "sha256": "4f9083957fa434a23abad9c8a39bd4f9337ce3f8ac9769b1e581bd521d61a997"
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": 11229,
244
- "sha256": "6dd7e606c0c8d2f150144ac3e3aa3de527d268be9391fc7fa2a43a3861468a6a"
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": 7268,
289
- "sha256": "daa54ea4382a11bc4c84fbf05dc99d5ccc0e5f9325336f5a2fc00b271a42c95c"
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": 4244,
309
- "sha256": "5452acda5376977d47b9f3aa1f48e510c181fd2cf3edbd45813e60b8748186ea"
310
  },
311
  {
312
  "path": "android/aiflow-math-ink-runtime/src/main/java/ai/aiflow/mathink/InkContracts.kt",
313
- "bytes": 3304,
314
- "sha256": "14e4f1a3f10507abf8fae0e88e692e8ea2ec66454fbe0cce27a8e48bfd766e2f"
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": 2978,
324
- "sha256": "06251288705c15bea4e38285f42b86e9754aa3e7190a030278b36ba69a2d0a78"
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": 2978,
344
- "sha256": "d6876734bbb23394b40c3b484e2c44d9e06cf3f67de77d3f50205e554b41f758"
345
  },
346
  {
347
  "path": "colab/aiflow_math_ink_06_litert_bundle.zip",
348
- "bytes": 17277316,
349
- "sha256": "5bb4d44d88ebd3a325e35ddfefd1b751c1a09151510e54ad189b5fe18e963701"
350
  },
351
  {
352
  "path": "pyproject.snapshot.toml",
@@ -355,8 +355,8 @@
355
  },
356
  {
357
  "path": "README.md",
358
- "bytes": 22413,
359
- "sha256": "71a75d7ce8253f336778a9ffa1da58fe4545abcc0a2bc2d018c41f6261ffa071"
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": 5819,
399
- "sha256": "227370669f6177b4c9a75ec6c610d2cadafbd0288327c5ea853706dece7c39a4"
400
  },
401
  {
402
  "path": "android/README.md",
403
- "bytes": 3167,
404
- "sha256": "8b2a49f775fba7e8fafdc97ca2abdc7ace2e0da0e3a8a88b46b63f4cfd640ad9"
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
- 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로 결합한다. Kotlin unit test 7개와 전체 Python 회귀 334개, release AAR build가 통했다. AAR은 74,915 bytes, SHA-256 `ed38d013e3b2035983d4cdce823df587105b4dbe2bbb6c119c3e7c49cac4125e`이며 모델을 포함하지 않는. 실제 flatbuffer·기기 benchmark 값은 아직 없다.
 
 
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 배포 graphexact 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 && raster.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
- return raster.run(rasterInk, exactLabelCount)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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<FloatArray> = emptyList(),
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-23T20:19:13.374433+00:00",
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": 4474,
31
- "sha256": "37c67827f1c39df9cb0f6c45f782c58a2d1bdf5190e51070de817d1ba2693b67"
32
  },
33
  {
34
  "path": "src/math_grid_drawer/research/math_ink_06.py",
35
- "bytes": 58690,
36
- "sha256": "657a720f32bfe8548ec859986bc3b6bcafbf3ec70f0abfdbf50127946c655052"
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": 11027,
56
- "sha256": "68510ea84f778bc415c3b5b29e3445cd0c5102a5febb1bb126c92262c75ccebb"
57
  },
58
  {
59
  "path": "artifacts/base_378.pt",
@@ -72,9 +122,9 @@
72
  }
73
  ],
74
  "product_validation": false,
75
- "bundle": "research\\runs\\math_ink_06_litert_colab_20260724\\aiflow_math_ink_06_litert_bundle.zip",
76
- "bundle_bytes": 17276876,
77
- "bundle_sha256": "d5357732c9f1654d65664e3fe23acab88a7374e88e3f2bd5b60ab46120aefc47",
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:5bb4d44d88ebd3a325e35ddfefd1b751c1a09151510e54ad189b5fe18e963701
3
- size 17277316
 
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-23T20:42:31.265188+00:00",
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": 5348,
31
- "sha256": "b9a3e73bcaa19e0a868b3daad533cc3a3b784a4b99d447c2d51f9b74644554a1"
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": 11229,
56
- "sha256": "6dd7e606c0c8d2f150144ac3e3aa3de527d268be9391fc7fa2a43a3861468a6a"
57
  },
58
  {
59
  "path": "artifacts/base_378.pt",
@@ -72,9 +122,9 @@
72
  }
73
  ],
74
  "product_validation": false,
75
- "bundle": "research\\runs\\math_ink_06_litert_colab_20260724\\aiflow_math_ink_06_litert_bundle.zip",
76
- "bundle_bytes": 17277316,
77
- "bundle_sha256": "5bb4d44d88ebd3a325e35ddfefd1b751c1a09151510e54ad189b5fe18e963701",
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, RasterExportWrapper06, exported_equivalence06,
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 = RasterExportWrapper06(
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 _convert_litert, _save_exported_program06
 
 
 
 
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]: