2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「最初はただ、iPhoneでJw_cadをヌルヌル動かしたかっただけなんです。」 30年動く「Jw_cad」のバイナリを解析し、Rust × Metal × 手書きC ABI 140本でゼロコピー描画パイプラインを構築。
44万要素の図面を0.5秒で開き、AIの暴走を純関数 PolicyGate で防ぐiOSアプリをApp Storeに出荷するまでの格闘記です。

30年動き続けている国産2D CAD「Jw_cad」の図面(JWW)を、
iPhone上で編集して、そのままWindowsのJw_cadへ戻せるアプリを作りました。
音声とAIからも同じ編集経路を通します。

App Storeで公開されました。

👉 JZ Draft AI(App Store)(無料)

前回の記事を書いた時点では「先日申請を出しました」でした。
そこから 差し戻され、直し、通り、公開され、次のバージョンを作っているところです。

この記事は、111フェーズ・約30万行の開発で
「いちばん面白かった技術的な発見」だけを抜き出したものです。


▼ 前回までのあらすじ

前回、国産2D CAD「Jw_cad」のバイナリフォーマットを解析した話を書きました。

結論はこうでした。

AIに業務を任せる第一歩は、「データを読めるようにする」こと。

今回は、その先で盛大に転んだ記録です。

先に言っておくと、この記事のいちばんの読みどころは
「うまくいった話」ではなく「4回作り直した話」の方です。


🎯 この記事で書くこと

章 中身 ひとこと
第1章 このアプリは何か 3分で
第2章 AIに責務を渡しすぎていた ★ 4つのモデルが同じ場所で0/4。真因は学習量ではなかった
第3章 教師データの「近道」を7つ潰した ★ モデルは意味ではなく“形”で当てていた
第4章 画像を足したら精度が下がった ★ しかも「別図面の画像」の方が成績が良かった。理由が美しい
第5章 人の答え988行で40.6%→69.7% ★ 手でルールを書くと26.6ポイント負ける
第6章 JWWバイナリの深いところ 削除したらJw_cadが開けなくなった話
第7章 「気をつける」を「できない」に変える 型に欄を作らない設計
第8章 速さの話 変換0回
第9章 声で図面を描く 通り芯・柱・壁を声だけで
第10章 Appleに差し戻された ★ 真因は「同意の口が1つも無かった」
第11章 5回続けて「測ってない」場所が穴だった ★ この開発の規律の原点
第12章 数字で見る現在地 母数つきで

🏗️ 第1章:このアプリは何か(3分)

Jw_cadとは

Jw_cad(ジェイダブリュー キャド)は、1991年にDOS版が公開された国産の2D CADです。
清水治郎氏・田中善文氏が作られ、30年以上ずっと無料で、今も更新が続いています。

日本の建築業界での立ち位置を一言でいうと、

中小の設計事務所・工務店にとっての「事実上の標準」。

その保存形式が .jww(JWWファイル) です。中身はMFCのシリアライズ形式で、
公開仕様はありません。だからバイト列を実測して解明しています。

作ったもの

既存のiOS用JWWビューアとは異なり、JWW本体を編集し、
JWWのままWindows版Jw_cadへ戻すところまでを実装した。

iOSには既にJWWを見るアプリがあります(TouchJW v2 / View The Jw / JWViewer など)。
今回狙ったのは、編集して往復させるところです。

(「初」「唯一」とは書きません。網羅調査をしていないので立証できません)

中核は「6段の鎖」

最初に決めた条件が1つだけあります。これは開発中に自分で書いた原文がそのまま残っています。

「JWデータを読み込むときにIRデータと画像データを重ね合わせ、そこから意味を読み取り
IRデータに書き込み、それをこのiPhoneアプリで編集できる様になり、最終的に書き出すときには
JWデータで書き出しWindowsのJWCADで読み込める様にすること、
これ絶対条件です このアプリの一番大事なとこなので記録しといて」

段 状態 根拠
① JWWを読む 実証済み 441,000要素を531.5msで開く
② IRと画像を重ね合わせる 実証済み 実図12枚とも主要線100%一致
③ そこから意味を読み取る ⚠️ ここだけ未完 人の答えで測って正解率69.7%
④ 意味をIRに書き込む 実証済み sidecar(別ファイル)
⑤ iPhoneで編集する 実証済み 押せるコマンド85本 + 音声 + AI
⑥ JWWで書き出してJw_cadで開ける 実証済み Jw_cad 10.03.5で往復確認

6段のうち5段は閉じています。残るのは③だけで、
この記事の第3〜5章はほぼ全部「③の話」です。

機能一覧

分類 できること
開く JWWをそのまま開く / ZIPの中の図面も展開せず開ける / メール添付・Filesから直接
見る ピンチ拡大、文字検索、レイヤ表示切替、ルーペ、ミニマップ、1cmスケールバー、用紙枠
描く 線 / 連続線 / 矩形 / 柱 / 円・円弧・楕円 / 文字 / 寸法 / 点 / 2線 / 中心線 / 接線 / 接円 / 曲線
編集 移動 / 複写 / 消去 / 伸縮 / コーナー / 面取り / 丸面 / 複線 / 線切断 / レイヤ移動 / 属性変更 / パラメトリック変形
計測 距離 / 角度 / 弧長 / 中心角 / 面積(求積・掃引)/ 座標 / 累積 / 式計算・表計算
意味 39種類の役割を自動判定 / 意味で選ぶ / 用紙ごとの領域分割
渡す ベクタPDF / AirPrint / DXF入出力 / JWK取込 / 画像のJWW同梱
話す 音声操作 / 通り符号での作図 / 声で確定・取消
AI 7つの接続先 / 道A(命令)・道B(図面ごと渡す)
写真→CAD 紙の図面を撮って線を起こす / PDFはベクタを直読

注意:登録93本の内訳は 完全20本 / 一部65本 / 未対応8本(= 押せるのは85本)。
一部対応のコマンドには「まだ〜はできません」を画面内に名指しで書いています(現時点で41か所)。
押しても何も起きないボタンを作らない、という掟です。


【ここからが本題】

🧠 第2章:AIに責務を渡しすぎていた

ここがこの記事でいちばん書きたかった話です。

症状:4つのモデルが、同じ場所で0/4

自社で小さなモデル(FunctionGemma 270Mベース)を学習させて、
「声で言った操作をCADコマンドに変換する」役をやらせていました。

ある時、状態別に採点し直したら、こうなりました。

                                        モデルA  seed17  seed42
  要1件 / 選択0件(= 断るべき)           10/18    11/18    7/18
  要1件 / 選択1件(= 実行すべき)         46/49    41/49   42/49
  要2件以上 / 選択0件(= 断るべき)        5/5      5/5     4/5
★ 要2件以上 / 選択1件(= 1件足りない)     0/4      0/4     0/4
  要2件以上 / 選択が十分(= 実行すべき)   14/15    14/15   14/15

「1件足りない」だけが、3本とも0/4。1度も当たっていません。

一方で「0件かどうか」はほぼ満点(5/5)です。
seedを変えても、モデルを変えても、この1行だけ動かない。

「学習量が足りない」では説明が付かない

4つモデルを替えて1度も当たらないなら、それはもう学習の問題ではありません。
構造を疑うべきです。 実際に構造で説明が付きました。

モデルへ届く状態(state)は 7つのキーだけで、その中に required_selection が無い。
候補は "edit.fillet=面取り" のように 名前しか入っていない。

→ モデルは「面取りは2件要る」を どこからも受け取っていない。
  コマンドごとに暗記するしかない。だからseedでブレる。

つまり、答えられない問題を出して、当たらないことを責めていたわけです。

気づいたこと:責務の分け方が間違っていた

「実行してよいか」の判定に必要な入力は、実は全部決定論で取れます。

  • そのコマンドは何件の選択を要求するか(← コマンド定義に書いてある)
  • いま何件選択されているか(← アプリが知っている)
  • 必須の引数は埋まっているか(← アプリが知っている)
  • 破壊的な操作か(← 定義に書いてある)

決定論で書けるものを、確率モデルに投げていた。

直し:PolicyGate を作って、モデルから責務を取り上げた

【前】
  モデル → 「どのコマンドか」+「実行してよいか」 → 実行

【後】
  モデル     → 「どのコマンドか」だけ
  PolicyGate → 「実行してよいか」(= 純関数。コードが決める)

結果:学習を1度もせずに直った

同じ4モデルの、同じ観測ログを、新しい責務分担で読み直しました。

モデル 該当ケース 旧:正解 新:end-to-end正解 新:意味の正解
モデルA 5 0 5 5
seed 17 5 0 5 5
seed 42 5 0 5 5
seed 97 5 0 5 5

読み方はこうです。

モデルは意味を当てていた(= 意味の正解 5/5)。
当てたうえで「実行する」と自分で決めてしまい、件数が足りないのに実行していた。

足りなかったのは知識ではなく、責務の分け方だった。

PolicyGate単体の合格条件は、コマンド定義から機械生成した**全境界 501/501 = 100%**です。
純関数なので、100%でないと通しません。

教訓(LLMエージェントを作る人へ)

決定論で書けることを、確率モデルに判断させない。

言い換えると、「モデルが何を知り得るか」を先に数えろ、ということです。
モデルに届いていない情報で判断を求めていたら、
どんなに学習しても当たりません。それは精度の問題ではなく設計の問題です。

そしてもうひとつ。

「AIの精度」を1つの数字にまとめない。

いまは8段を別々に測っています。

① 候補抽出  ② 文脈生成  ③ スキーマ変換  ④ モデルの応答
⑤ パーサ    ⑥ 検証      ⑦ 関所         ⑧ 下見

全部まとめて「精度75%」と言っていたら、
候補抽出のバグ(第3章)にも、責務設計のバグにも、一生気づけませんでした。


🕳️ 第3章:教師データの「近道」を7つ潰した

第2章の裏側で、もっと地味で、もっと厄介な問題と戦っていました。

モデルが「意味を読まずに答えを当てられてしまう手掛かり」——
これを社内では**「近道(shortcut)」**と呼んでいます。

問題:「CAD の外の依頼」を断れない

最初に測ったとき、こうなりました。

実測
モデルの範囲外拒否率 0%(= 14/14 で実在コマンドの提案が返った)

「天気を教えて」にも、存在しない機能(日影図・VR)にも、
全部なにかのCADコマンドを返してくる。

図面が勝手に変わることはありません(人の確定が挟まる)。
でも雑音の下見が出るのは品質課題です。

近道を1つずつ数えて潰した

ここからが本題です。7つ見つけました。

# 近道 どうやって見つけたか
① 断りの行に候補欄が1件も無い 教師データを数えた
② 正解が必ず候補の1番目(実行時は90%しか1番目でない) 同上
③ 紛らわしい相手が候補に1度も並ばない(8対すべてで0回) 同上
④ ★ tool宣言の順番が答えを漏らす 学習側とアプリの宣言順を突き合わせた
⑤ ★ 「答えの名前+決まった接尾辞」というテンプレの形 正例の94.84%が答えの名前をそのまま含んでいた
⑥ 接尾辞40種のうち32種は、断りの例が1件も無かった 接尾辞1本ずつまで数え直した
⑦ ★ 教師の82.4%が「正解=候補[0]」(断りは6.1%。13対1) 失敗側でも同じ形が出ているのを確かめた

④ が特にひどかった

学習データを作るとき、正解のtoolを宣言の先頭に置いていました。
一方アプリは、状態だけで決まる固定順で宣言します。

学習の中では   正解は常に先頭 → 100%当たる
実際のアプリでは 順番が違う   → 断り 0%

学習内スコアが満点なのに、実機では1件も断れない。
「量子化のせいでは?」と疑いましたが、3点セットで量子化は無罪と証明しました。

⑤ は、もっと不気味だった

過剰拒否(=正当な依頼まで断る)が7件出たので、原因を数えました。
最初に立てた仮説は「短い『〜して』型がclarifyに寄っている」でしたが、
数え直したら違いました。 帯の78.7%が実行側です。仮説は棄却(REFUTED)。

本当の近道はこれでした。

・正例の 94.84% が、答えのコマンド名・別名を そのまま含む
・名前の後ろに来る語は 上位20種で 87.4% の閉じた表
・★ 過剰拒否した「文字を書き入れたい」の「を書き入れたい」は 0件
   (「点を打ちたい」「寸法線を入れたい」「求積表を出して」も 0件)

つまり、モデルは意味ではなく「形」で実行かどうかを当てていた。
名前の後ろに知らない動詞が来ると形が合わず、断り側へ落ちる。

⑦ は最新の発見(そして、まだ勝てていない)

教師36,149行のうち、proposal行32,521の82.4%が「正解=候補[0]」。
一方 unsupported(=断り)は6.1%。
つまり「候補[0]を書け」を「断れ」の13倍教えていました。

失敗側でも同じことが起きているのを、数で確かめました。

seed コマンドを出した = 候補[0]だった 候補内で[0]が占める
17 50 31(62%) 31/35 = 89%
42 41 29(71%) 29/33 = 88%
97 65 47(72%) 47/48 = 98%

偶然なら候補8つに一様で12.5%。
④で潰した「宣言順が答えを漏らす」近道と同じ形が、
今度は候補の並びに残っていました。

「直った」と「直っていない」を両方書く

これ、直すたびに別の場所が壊れます。

回 やったこと 結果
2本目 近道①〜④を潰す 拒否率 0% → 50%。ただし過剰拒否7件
3本目 近道⑤を潰す 過剰拒否 7 → 1件、意味正解 82.5% → 88.8%。★ 拒否率が50%→21.4%に悪化
4本目 断り側にも同じ接尾辞を通す —
5本目 拒否率 21.4% → 92.9%。★ 過剰拒否が 1 → 2件
7本目 断りの割合 6.1%→20% 危険な実行 -44%〜-76%。★ ただし0/300に届かず

3本目の悪化の理由も、その場で数えて特定しました。

足した「動詞つきの言い方」は実行側にしかなく(帯5,695件中clarifyは126件=2.2%)、
「動詞つきの形 → 実行してよい」という新しい近道を作っていた。
= 2本目で断り側に候補欄を付け忘れたのと同じ過ちの鏡像。

そして、7本作って1本しか配っていません

いちばん大事なのはここです。

事前に「1つのseedでも危険な実行が1件以上あれば、レシピごと不採用」と決めていました。
最新の7本目は、危険な実行が 45→25 / 38→23 / 54→13(最大−76%)と大きく減りましたが、

条件 必要 s17 s42 s97
危険な実行 0/300 25 23 13
意図一致 80%以上 65.3% 62.7% 67.7%

届いていません。だから出しません。

良い数字が出ても、事前に決めた規則を、結果を見てから緩めない。
seedを選び直さない。封印した問題集の成績で出荷を決めない。

配布しているのは1本目のままです。R2のモデルファイルは1バイトも触っていません。

教訓

「学習データを増やす」の前に、「学習データを数える」。

近道は、集計では見えません。
⑥ なんて、平均を見れば「断りは2.28%ある」ように見えるのに、
1本ずつ数えたら40種のうち32種はゼロでした。

平均は嘘をつきます。分布を見てください。


🖼️ 第4章:画像を足したら精度が下がった(6回否定した話)

「図面の意味を読む」ために、画像を見せれば良くなるはず——
そう思って6回テストしました。6回とも否定されました。

そして今回(6回目)がいちばん厳しい設計で、いちばん面白い結果でした。

実験の設計

アプリが実際に用紙を描いて測っている画素の量
(黒の有無・線の太さ・周りの濃さ・両脇の白)を、役割の判定に足して測ります。

反証条件を先に登録してから測りました。

条件 正解率
別図面の画像 + IR 0.6721 ← ★ いちばん良い
IRだけ 0.6630
正しい画像 + IR 0.6589
白紙の画像 + IR 0.6285
画像だけ 0.4079
用紙の位置だけ 0.2824

★★★ 正しい画像より、別の図面の画像を混ぜたほうが成績が良い。

事前登録の6条件のうち3つが不合格。判定は REJECTED です。

理屈が美しかった

なぜこうなるのか。答えを聞くと、当たり前すぎて笑ってしまいます。

今回テストした「絵」は、アプリが IR から描いたものである。

業務の実図には画像が貼られていないので、アプリが自分で用紙を描く。

★ ということは、その絵に IR に無い情報は 原理的に 1つも無い。
★ だから「別図面のほうが良い」は異常ではなく 当然 である。

自分で描いた絵を見て「線が描かれている」と言っても、それは循環しているだけです。
線の座標はJWWの中に最初から書いてあるので、そちらを読めばいい。

だから禁止事項をひとつ足しました。

自前レンダの画素から「描かれているか」「線の太さ」「線色」を取るのは禁止。
重ね合わせで意味があるのは位相(閉領域・連結・かたまり)を取る用途だけ。

★ 例外は2つ。紙をスキャンした図面とJWWに貼った下敷き画像。
ここは画像が元データそのものなので、この理屈は当てはまりません。

ただし——「効かない」と「測れていない」は違った

ここが本当に大事なところです。3回、測定そのものが壊れていました。

回 何が「測れていなかった」か
4回目 候補3,120のうち835件(26.8%)が母数から消えていた。835件は全部が文字(注記267 / 寸法値248 / 記号114…)で、この8種の文字クラスは評価対象15クラスに1つも入っていなかった
5回目 ★ 学習データの分け方に穴があり、15種のうち8種が学習側に1件も入っていなかった。モデルはその8種を一生出せない。出せないものを「当たらなかった」と数えていた
6回目 分け方を組み直したら、まったく同じモデルの合成図面での成績が 0.155 → 0.677 になった

「絵が効かない」は間違いでした。測れていませんでした。

そのうえで、測れるようにしてもなお、実図では効かないというのが今回の結論です。
真因は「測れていない」から「合成図面と実図が違いすぎる」へ移りました。

なぜ4回目に「文字が消えていた」のか

自前で描いていた絵に、日本語が1文字も描かれていなかったからです。
外形の箱でしか描けていませんでした。

boundary_line(敷地境界線)はIRだけでは recall 0.095 しか拾えないクラスで、
その理由は「敷地境界線と書いてある」ことでしか決まりません。
字が無ければ、絵を見せても当たりようがない。

なので日本語フォントを埋め込んで、21,753字を字形で描けるようにしました。
このときの手順も記録に残しています。

07:41:49 UTC  一次資料で license を読み終えて PASS と判断
07:41:58 UTC  ★ その 後 に font file を取得(= 9秒後)

権利を確かめてから取る。順番を証拠として残す。
Reserved Font Name は私の許可では動かせない条項なので、
実物のフォントの name テーブルからバイトで確かめました。

教訓

「効かなかった」と書く前に、「測れていたか」を数える。

母数から26.8%が黙って消えていても、スコアは普通に出ます。
スコアが出ることは、測れていることを意味しません。


🌲 第5章:人の答え988行で 40.6% → 69.7%

「図面の意味を読む」の本命は、結局これでした。

出発点:ルールを手で書いても、そこで止まる

線1本1本に「これは外壁」「これは通り芯」というラベルを付けます。
ルールベースで頑張って、2年ぶん相当のフェーズを使って上げました。

版 heldout macro-F1
初期 0.1247
V2 0.3539
V3 0.4048
V4(公開中の版) 0.4276

ここで止まりました。outer_wall(外壁)のF1は 0.044。ほぼ機能していません。

方針転換:人に答えてもらう

社内で図面に手でラベルを付けてもらいました。988行 / 4物件 / 19図面。

そのデータで random forest(16本・深さ10・葉3)を学習させ、
Rustの定数表として生成して端末に載せます(生成物は322KB、数の表だけ。
物件名も文字本文も入っていません)。

結果

どの1物件も学習に使っていない状態で当てる(=物件group 4-fold):

正解率 macro-F1
V4(手で書いたルール。公開中はこれ) 0.4059 0.3258
新しい森 0.6974 0.5442
差 +0.2915 +0.2183

95% CI(図面クラスタのgrouped bootstrap, B=2000)[+0.2347, +0.3629]。下限が0より大きい。

4物件すべてで改善しました。

外した物件 n V4 新しい森
P01 190 0.4684 0.7842
P02 267 0.4382 0.6891
P03 263 0.4563 0.6806
P04 268 0.2799 0.6604

役割ごとに見ると、手書きルールが0だったものが立ち上がっています。

役割 V4 新しい森 差
構造線 0.000 0.606 +0.606
通り符号 0.091 0.722 +0.631
図面枠線 0.233 0.699 +0.466
寸法線 0.145 0.583 +0.438
記号文字 0.098 0.487 +0.389
建具線 0.428 0.718 +0.289
外壁 0.094 0.308 +0.214

★ 変えたのは「1つだけ」

いちばん効いたのは、意外なことに特徴を減らすことでした。

特徴を55 → 26にした(=文脈29個を外した)。

  • 物件group 4-fold: 0.6630 → 0.6974
  • 入れ子の検証(特徴の選択をfoldの内側だけで行う)でも 0.6974
    → 「同じデータで選んだ有利」では説明できない

見立てはこうです。

文脈29個は『同じ図面の中の他のentityとの関係』で、
描き方の癖が図面ごとに違うため、別物件では迷わせる。

費用対効果の表(これが結論)

投資 効き
人の答えを増やす 学習曲線がまだ伸びている(74行 51.4% → 741行 67.2%。倍で+3.9)
転移しない特徴を捨てる 文脈29個を外して +3.4ポイント
手でロジックを書く V4が26.6ポイント負けている(40.6% 対 67.2%)
画像を足す −0.4ポイント(=第4章)

「IRを分析してロジックを書く」のではなく、
「人の答えを増やして、転移しない特徴を捨てる」が効く。

★ 途中で見つけた、いちばん怖いバグ

学習と実行が、別の道を通っていました。

sklearn は木を辿るとき X を float32 に落として比べる
Rust は f64 のまま比べていた

→ しきい値ちょうどの値(0.82 対 0.8199999928474426)で
  ★ 枝が逆に分かれていた

988件中 3件。クラスは一致していたので画面では見えなかった。

Rustをf32経由に直して、特徴・判定・確信すべて988/988が完全一致(最大差 1.11e-16)。

これ、画面では絶対に気づけません。 parity試験を書いていなかったら、
「なんとなく合わない気がする」で一生残っていたバグです。

誇張しないための注記

  • gold は 4回開封済み(=CONSUMED)。封印した未見データでの確証ではない
  • P02〜P04 は1名の回答。主張は「その方の語彙との一致」
  • したがって状態は DEV_MEASURED。「完成」とは書きません

★ ただし、測らずに「BLOCKED」と書くよりは、はるかに情報があります。

画面はどう変わるか

掛け替え率 敷地境界線 部屋名 外壁 建具
V4(公開中) — 576 30 337 1,124
新しい森 63.8% 58 30 696 2,036

10,965件のうち63.8%が別の名前になります。

敷地境界線が576→58に減るのは、V4の過検出が直ったためです
(テスト図面は V4 が56本と言うのに、実際の敷地は6辺)。

★ 面積・求積はV4の答えを読み続けるので1mmも変わりません。
★ 現場の感覚に合わなければ、既定を戻す1行で元通りになります。


🔬 第6章:JWWバイナリの深いところ

① 「削除したらJw_cadが開けなくなった」

症状: 要素を削除して書き出すと、Jw_cadが図面を開けなくなる。

削除は「2バイトのNULLタグへの置換」で実装していました。
これは要素の通し番号(PID)を壊さないための、一見正しい手当てです。

真因: MFCの CArchive::ReadObject は
NULLタグを読んでもオブジェクト番号を消費しません。
自作パーサは「NULLで番号を1つ使う」実装だったので、
自分の規則の中では辻褄が合ってしまい、気づけませんでした。

証明を3段でやりました。

① 1か所しか違わないファイルが開けない
   (元の 141 byte → 2 byte の 0x0000。前後は完全一致)

② 手元の JWW 189件を全部走らせて NULLタグを数えた
   → 6件だけ。★ 6件とも こちら(AI)が作ったもの
   → Jw_cad が書いた実図に NULLタグは 1件も無い

③ NULL をやめて物理削除だけにしたら
   ★ こちらのパーサ自身が同じ壊れ方をした(=完全再現)

直し: 物理削除 + 件数−1 + 番号の詰め。

② パーサの完走判定が、自分のコメントと違っていた

削除した要素が末尾に来ると、主ループの最終回が continue で
完走フラグの行に届かず、**全部回り切っていても「完走していない」**と判定される。

面白いのは、正しい式がパーサ自身のドキュメントコメントに元から書いてあったことです。

/// 走査が完走したか (= entity_count == entities_parsed)   ← 正しい
pub parse_complete: Option<bool>,

        if i + 1 == count {          // ← 実装はこう書いてあった
            completed_loop = true;   //    「最後の1回が本体の下まで来たか」
        }

主ループには本体の下へ行かずに continue する道が3本ありました。直しは1行。

③ 画像の同梱形式(リンクではなく本体埋め込み)

「下敷きの画像がWindowsで出ない」という報告がありました。

真因: アプリは同梱していませんでした。リンクしか書いていなかった。

^@BM<この iPhone の中のパス>,<幅>,<高さ>                       ← アプリ(3欄)
^@BM%temp%<名前>.bmp,<幅>,<高さ>,0,0,1,<回転>,255,255,255      ← 本物(10欄)

Windowsにそのパスは無いので、絵が出ようがない。

本物の並びを、実物12標本から確定しました。

<entity列 …>
DWORD K                       ← 画像の枚数
K回 {
    CString name              ← 末尾が ".bmp.gz"
    DWORD  compressed_len     ← ★ gzip の byte 数(=展開後ではない)
    <gzip stream>             ← 展開すると BMP
}
EOF                           ← ★ 12標本すべて 余り 0 byte

④ そして、^@BM が「0個」に見えた理由

前のフェーズで「同梱された画像に ^@BM は1つも無い」と報告していました。誤りでした。

b.count(b'^@BM')                      # → 0   ← 誤った数え方
b.count('^@BM'.encode('utf-16-le'))   # → 1   ← 正しい

JWWの文字はUTF-16LEです。ASCIIで数えていました。

★ 過去の報告は書き換えず、追加文書で訂正しています
(=凍結した記録を書き換えると、何を信じていいか分からなくなるので)。

⑤ なぜすぐ直さなかったか

Macに Jw_cad が無く、「正しく書けたか確かめる手段が無い」からです。

半端に書いたJWWは「絵が出ない」ではなく
「Jw_cadが図面ごと開けない」になり得ます。いまより悪い。

なので、実装は先に済ませて「1分で確かめられる見本ファイル」を2本作り、
Windows側で確認が取れてから配線しました。

golden から画像欄を剥がして0枚にし、同じgzipを貼り直すと
1,108,896 byte が完全一致する試験を書いてあります。

確かめる手段が無いなら、実装を止める。

⑥ その他、実測で分かったこと

# 事項
1 塗りの色: ある条件のときだけRGBのDWORDが続く。4バイト書き忘れで後続要素が全ずれ
2 文字種テーブル: ヘッダに28byte×10件。moji_shu は1-based(0は任意サイズ。実図の79%)
3 ★ 柱は「線4本」ではなく「線6本」(正方形4辺+対角2本のX印)。描き順まで実図105本すべて同じ
4 ★ 外壁は芯振り分けではなく片側寄せ(内65mm / 外97.5mm)。104組を測って判明
5 座標の限界: round6 の丸めで |v×1e6| ≤ 2^53。書式の限界であって当方の上限ではない

③④は「そう習った」ではなく、実図をバイトで測って初めて分かったことです。
実際、事前の想定(芯振り分け・壁厚105)とは違っていました。

裏付けもバイトで取りました。

求積表の芯々寸法      11,375 × 6,370
実図の壁の外形        11,570 × 6,565
差                     ちょうど 97.5 × 2 = 195mm  ← 片側寄せの証拠

こちらが書き出した壁の外形 → 4辺すべて 0.000000mm で一致

★ もし想定どおり「105の芯振り分け」で描いていたら、4辺とも45mmずれていたはずです。

推測で書いた寸法は1つもありません。


🔒 第7章:「気をつける」を「そもそもできない」に変える

作っていていちばん面白かったのは、AIが触っていい「器」の設計でした。

① 見せてはいけない情報は、型に欄を作らない

AIに渡すコンテキストは、うっかりすると内部IDやファイルパスが混ざります。
「混ぜないように気をつける」では、いつか必ず混ざる。

なので2つの型に分けて、片方には欄そのものを作りませんでした。

// ① アプリ内部だけで持つ(モデルには見せない)
struct CADExecutionCaptureV1 {
    let requestId: String                       // アプリが採番
    let capturedDocumentStamp: CADDocumentStamp // 図面の印
    let registryHash: String
    let source: CADCommandSource
    let startedAt: Date
}

// ② モデルに見せてよい情報「だけ」を持つ
struct ModelVisibleCADContextV1 {
    var activeCommandId: String?                // 握っているコマンド
    var allowedActionNames: [String]            // いま呼べる道具の名前
    var requiredSlots: [String]                 // 必要な欄
    var currentSlotValues: [String: Double]     // いま入っている値
    var selectionCount: Int
    var hasBasePoint: Bool
    var sheetLine: String                       // 「A-3 / 1:100」の1行だけ(座標なし)
    var pendingPreview: Bool
    var candidates: [ModelVisibleCandidate]     // 最大8件
}

requestId も revision も registryHash も、②の型には存在しません。
だからプロンプトへ混ざりようがない。

② commit を enum から消す

AIが呼べる道具は7種です。

enum CADModelToolName: String {
    case runCommand      = "run_command"
    case setCommandValue = "set_command_value"
    case setBasePoint    = "set_base_point"
    case readDrawing     = "read_drawing"
    case undo            = "undo"
    case clarify         = "clarify"
    case cancel          = "cancel"
}

commit がありません。型の上でモデルは確定できない。

しかも状態によって見せる道具が変わります。

状態 見せる道具
コマンド未選択 run_command / read_drawing / undo / clarify / cancel
コマンド選択中 set_command_value / set_base_point / run_command / clarify / cancel
下見が出ている間 cancel だけ

③ 指示は1本だけ足して、中身をenumにする

85本ぶんの指示を定義すると、85か所に安全チェックを書くことになり、どこかで書き忘れます。

run_command( id: <押せる85本のコマンドIDのenum> )

コマンドIDを「押せるコマンド一覧から生成したenum」にしただけ。これで、

  1. 85本ぜんぶにAIが届く
  2. 新しいコマンドを足したら、AIからも自動で見える
  3. 安全チェック(関所)は1枚のまま

実行経路は、指で押したときとまったく同じ1本を通ります。

★ 正直な後日談:この配線を作ったあと「85本すべてに届きます」と報告したのですが、
run_command は intent の絞り込みで1度もモデルへ見せられていませんでした。
数えたら分かりました。「配線が在る」は「動いた」ではありません。

④ 答えが返ってきた瞬間に、図面が動いていたら捨てる

@MainActor
final class CADStaleGate {
    enum Verdict {
        case allow
        case staleResult(String)   // 図面かコマンド状態が動いた
        case duplicate             // 同じ requestId を既に実行した
    }
}

「印」の作り方で一度つまずいた(ABA問題)

最初、図面の印を 要素数・操作数・redo可否の3点で作っていました。
これ、編集して → Undoで戻すと3点とも元に戻る。同じ印になります。

いまは 単調増加カウンタ(documentEditEpoch) が主判定です。

  • 編集・undo・redo・開き直しで進む
  • 下見・cancel・実変更なしでは進まない
  • オーバーフローしたら毎回違う印にする(fail closed)
  • 印を作る場所は1本。全員がここを読む

さらに、下見が出ている最中の編集も止めます。
下見を出した瞬間の印を控えて、確定の直前にもう一度照合し、違えば0件で止める。

⑤ 候補8件 / 道具8本 / 出力96トークン

最初は「AIには全部見せたほうがいい」と思って、コマンドを毎回全部プロンプトに入れていました。
入口のトークン数が中央値15,428。 遅いし外します。

いまは、

  • 発話から決定論的に候補8件を抽出(完全一致1000点/含む=長さ×10/N-gram重なり。同点はID昇順)
  • 状態から道具を最大8本に絞る
  • 出力は96トークン

これで入口が604〜752トークン(約1/20)になりました。
完全一致が1件だけならLLMを呼ばずに直接実行します。

★ そして、候補抽出に1行のバグがあった

guard k.count >= 2 else { continue }   // ★ 1文字の別名 8個が1度も当たらない
                                       //   線 / 点 / 表 / 角 / 式 / 円 / 柱 / 窓

コマンド定義に1文字の別名がちゃんと登録されているのに、
採点側が1文字を切り捨てていました。

直し方の候補を5つ作って比べ、hard gateを通ったのは1つだけでした。

指標 A(現行) B C D ★ E(採用)
直すべき正例の到達 0/1,488 788 788 727 ★ 1,488/1,488
★ goldを失った 0 0 0 ★ 11 ★ 0
recall@1 / @8 92.01% / 95.19% — — — 96.49% / 99.41%
latency P95 5,013μs — — — 5,175μs(1.03倍)
判定 FAIL FAIL FAIL FAIL ★ PASS

D単独は1文字を直す代わりにgoldを11件失います。
Cが空白を対称に揃えるとその11件が残る。だから C+Dでなければならない。

★ 隠さないこと:候補の並びは43,103発話中16,021件(37%)で変わります。
衝突負例14本のうち3本で、1文字の持ち主が人手goldより上に来ました。
点を下げれば直りそうですが、やりません(3点は実装前に固定したので)。

⑥ Provider間の自動フォールバックは禁止

送信先が変わる行為なので、利用者の明示選択が必要です。
「Claudeが落ちたからGeminiへ」を勝手にやりません。

⑦ 「送る内容を見る」は実payloadそのもの

1. canonical request bytes を作る
2. その同じ bytes から preview と byte数・hash を作る
3. その同じ bytes を transport へ渡す

表示用に別の文章を組み立てると「画面に出したもの」と「実際に出たもの」がいつかズレるので、
bytesは1回しか作りません。

⑧ 道B:図面ごと渡して自由に編集させる

「同意した範囲の図面をまるごと渡す」道もあります。6段です。

① 同意        接続先ごとの明示同意(既定OFF、いつでも取消可能)
② 包む        同じrevision・同じ範囲からpayloadを作る
③ 送る        送信直前にも同意を再確認
④ 突き合わせ  返却patchを全件突き合わせ
⑤ 下見        変更・追加・削除・範囲外の件数を表示
⑥ 確定        利用者が確定して初めて適用

「全部送る」は、測ったら物理的に入りませんでした。

実務図面(10,961要素)の IR JSON = 4,706,807 byte
  → トークン見積り 約120万〜160万
  → どの接続先の入口にも入らない

なので「見えている範囲」または「用紙1枚」に切り直しました。

そして返ってきた差分は、1件でも問題があれば全件拒否(atomic)。

知らないID / 同意範囲外 / 異なるrevision / NaN・Infinity /
不明なfield / 不明なoperation / 重複operation ID / 壊れた参照 /
delete後の参照 / 語彙外のsemantic role / 最大操作数超過 / 深すぎるJSON

AIが書いたIDは使いません(こちらで振り直す)。

そして同意した範囲の外にはみ出した提案は、見せるけれど確定できません。
一度「警告だけ出して確定できる」形で作ったのですが、
見えているのに止められない警告は無いのと同じなので、確定不可に変えました。


⚡ 第8章:なぜ速いのか

理由は3層あります。どれも定石で、新規性はありません。

① データを1回も詰め替えない

普通は「Rustの構造体 → Swiftのオブジェクト(44万回生成)→ 配列 → GPUバッファ」となり、
詰め替えだけで数秒行きます。

このアプリは、

Rust が f32 の連続メモリを作る: x0,y0,x1,y1,x0,y0,...
  → Swift はそのポインタと長さだけ受け取る
  → そのまま MTLBuffer に載せる

変換が0回です。図面の実体は最後までRust側のヒープに置きっぱなし(opaque handle)。
だから 描画経路にJSONを置かないを鉄則にしていて、JSONはAI・監査・書き出し専用です。

② GPUに考えさせない

やっていること 効果
色バケット別に事前ソート(Jw_cadの線色10種) draw callが13本で済む
破線の展開をRust側で済ませる GPUで毎フレーム点線を計算しない
文字はGlyphAtlas(テクスチャ1枚) 文字ごとにテクスチャを切り替えない
hit test用バッファを事前構築 タップのたびに44万本を線形探索しない

③ 見えないものは作らない(LOD)

  • 円弧の分割数は画面上で1セグメントが6pxになるように決める(8〜64分割)
  • 破線の1セグメントが1px未満なら実線として流す
  • 端末が熱くなったら予算に係数を掛けて自動で下げる(理由も画面に出す)

実測(iPhone 16 Pro / Release / n=1端末)

図面 開くまで GPU時間 ピークメモリ
合成50,000要素 — 1.04 ms(P50) 130〜172 MB
合成441,000要素 531.5 ms 5.7〜6.0 ms(P50) 364〜366 MB

2万要素を選択して塗り分けても +0.68 ms、ルーペを出しても +0.4 ms です。

正直に言っておくこと

速さの相当部分は Apple Silicon と Metal のおかげです。
「うちの設計が優秀」というより「速いハードの足を引っ張らない設計にした」が正確です。

★ 検証端末は iPhone 16 Pro 1台のみ。この定点観測値は開発中盤に測ったもので、
以降は再計測していません。他CADアプリと同じ図面で比較したことはありません。

おまけ:.ultraThinMaterial を実機の数字で捨てた

条件(6回ずつ) 間隔P95 目標達成
.ultraThinMaterial の帯あり 10.02〜19.80 ms 2/6
ぼかさない半透明に置換(最終) 8.35〜8.59 ms 6/6

CADは指を置いている間ずっと図面が動くので、
いちばん効く場所でいちばん高い装飾を使っていました。

おまけ2:どうしても直せなかった話(K37)

実機でフレーム間隔が 16.67ms に落ちる現象があります。

実測で棄却した仮説: 低電力モード / 適応電力 / OS更新 / 横向き / 端末再起動 / 描画の回帰。

判明した挙動: 連続描画が動いている間だけ全ての要求が一律1段落ちる。
present を完全に止めても同じ。描画が止まると1秒未満で全快する。

→ OSの電力ガバナであり、アプリから外す口がありません。
だから回帰は GPU / CPU / draw / peakMem で見ることにしました。

「直せない」と分かることも成果です。(仮説を5つ、実測で潰しました)


🎤 第9章:声で図面を描く

マイクはつけっぱなし

CADは片手が指でふさがっているので、マイクボタンを押しに行く動作自体が邪魔でした。
そこで 図面を開いている間、アプリが前面にある間はマイクを開いたままにしています。

項目 実装状況
有効になる条件 アプリ前面 + 図面を開いている間
入切 画面上のマイク丸ボタン1つで切れる
使用中の表示 聞き取り中は図面の上に1行を浮かせる
音声認識 端末内で完結(requiresOnDeviceRecognition / iOS 26以降は SpeechAnalyzer)
音声波形 監査ログに書かない(禁止項目として型と試験で固定)
クラウドへ送るもの 波形ではなく認識後の文字列。かつ同意の条件を満たしたときだけ

経路は2本ある(役割が違う)

音声コマンド 音声作図補助
何を読むか 命令 数と単位だけ
LLM 規則で解けなければ呼ぶ 1度も呼ばない
関所 検証 → 対象解決 → 確認 指を離すことそのもの

1回の読み取りは 0.0016 ms。LLMを呼ばないのは間に合わないからです。

「指を離す」が確認の関所そのものである。

線を引きながら「5メートル」と言って指を離すと、5,000mmの線になります。
円を描きながら「半径3m」、矩形なら「幅2m」「高さ3m」。
「910」「910ミリ」「0.91メートル」が同じ 910mm になります。

通り符号(X1・Y1)で図面を組み立てる

通り芯って「X1通り」「Y7通り」で会話するんですよ。座標では喋りません。

「原点をX1通りとして、右へ910ミリ間隔でX14通りまで、縦の通り芯を14本引いて。」
「Y1通りのX1、X5、X6、X14に柱を置いて。」
「X5通りに、Y1通りからY10通りまで間仕切壁芯を引いて。壁厚は105ミリ。」

結果(合成図面 / 自動テストで数を固定):

項目 結果
通り芯 縦14本 + 横10本 / 全区間 910.00 mm
交点 140点、X1-Y1 のような描画順に依存しないIDを付与
柱 21本 / 105角 / 交点からのずれ 0.00 mm
外壁芯 11,830 × 8,190 = 96.8877 m²

実機のマイクで、16場面が声だけで完走した

静かな環境で、Macから実際に音を鳴らしてiPhoneのマイクに聞かせました。

要素数の推移:
  20 →  78(図面枠)→  96(通り芯)→ 216(柱)→ 224(壁)
     →(「元に戻す」×3)→ 78

  画面止め 0場面 / タップしたボタン 0回 / 落ちた回数 0

★ 1回で「通った」と書きません。 別のフェーズでは9回、5回とまわしています。

安全側の挙動(すべて実機の音で数を取った)

状況 実測
途中認識で「削」→「削除」→「削除し」→「削除して」と4回届く 消えるのは1件
実行直後の言い直し 2度目は走らない
周囲の雑談を拾った(「雨降ってたさっけ…」) 命令が1行も出ない
「消去しないで」 打ち消しとして止まる
下見中に相槌(「はい」「そうですね」) 確定しない
「キャンセルして」 要素数もハッシュも同一

即実行を許す前提としてUndoを検証しているのですが、
検証しているのは要素数だけです。
座標・属性・線種・文字・レイヤのcanonical hashまでは検証していません。
だから「どの操作でも必ず完全に元へ戻る」とは書けません。ここは宿題です。

😂 おもしろかった話1:テストの道具のほうが悪かった

通り符号(X1・X14)だけが、実機の音で5回とも1度も綴れませんでした。

合成音声(Macの読み上げ)が言った「X1通り」
  → 認識器が綴ったもの:「ウックス」「す位置」「リックス」「スイッチ」「ス」

生声で言ったら一発で正しく綴られました。
つまりアプリでも認識器でもなく、テストに使っていた合成音声の発音が原因。

ここで大事なのは、「ウックス」を辞書に足さないことです。
足せば数字は良くなりますが、合成音声の癖に過学習しているだけです。
足すべきは「エックス → X」という日本語として正しい読みだけ。

😂 おもしろかった話2:「3mと言っても引かれない」の真因

報告:「3mと言って放しても3mラインが引かれなかった」

実機の認識器は「3メートル」を4回とも 「3メーター」 と文字化していた
  → 単位語彙に「メーター」が無い
  → 単位なし = 3mm の線(小さすぎて見えない)

語彙1語でした。3語足して、その場で「効いた!3mが引けた」。

😂 おもしろかった話3:完成した文字列では絶対に出ないバグ

音声補助のテストは63本全部通っていました。本物の音を通したら2件出ました。

# 症状 真因
① 幻の長さ 「45度」とだけ言うと 長さ45mm・向き45°の線が入る 認識器は 4→45→45度 と刻んで届ける。45 の時点で「長さ45mm」と読み(単位なしはmm)、45度 が角度になったあとも 言っていない長さ45mmが残る
② 空白で種別が落ちる 「高さ3m」が 幅3,000 になる 認識器は「半径 3メートル」のように種別語の後ろへ空白を挟む。空白で種別が落ち、矩形ではplainが幅扱い

テストが全部通っていたのは、完成した文字列を1回で流し込んでいたからです。
刻んで届く・まとめて届く・巻き戻して届く・空白が挟まる、の4つは実物にしか無い。

😂 おもしろかった話4:認識器は無音中に喋らない

「1回言って待つ」使い方がいちばん遅かった(発話終了 +3.3〜4.7秒)。

認識器は無音の間は決して結果を吐かず、次の音が来た瞬間に前の結果を吐く。

直し:声のあと0.6秒の無音かつ途中認識が0.35秒動いていないとき、強制的に吐かせる。
→ 全回 +350〜770ms に短縮。

★ 途中で .fastResults(SDKの速さ優先オプション)も試しましたが、撤回しました。
刻みの頭は速くなったのに、途中認識の尻が「5メート」で止まって「ル」が永久に来なくなり、
「5メートル」が 5mm で図面に入った
からです。

「速くなった」は「正しいまま速くなった」を読み戻しで確かめて初めて言える。


🍎 第10章:Appleに差し戻された

申請しました。3日後に差し戻されました。

Guideline 5.1.1(i) / 5.1.2(i)
Review Device: iPad Air 11-inch (M3)

Appleの指摘

第三者AIへ個人データを送るのに、何を・誰に送るか説明し、
送る前に許可を取る作りになっていない。プライバシーポリシーにも記載が足りない。

真因(1行)

★★★ 道A(=ふだんの声・チャット)に、同意の口が1つも無かった。

  • 道B(=「図面をまるごと送る」)には最初から同意があった
  • 道Aは 接続先を選んでAPIキーを保存したその瞬間から、
    話した言葉と図面の要約が送信される作りだった
  • 画面に出ていたのは Picker 下の footerの説明文だけ

★ 「説明は書いてあった」は言い訳になりません。 Appleの文面はこうです。

only including this information in the … Privacy Policy is not sufficient

Appleの4要件と、直したあと

要件 差し戻し時 直したあと
何を送るか開示 △ footerに説明文があるだけ ★ 同意画面に送る項目を全部列挙 + どの設定でも送らないものも併記
誰に送るか明示 △「Claudeへ送られます」=製品名だけ ★ 事業者名(Anthropic PBC / Google LLC / OpenAI, L.L.C.)+ 通信先ホスト + 各社ポリシーへのリンク
送る前に許可を取る ★★★ 無い(=これが真因) ★ 既定OFFの同意。押すまで エンジンを作らない。試し送信も押せない
ポリシーの記載 △ 収集方法と第三者の保護水準の確認が無い ★ 3節を新設

実装で効いたのはこの1行です。

// 未同意なら CloudCommandEngine を そもそも作らない
// → 送る経路が存在しないので、1 byte も出ない

「送らないように気をつける」ではなく「送る部品を作らない」。
第7章と同じ考え方です。

★ 差し戻しの審査機が iPad だったので、iPadでも同じ数のテストを通しました。

そして通りました

その後、無事に審査を通過し、App Storeで公開されています。

👉 https://apps.apple.com/jp/app/jz-draft-ai/id6801720910

学んだこと

プライバシーポリシーは「書いてあるかどうか」ではなく「アプリ内で許可を取っているか」が見られます。
そして製品名ではなく事業者名
が要ります。

もうひとつ。この差し戻しは予定を丸ごと崩しましたが、
「2本のモデルが仕上がってから申請」という当初の順番より先にこれをやると決めました。
モデルが仕上がっても、この直しが入っていなければ同じところで落ちるからです。


🕳️ 第11章:5回続けて「測っていない」場所が穴だった

この開発の規律は、全部ここから来ています。

開発初期、報告書に正直に「ここは確かめていない」と書くようにしていました。
そうしたら、次のフェーズで、そこが必ず穴でした。5回連続で。

前のフェーズの但し書き 次のフェーズで通してみた結果
「候補を選んだ後の挙動を通していない」 選んだ瞬間に「先に対象を選んでください」で止まった
「線だけを移して は候補に出せない」 候補には出た。選んだ瞬間に文字へ戻った
「ずれた図面を持っていないので実害は未確認」 ずれる図面は作れた。作ったら本当に別の要素が動いた
「寸法はfixtureに1件も無いので測れない」 書けた。しかも書いた途端に「寸法線が1本も描かれていない」が出た
(誰も但し書きすら書いていなかった) 画面のマイクボタンは11フェーズのあいだ1度も押されていなかった

最後のやつが強烈でした。

・音声のスモークテストは 仕様として マイクを使わない(認識結果を直接入れる)
・「マイクに向かって話す」手動チェックは 7フェーズ続けて未実施
・結果:画面のマイクボタンは 11フェーズのあいだ 1度も押されていなかった
   (押したら、許可済みでも落ちた)

「音声で〜が実行できる」というチェック項目は ✅ でしたが、
それはマイクを飛ばした先を測った ✅ でした。

ここから3つの掟ができた

①「確かめていない」と書くだけでは足りない。通さないと分からない。

②「測れない」で止めると、測れないこと自体が別の不具合を隠す。

③「自動化できない」で線を引いた場所の内側にも、まだ機械で押せるところがある。
 (声は機械で出せない。だが「ボタンを押す」ところまでは機械で押せる)

もうひとつの掟:「テストが全部通っている」は「指で届く」を保証しない

あるフェーズで、一覧にこう書いていました。

「円弧(中心・始点・終点の3点)を描けます」
「ソリッド(塗り)は3点か4点で置けます」

Rust側の口は動いており、cargo test 181件も xcodebuild test 559件も
1件も落ちていませんでした。

それでも、どちらも指では1度も描けませんでした。

原因      円弧コマンドの道具を「円」に 決め打ち していた
なぜ      「円」は2点で自動確定する。3点目を置く機会がそもそも来ない

単体テストは「部品が動くか」を見る。実機スモークは「押して届くか」を見る。
この2つは別のものであって、片方でもう片方の代わりにはならない。

さらに:「道具の表は1枚ではない」

「押せるが効かない」ボタンを作ってしまったことがあります。
点の数の表には「4点」と書いてあったのに、
確定の表に足し忘れていました。
4点目の自動確定がguardで無言で弾かれ、点だけが溜まり続ける。

道具の表は 4枚ある:
  picksPoints / requiredPoints / drawsByDragging / ★ canCommit
テストは 3枚しか見ていなかった。

結果として、こういうテスト体制になりました

手法 内容
実データ経路 …OnRealDataTests / …RealDrawingTests / …RealPhotoTests
件数の固定 押せるコマンド数の増減をテストが固定する
既知問題番号 = クラス名 K20TailAnalysisTests / K29SkewedIndexTests …
全数掃引 編集ツール全43種を default なしの switch で分類
禁句の監視 「完成」「完全版」「最終モデル」「配布可能」「速い」「賢い」を検出
測れていないことの検出 「楕円を1件も見ていないなら落とす」assert
JSON往復の強制 decode を通さないテストを「実運用で通る」と書かない
parity試験 学習側と実行時のプロンプトをバイトで照合
★ ソースの走査 .fileImporter が別のpresentationの後ろに置かれていないかをソースを機械で走査して見張る
★ 色の距離 「0.35より近い色があったら落ちる」(=線色とぶつかる色へ戻せない)

試験運用で踏んだ落とし穴(実話)

# 内容
① xcodebuildを何本も同時に走らせるとSimulatorがSIGKILLされ、落ちるテストの顔ぶれが毎回変わる
② ファイルを足したらその場で xcodegen generate(忘れると "Executed 0 tests")
③ echo "… exit=$?" はechoの終了コード。RC=$? を先に取る
④ 端末へ入れたら必ず devicectl device info apps で版を確かめる
⑤ 所要時間が想定と桁で違ったら、通っていても疑う
⑥ 良すぎる数字を疑う(量子化後1.0000のとき、答えの種類2,890通りを数えた)
⑦ ★ pgrep -f の自己一致(起動側shellのコマンド行に検索語を書くと自分に当たる)
⑧ ★ xcodebuildは TEST_RUNNER_ を剥がす(環境変数を渡しているのに毎回skipされていた)
⑨ ★ P95を「値」で切ると同値が全部裾に入る(120fps固定で400中138本が裾になった)。本数で切る

📊 第12章:数字で見る現在地

指標 値 母数・条件
公開 App Store 公開済み id6801720910
フェーズ数 111 iOS Phase 0〜111
Swift本体 / テスト 101,095行 / 61,021行 190 / 169ファイル
Rust 138,256行 11クレート
C ABI 140本 exact manifestで完全一致照合
登録 / 押せるコマンド 93 / 85 未対応8はグレーアウト
コマンドに残る「まだ」 41か所 画面内に名指しで表示
API カバレッジ C1〜C4 85/85 / C5 83/85 機械生成
AIに見せる道具 / 候補上限 / 出力予算 7種 / 8件 / 96トークン —
AIの接続先 7 うち端末内2
意味の役割 39種 学習は15クラス
意味認識(新しい森) 正解率 0.6974 / macro-F1 0.5442 ★ 人の答え988行 / 物件group 4-fold / 開封済みgold
意味認識(公開中のルール) 正解率 0.4059 / macro-F1 0.3258 同上
学習と実行の一致(parity) 988/988 差 1.11e-16
PolicyGate 501/501 = 100% 定義から機械生成した全境界
候補抽出 recall@1 / @8 96.49% / 99.41% 43,103発話
面積算出 実図の求積表と 6種すべて一致 ★ 循環しており独立検証ではない
重ね合わせ 主要線 219/219 / 用紙 12/12 ★ recall相当。precision未記録
画像+IRの意味づけ ★ 不採用 6回目の否定
音声モデル ★ 不採用 封印blindで0/300に届かない
最大実測 441,000要素(0.5秒で開く) 合成図面
実機latency(学習モデル) P50 755 / P95 1,129 / P99 1,208 ms iPhone 16 Pro / n=1端末
実機 peak RSS 890〜968 MB モデルload + 推論
Simulator回帰 2,894件 / 失敗0 / skip11 iPhone 17 Pro + iPad Pro M5 × 2回ずつ同値
実機回帰 2,834件 / 失敗0 / skip68 iPhone 16 Pro
Rustテスト 1,027件 / 失敗0 ×2 同値
検証端末 iPhone 16 Pro 1台のみ iPad・低メモリ実機は未実施

まだできていないこと(正直に)

項目 状態
音声モデルの完成 不採用。封印blindで 0/300 に届かない
意味づけの独立検証 goldは4回開封済み。封印した未見データでの確証ではない
Undoの完全復元検証 現在は要素数のみ。座標・属性のhash検証は未
写真→CADの幾何精度 precision / recall / F1 が未計測
面積算出の独立検証 追加図面+人手の正解ポリゴンが必要
Ed25519のモデル署名運用 実装はあるが未運用(=現状はSHA-256の改ざん検知まで)
iPad・低メモリ実機での検証 手元に無い
外部ファイルパーサのfuzz test 未実施
画像→CADが「瞬時」ではない 4.9〜10.7秒
対話型CAD APIの9段目(追従) 未着手(壁⇔建具⇔寸法の連動)

未確認の項目について「安全」とは書きません。


🧱 おまけ:技術構成

┌────────────────────────────────────────────┐
│ SwiftUI(画面)                              │
├────────────────────────────────────────────┤
│ 解釈層  規則 → 候補8件 → 推論器               │
├────────────────────────────────────────────┤
│ 共通AI実行契約                               │
│   封筒 → StaleGate → ★PolicyGate →           │
│   検証 → 安全判定 → 下見 → 人が確定           │
├────────────────────────────────────────────┤
│ 実行層  CommandSession(= 指で押す経路と同一)│
├────────────────────────────────────────────┤
│ 描画:Metal      │ 境界:手書きのC ABI 140本  │
├──────────────────┴─────────────────────────┤
│      Rust(JWWパーサ/ライタ/意味付け/面積)│
└────────────────────────────────────────────┘

なぜ手書きC ABIか

RustとSwiftをつなぐ自動生成ツール(UniFFI等)は使っていません。

今回検討した自動生成ブリッジの素直な設計では、要素単位の変換コストが大きかったため、
描画ホットパスは連続バッファを渡す手書きC ABIにしました。

★ 自動生成でも設計次第でバッファ渡しは可能でしょうし、
手書きC ABIだけが高速化の唯一解というわけではありません。
今回の条件(44万要素 / 毎フレーム / 既存Rustコアの再利用)ではこの形が素直でした。

リリース成果物のシンボル検証

過去に、リリース成果物で必要なC ABIシンボルが欠落した事例がありました。

release(lto=fat) + crate-type = ["staticlib","rlib"]  →  シンボル 0本 / .a 30MB
release(lto=fat) + crate-type = ["staticlib"]         →  シンボル 全部 / .a 6.1MB

リンクは通るがSwiftから呼べないという無言の失敗になります。

対策として、最初は「N本以上あること」という下限検査を入れていたのですが、
これは 122 → 103 に減っても素通りします。いまは完全一致のマニフェスト検証です。

・140シンボル × 2スライス(実機 / Simulator)を名前で完全一致
・スライスのplatform判定(実機とSimulatorの取り違えを検出)
・ヘッダ3か所のSHA-256(宣言変更の検出)
・duplicate 0

goldenは、回帰と実機の検証を通した現物の .a から採取したものを使い、
作業中のファイルから自動で採り直しません。

編集モデル:「原本 + 操作列」

pub struct EditSession {
    original: Vec<u8>,   // 不変。常に再生の起点
    ops: Vec<EditOp>,    // 26種類の操作
    undone: Vec<EditOp>, // やり直し用
    ...
}

Undoを逆パッチでやると、いつか必ず戻らないケースが出ます。
なので原本を不変で持ち、操作列を全部再適用する方式にしました。

★ この方式の代償が 「費用が件数の2乗で伸びる」 ことです。全部潰しました。

操作 対策前 対策後 打ち手
複写20件(50,000要素) ≒1.6秒 32 ms バイトごと複製する1操作
ハッチ2,000本 83.1 ms 41.1 ms まとめて置く口
複線20件 ≒1,600 ms 46.8 ms 何件でも1操作
分割20等分(441,000要素) 19,772.9 ms 1,325.4 ms 分割数によらず2操作

★ 教訓:上限は「件数」だけでなく 「図面の大きさ」 も測らないと決まりません。
同じ20等分が、小さい図面では155ms、441,000要素では19.8秒でした。


🎯 終章

前回の記事の締めはこうでした。

「読めない技術」を「読める技術」に変える。

前回から今回への一歩はこうでした。

「読める」を「触れる」に変える。

そして今回、いちばん学んだのはこれです。

「AIに任せる」の前に、「AIに任せてはいけないもの」を先に決める。

第2章のPolicyGateがまさにそれでした。
4つのモデルを替えて1度も当たらない境界があって、
その真因はモデルに届いていない情報で判断を求めていたことでした。

「実行してよいか」は決定論で書けます。だからコードが決める。
モデルの仕事は「利用者が言ったのはどのコマンドか」の特定だけ。
それだけにしたら、学習を1度もせずに 0/4 が 5/5 になりました。

作っていて面白かったのは、AIが触っていい「器」の設計です。

  • 見せてはいけない情報は、型に欄を作らない
  • モデルに確定させないなら、enumから commit を消す
  • 答えが返る間に図面が動いていたら、実行の直前に捨てる
  • 同意していないなら、送る部品をそもそも作らない
  • クラウドを足すときは、第2の実行系を作らずAdapterだけ足す

「気をつける」を「そもそもできない」に変える作業の連続でした。
これはAIにコードを書かせているからこそ効く設計だと思っています。

そしてもうひとつ。この開発でいちばん価値があったのは、うまくいった記録ではなく
「落ちたものを落ちたと書き続けたこと」でした。

  • 音声モデルは7本作って1本しか配っていません
  • 画像は6回否定しました
  • 意味づけは DEV_MEASURED であって「完成」ではありません

事前に決めた合格条件を、結果を見てから緩めない。
良い数字が出ても、条件が揃っていないrunは数えない。
これをやらないと、自分が何を作ったのか分からなくなります。

30年分のJWWは、まだ倉庫で眠っています。 起こしにいきます。

👉 JZ Draft AI(App Store・無料)


📦 使った技術スタック

  • Claude Code(Opus 5 / Fable 5.1)← 主力。iOSアプリ1本まるごと
  • Rust ← JWWのパーサ・ライタ・意味付け・面積・自前ラスタ(11クレート / 13.8万行)
  • Swift + SwiftUI + Metal ← 画面と描画(10.1万行 + テスト6.1万行)
  • 手書きのC ABI 140本 ← RustとSwiftの境界
  • XcodeGen ← プロジェクトファイルを人間が触らない(YAMLが正本)
  • LiteRT-LM 0.16.0 ← 端末内でLLMを動かすランタイム
  • FunctionGemma 270M ← 自社学習モデルのベース
  • scikit-learn → Rust定数表 ← 意味づけの森(端末で動かすため生成)
  • Apple Foundation Models ← OS内蔵のシステム言語モデル
  • Apple Vision / SpeechAnalyzer ← 文字認識と音声認識(端末内)
  • DGX Spark ← 自社モデルの学習
  • Cloudflare R2 ← 学習済みモデルと公開ページの配布
  • Noto Sans JP(OFL 1.1 / subset) ← 自前ラスタの日本語字形
  • Jw_cad 10.03.5 ← 往復確認の基準

⚖️ 注記

  • 本アプリは、JWW形式との互換を目的に独自開発したものです。
    Jw_cadおよびその作者の方々との提携・承認関係はありません。
  • 「完全互換」ではありません。往復確認したのは実務図面数枚 + 楕円入り1枚です。
  • 本記事の数値は実測値ですが、母数と評価条件を必ず併記しています。
    未計測のものは「未計測」「未確認」と書いています。
  • 意味づけの評価データは4回開封済みであり、封印した未見データでの確証ではありません。
  • 意味認識・写真→CAD・自社モデルは実験機能です。
  • 自社の学習済みモデルは、署名運用が残っているため「候補」として提供しています。
  • クラウドAIへの送信はすべて任意で、接続先ごとの明示同意より前に1バイトも送りません。
  • 個人情報や顧客機密を含む図面をクラウドAIへ送る場合、アプリ上の同意とは別に、
    所属組織の契約・利用目的・本人同意・外国第三者提供等の確認が必要になる場合があります。
  • バイト単位のJWW仕様は、前回同様伏せています。

🙏 最後に

東海地区の小さな建築設計事務所が、iPhone 1台と、社内に20年分の図面と、
AIに無茶振りする社長で、CADアプリを作ってApp Storeに出しました。

図面は、読めるようになった。
iPhoneで編集して、Windowsへ戻せるようになった。
意味を読むところは、40.6% から 69.7% まで来た。
声で図面を描くところは、まだ出せていない。

出せていないものを「出した」と書かない限り、
次に何をすればいいかは、いつでも数字が教えてくれます。

続きはまた書きます。


タグ: iOS, Swift, Rust, JwCAD, JWW, 建築, CAD, AI, LLM, 個人開発

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?