Stocky を作る:キッチンで何が切れるかを予測する — しかも端末の上で
バーコードのスキャンとちょっとした使用履歴を、切らす前に現れる買い物リストへと Stocky がどう変えるのか。すべての計算も、すべてのデータも、あなたの電話の中にとどめたまま。
たいていのパントリーアプリは、体裁を整えただけのチェックリストです。牛乳を追加し、牛乳にチェックを入れ、また牛乳を追加する。アプリは何ひとつ学びません。Stocky は別の問いから始まりました。もしアプリが、あなたが実際にどれくらい速くものを消費しているかに気づき、切らす前に買い物リストを組み直してくれたら?
このたった一つのアイデア — 小さくて正直な予測 — が、エンジニアリングの大部分を形づくりました。以下は、その仕組みと、なぜすべてがローカルで動くのかという話です。
スキャンは行ではなく、タイムスタンプ付きのイベントである
素朴なモデルは、増減させる quantity 列を持つ品目テーブルです。単純ですが、編集した瞬間にすべてを忘れてしまいます。
Stocky は代わりにイベントを保存します。すべてのスキャン、すべての「少し使った」、すべての補充は、追記専用のレコードです。
@Entity(tableName = "pantry_events")
data class PantryEvent(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val productId: String, // barcode / Open Food Facts id
val type: EventType, // ADDED, CONSUMED, DISCARDED
val amount: Double, // in the product's base unit (ml, g, pcs)
val at: Long, // epoch millis
)
現在の数量は、イベントを畳み込んだ(fold)だけのものです。無駄に思えるかもしれませんが、そこにこそ核心があります。履歴を保持しているからこそ、単なる累計では答えられない問いを立てられるのです — たとえば「この家庭はこれを一日に何ミリリットル消費するのか?」といった問いに。
予測はあえて退屈である
ここにニューラルネットワークはありません。ある家庭のオリーブオイルの消費は、Transformer を必要とする問題ではありません — 必要なのは、大きな週末に過剰反応しない平均です。
製品ごとに、後方に伸びるウィンドウで消費レートを計算し、直近のイベントを少しだけ重く重み付けします。
fun dailyRate(events: List<PantryEvent>, now: Long): Double {
val consumed = events.filter { it.type == EventType.CONSUMED }
if (consumed.size < 2) return 0.0
val windowStart = now - 30.days.inWholeMilliseconds
val recent = consumed.filter { it.at >= windowStart }
if (recent.isEmpty()) return 0.0
val total = recent.sumOf { it.amount }
val spanDays = ((now - recent.first().at) / 1.days.inWholeMilliseconds)
.coerceAtLeast(1)
return total / spanDays
}
daysLeft = currentQuantity / dailyRate。この値がしきい値を下回ると、その品目はそっと買い物リストに現れます。まだレートがない? ならば予測もしません — Stocky は当て推量をするくらいなら何も言いません。穏やかなアプリは、間違えるくらいなら黙っていることを選びます。
部分的な使用、なぜならキッチンはアナログだから
最も手間がかかった機能は数学ではなく、「これをだいたい三分の一くらい飲んだ」と言えるようにすることでした。人は整数で数を減らしたりしません。ジュースの紙パックは 200 ml ずつ消費されるもので、もしアプリが「満杯」のままにするか「なくなった」と印を付けるかの二択を強いれば、予測はゴミになります。
だから消費シートは実際の単位で語り、製品のパッケージサイズを覚えています。おかげで「グラス一杯」や「半分」が、レート関数の使える値に対応します。これは、モデル全体をそっと信頼できるものにする、ほんの小さな UI です。
なぜこのどれもが電話の外に出ないのか
上記のあらゆる数値は、あなたのイベントから導き出されます。そしてあなたのイベントは、あなたの生活を居心地が悪いほど詳細に描き出します — いつ食べるか、どれだけ食べるか、どのくらいの頻度で買い物をするか。まさにそれこそ、私が手元に持ちたくないデータです。
だからアカウントも、あなたのパントリーを保存するサーバーもありません。すべてはローカルの Room データベースの中にあります。製品の検索は Open Food Facts(および、コミュニティによるトルコ製品のセット)に問い合わせますが、そのリクエストに含まれるのはバーコードだけ — あなたの履歴は決して含まれません。予測が端末上で動くのは、データがすでにそこにあり、そしてそこにとどまるべきだからです。
ここでのローカルファーストは、マーケティング用のチェックボックスではありません。このアプリの正直なバージョンがそもそも成り立った、まさにその理由です。私は、一度も集めていないデータベースを、失うことも、売ることも、漏らすこともできないのです。
違うやり方があるとすれば
いま同じレートモデルを作り直すなら、穏やかな季節項を加えるでしょう — 夏に跳ね上がるものもあります — そしてカテゴリごとの事前分布(prior)を加え、真新しい品目が一週間黙り込む代わりに、妥当な初期レートを借りられるようにするでしょう。どちらも小さな変更です。どちらもクラウドを必要としません。
Stocky は Google Play で公開中です。もし試していただけたなら、ひとりでに現れるあの買い物リストこそ、私がいちばん誇りに思っている部分です — そして、それが賢いことすら、願わくば忘れてしまう部分です。
// 関連記事
ジャーナルの他の記事
Granynを作る: 銀行ログインなしの家計簿を、3つのテーブルで
Granynが銀行口座を一つも連携せずに、複数通貨での支出を追跡し、定期的な請求を検知する仕組み — その裏にあるRoomスキーマと、そこで生じるトレードオフ。
2026年のローカルファーストなAndroid: SQLite、Room、そしてユーザーデータを端末に残すこと
RoomとSQLiteでローカルファーストなAndroidアプリを作るための2026年版ガイド — スキーマ設計、マイグレーション、WAL、エクスポート、そして同期をいつ足すべきか(いつ足すべきでないか)。
AndroidでのプライバシーファーストなOCR: Sublyがクラウドを使わず請求書を読み取る仕組み
ML Kit Text Recognitionを使ったAndroidのオンデバイスOCRに関する、2026年の実践的なガイド。セットアップ、コード、そしてユーザーのデータをサーバーに残さないためのフィールド抽出のコツを解説します。