本文へスキップ
すべての記事

Stocky を作る:キッチンで何が切れるかを予測する — しかも端末の上で

バーコードのスキャンとちょっとした使用履歴を、切らす前に現れる買い物リストへと Stocky がどう変えるのか。すべての計算も、すべてのデータも、あなたの電話の中にとどめたまま。

MFKAPPS 1 分で読めます

たいていのパントリーアプリは、体裁を整えただけのチェックリストです。牛乳を追加し、牛乳にチェックを入れ、また牛乳を追加する。アプリは何ひとつ学びません。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 で公開中です。もし試していただけたなら、ひとりでに現れるあの買い物リストこそ、私がいちばん誇りに思っている部分です — そして、それが賢いことすら、願わくば忘れてしまう部分です。