個人健康データ基盤「yuru」運用記 その3 — 「AIの記憶はあてにならない」を制度で潰す:眠っていた180万サンプルの救出と、3階層バックアップと、全ドキュメント化

前回(その2)は、AIバックエンド移行で噴出した構造欠陥と、相関分析で「データに騙されない」土台づくりの話を書いた。今回は、その土台の上で起きた2日間の集中作業——外泊タグという小さな機能追加から始まって、「全部取っているつもりだった」データの大量未取得の発覚、180万サンプルの救出、バックアップの3階層化、そして全ドキュメント化——を扱う。だが通奏低音は別のところにある。AI(Claude)の記憶が、確認するたびに間違っていたのだ。完了済みタスクを「保留中」と言い、存在しない条件を「決定事項」と言い、その捏造が一次ソースにまで漏れていた。この記事は、AIとの分業体制で「記憶」をどう扱うべきかを、実例で骨身に沁みた記録である。自分の勉強用メモ。


この記事の位置づけ

体制は前回と同じ。実装は Claude Code に委譲し、自分は PM 兼アーキテクト、設計壁打ちは Claude(チャット側)と行う。判断が必要な分岐は J-series(判断項目)として番号付きで表面化させ、勝手に決めさせない。調査(Phase 0)→ テスト先行(Phase 1)→ 実装(Phase 2)のフェーズ規律も継続。

今回の2日間で扱ったテーマは6つ。

  1. 外泊タグ — 小さな機能追加と「日付帰属」の規律
  2. 「全部取っているつもり」の崩壊 — フェッチ全量化と180万サンプルの救出
  3. ベースラインの3層化 — σの「狼少年化」を止める
  4. バックアップの3階層化 — NAS の SSH で撃沈してから R2 到達まで
  5. 全ドキュメント化と、食い違い7件の検出
  6. 通奏低音:AIの記憶とどう付き合うか

1. 外泊タグ — 表示日とデータ帰属日は1日ずれる

発端は素朴な要望だった。「外泊した夜は、寝室の CO2 センサーのデータが睡眠と無関係になる。記録するボタンが欲しい」。

設計はすぐ決まった。新しいボタンではなく、既存の daily_tags(飲酒/昼外食/夕外食)に night_away を1個足すだけ。UI もテーブルも通知フローも既にある。新規実装ゼロが正解、という YAGNI の教科書ケース。

面白いのはここからで、UI は「寝る日にタグを付ける」のが直感的だが、Oura の睡眠データは起床日に帰属する。つまり表示日とデータ帰属日が構造的に1日ずれる。この手の「日付境界の暗黙ズレ」は過去に UTC の date() バグで一度痛い目を見ているので、規約を1つに固めた——タグの date は就寝日のまま保存し、睡眠データとの突合時にのみ消費側で +1 シフトする。ストレージ層で「気を利かせて」+1 して保存するような実装は明示的に禁止。意味論が分裂すると必ず後で混乱する。

実装後の受け入れテストは実地で来た。実装の2日後にちょうど外泊があり、翌朝の AI インサイトは冒頭で「外泊による環境変化が睡眠とストレスに影響した可能性」と書き、前日まであった「室内環境(29.2℃・湿度54%)が睡眠に影響」の段落が消えた。CO2 411ppm(無人の部屋=ほぼ外気濃度)を睡眠と結びつける誤帰属が、注記1つで止まった。おまけにアクション欄には「明日は自宅で過ごし、寝室の温湿度を確認」——外泊を前提にした行動提案まで出てきた。

教訓: 日付境界をまたぐデータは、保存の意味論を1つに固定し、シフトは消費側に閉じ込める。そして機能の受け入れテストは、可能なら実地イベント(本物の外泊)で行う。テストがグリーンなことと、LLM の出力が実際に変わることは別の検証である。


2. 「全部取っているつもり」の崩壊 — 180万サンプルの救出

発覚の経緯が皮肉だった

ベースライン改善の議論中、私は Claude に「呼吸数も対象候補では」と言われて頷きかけた。だが直前に「実装済みのタスクを保留と思い込む」記憶エラーを2連発で見ていたので、「そもそもどの指標が取れているかの棚卸しから始めるべきでは」と切り返した。これが当たりだった。

棚卸し(実DBの全数調査)の結果:

  • 呼吸数の生値はそもそもフェッチしていなかった(詳細 /sleep エンドポイントが未接続)
  • heartrate 時系列(5分粒度)も未フェッチ
  • vO2Max、心血管年齢のエンドポイントも未接続
  • 一方で raw には temperature_deviation(皮膚温偏差)等の未パース資産が眠っていた

Data Maximal(生データ全保存)を設計思想に掲げながら、フェッチ範囲が API 提供全量の部分集合であることを、誰も検証していなかった。「全部取っている」は思い込みだった。

救出作戦と、その途中で見つけた地雷

Oura API の遡及可能期間を実測させると、heartrate は 2024-06-01 まで、詳細 sleep は 2024-01-02 まで遡れることが分かった。つまり今なら過去2年分を取り返せる。バックフィルを即決し、結果は heartrate 807日・約180万サンプル(欠測日ゼロ)、詳細 sleep 1,315 セッション、大気汚染データは 2020-11-27 まで6年分。

ただし2つの設計判断を挟んだ。

保存粒度: 180万サンプルを1行ずつ保存すると、行オーバーヘッド(id・ハッシュ等の管理コスト)を180万回払って DB が30MB→約1GBに膨らむ。1日分を配列で1行にまとめる「日次バッチ」なら808行・約340MB。保存される情報は完全に同一で、違うのは梱包だけ。ネジ4,000本を個包装で送るか1箱に詰めるかの違いである。

スナップショットの掛け算問題: yuru は毎日 DB 全テーブルの Parquet スナップショットを撮っている(世代保険)。ここに340MBの二度と変わらない確定データを入れると、同じバイト列を毎日再複製して年間126GBになる試算が出た。全量コピー方式は「テーブルが小さい」前提でだけ合理的で、その前提が今日崩れる。そこでバックフィル実行前に、追記onlyテーブルは増分のみ書く方式への改修を先行させた(watermark + 日次パート + manifest の和集合で任意日を復元可能な設計)。順序を逆にすると初日から345MBのスナップショットが走り始めるところだった。

「バグ」の半分は誤診だった

棚卸しは副産物としてパイプラインの不整合も炙り出した。total_sleep_duration 列が恒久NULL——パーサーが読んでいるキー名が raw に実在しないキーだった。しかもこの列は週次集計の対象に入っており、毎週 LLM に嘘の欠測を見せ続けていた。エラーを吐かず静かに間違うバグの新作である。

一方「vo2_max のフェッチ欠落」と報告された件は、追調査で誤診と判明した。エンドポイントは初回コミットから存在し、直近7日窓にデータが無かっただけ。さらに面白いことに、全期間を見ると28件のデータが実在した——出どころは Galaxy Watch。Samsung Health → Health Connect → Oura と流れて記録されていたもので、Oura リング自身の測定ではない。機器の出自が違うデータは Withings の機器段差(その2参照)と同じ扱いが必要なので、ベースライン対象からは外して参考系列とした。

教訓: 「取れているはず」は検証されるまで幻想である。新データ源を評価するときは「フェッチ範囲 = API 提供全量か」の差分確認を必須項目にする。そして過去データは後から買えない——遡及限界がある以上、保存だけ先に始めて活用は仮説ができてから、が Data Maximal の実践形。バグ報告も鵜呑みにしない。「エンドポイントが無い」と「窓にデータが無い」は別の現象である。


3. ベースラインの3層化 — σの「狼少年化」を止める

前々回のインシデント(同じ HRV 76 をインサイトは「良好」、ルール層は -2.0σ と評価した齟齬)以来、日次インサイトには個人ベースライン(28日平均とのδ/σ)が注入されている。だが指標が増えると新しい問題が見えてきた。σの信頼度が指標によって全く違うのだ。

  • 睡眠・心拍系は測定条件が毎日一定で、日次σが素直に効く
  • 活動系(歩数など)は平日/週末の生活周期でσが荒れる。「歩数 +1.64σ」は日曜にたくさん歩いただけかもしれない
  • 体組成系は日内変動(水分・食事タイミング)がノイズで、意味があるのは週〜月のトレンドだけ。「体重 +1.2σ」を毎日報告されても飲水で説明がつく

全指標を同列にσ評価させると、説明のつく逸脱が毎日「明確な逸脱」として報告される——狼少年化して、本当のシグナルが埋もれる。

対策はσの信頼度で3層に分類し、層ごとに LLM への渡し方を変えること。

  • 層1(健康シグナル): δ/σをフル提供し、逸脱を明示的に評価させる。HRV・安静時心拍・レディネス・睡眠・立位心拍・SpO2・呼吸数。皮膚温偏差は Oura 自身が既に偏差値として出しているのでσを掛けず実値のまま渡す(偏差の偏差は意味不明になる)
  • 層2(活動): 同じ数値を渡すが「生活周期でσが荒れやすいので参考程度に」という指針を1行添える
  • 層3(体組成): 28日平均と方向(増加/横ばい/減少の3値)のみ。日次δ/σは最初から計算結果に含めない(プロンプト整形で落とすのではなく。渡っていない数値が dict に残ると将来の消費側が誤読する)

トレンド判定は「28日窓の前半14日と後半14日の平均差が 0.5×sd を超えたら方向あり」という単純な方式にした。最小二乗の傾きも検討したが、「週0.11kg減」という精度の錯覚を与える数字を、23日中5日欠測のデータから出すのは過剰適合気味で、層3を作った動機(細かい数字で騒がない)と逆行する。外部仕様を3値だけにしておけば、内部の判定方式は後で差し替え可能という保険も付けた。

実装後の初回プロンプトが、設計の正しさをいきなり実証した。その日は歩数 +1.64σ・活動カロリー +1.55σ——旧構造なら「明確な逸脱」として騒がれた値が、層2に収まって静かになっていた。

一方で新入りの呼吸数が -4.43σ という派手な数字を出した。sd が 0.29 と極端に狭い指標なので小さな実差が大きなσになるのだが、よく見るとこの初日データは外泊+飲酒の夜——新パイプラインの初お披露目が、今月最も非典型的な夜に当たっていた。指標特性と特異日の交絡は切り分けられないので、平常夜が数日溜まるまで判断保留とした。焦って層を動かさない。

教訓: 統計値の「信頼度」は指標の測定条件で決まる。全指標に同じ統計処理を適用するのは平等ではなく雑である。そして LLM に渡さないと決めた数値は、プロンプト整形で隠すのではなく最初から作らない——存在する数値はいつか誰かが読む。


4. バックアップの3階層化 — SSHで撃沈してからR2に届くまで

「潤沢なバックアップ」は全部同じ筐体にあった

きっかけは雑談だった。スナップショットの話の流れで「今は自動的に潤沢なバックアップを取っているということ?」と聞かれ、答えているうちに気づいた。日次スナップショットも raw_data_lake も DB も、全部 Mac Studio の同じディスクにある。ディスク故障・筐体障害・火災盗難で、多重化した保険が全部一緒に死ぬ。heartrate 180万サンプルを救出した直後に、その全部が単一障害点の上に載っていた。

NAS への道は3回折れた

家に Synology NAS(DS420j)があったので、Mac→NAS の夜間 rsync を組むことにした。ここからが苦難で、教科書通りの「rsync over SSH + 鍵認証」が3回折れた。

1回目: ssh-copy-id が Permission denied。パスワードは合っている。 2回目: DSM のユーザー設定を見直しても原因なし。 3回目: Claude Code に管理者権限で NAS 内部を診断させてようやく真因が判明——DSM は管理者以外のユーザーにログインシェルを与えない(backup-agent のシェルが /sbin/nologin)。さらにホームが 0777 で sshd の StrictModes に抵触。つまりパスワードが正しくてもセッションが張れない構造だった。

root で /etc/passwd を書き換えれば通るが、Claude Code の報告書に決定的な一文があった——「DSM の UI でこのユーザーを編集すると /etc/passwd が再生成され、シェルが nologin に戻る可能性があります」。OS の想定外の状態を手で維持し続ける構成は、いつか黙って壊れる。バックアップ基盤に一番あってはならない性質だ。

そこで方式を変えた。rsync デーモン方式——SSH を経由せず、rsync 自身がポート873で待ち受ける、DSM が GUI で公式サポートする受け入れ方式。「LAN内で暗号化されない」という減点はあるが、自宅LAN内の Mac→NAS 区間だけの話で、「DSM の仕様と喧嘩しない堅牢さ」の方が暗号化より重いと判断した。暗号化が本当に必要な区間(NAS→インターネット)は次の階層が担う。

実装は一晩で完了した。ライブ DB は WAL の取りこぼしがあるので生 rsync せず sqlite3 の backup API で断面を作ってから転送、初回31GB を11分、以後の増分は10.8秒(229MB の DB も差分転送で 517KB、speedup 383倍)。復元ツールも新旧両方式のスナップショットで実地リハーサル済み。

3階層目:R2 の罠と 0 円

サイト障害(火災・盗難)には NAS でも無力なので、3階層目としてクラウドを足した。選んだのは Cloudflare R2。理由は egress(取り出し)が恒久無料——バックアップは「復元する日=災害の日に請求が来る」構造が多い中、これはバックアップの意味論と相性が良い——と、無料枠10GBで yuru の規模(圧縮後255MB、年数百MB成長)なら恒久的に0円で収まること。NAS の Hyper Backup が S3 互換ストレージを転送先にできるので、Mac 側には何も足さずに済む。

ただし罠が1つ。スコープ最小化の教科書通りに「対象バケット限定の Object Read & Write トークン」を作ったら、DSM のウィザードがバケット一覧の取得(ListBuckets)で弾かれて先に進めない。R2 の S3 互換 API では一覧取得に Admin 権限が要るため、結局トークンを3回作り直して Admin Read & Write に落ち着いた。権限を正しく絞った結果ツールが使えない、というセキュリティ実務あるあるである(バケットは1つだけ・トークンはDSM内のみ・予算アラート設定済み、で妥協した)。

最後の設計判断は対象の絞り込み。Mac→NAS は31GB全量だが、その99.1%は移行時に作った28.85GBの静的ディスクイメージ1本だった。これは Mac+NAS の2箇所保持で十分なので、R2 に上げるのは「本当に買い戻せないデータ」(DB断面・スナップショット・設定・移行成果物)だけの5フォルダに絞った。クライアント側暗号化を有効にし、パスフレーズは 1Password + 紙(Mac とも NAS とも運命を共にしない場所)の二重保管。

3階層バックアップの構成。下の層ほど、遠くの災害から守る

最終形: Mac 内増分スナップショット → NAS(毎日4:12、rsyncデーモン)→ R2(毎日5:00、暗号化、Smart Recycle 30世代、土曜に整合性チェック)。2年分の健康データが、起票から24時間強で単一筐体を出た。

教訓: バックアップの多重化は「別の場所」でなければ多重化ではない。方式選定では、賢い構成より「OS の想定内で壊れない」構成を取る。そして権限の最小化は、ツールの要求仕様(ListBuckets等)と衝突したら実利側に倒してよい——多層の緩和策を添えて。


5. 全ドキュメント化 — 食い違い7件が最大の成果だった

2日間で実装が積み上がり、設計判断の多くがチャットログと記憶にしか存在しなくなった。そこで docs/ 配下に5本(DATA_MODEL / ARCHITECTURE / OPERATIONS / DECISIONS / ROADMAP)を書き下ろすことにした。

書き手は Claude Code に固定した。理由は次章に書く「記憶問題」そのもので、チャット側の Claude が記憶ベースで書くと誤りが混入する。Claude Code ならコードと DB という一次ソースを見ながら書ける。私が用意したのはブリーフと「判断記録の素材」で、素材には「チャット由来の二次情報であり、現物と食い違えば現物が正。食い違いの発見自体が本タスクの価値」と明記した。つまり、ドキュメント化タスクを私(とAI)の記憶の検査装置として設計した。

検査は機能した。食い違い7件:

  1. net-sentinel(ネットワーク監視)が未稼働 — 記憶では「稼働中」。実際は plist が未設置で、2週間前から一度も走っていなかった(後述)
  2. バックアップの週1サマリー通知が未実装 — J-series で裁定済みの機能が、実装から漏れていた
  3. daily_intake() の「不変性」の意味論が記憶と現物で不一致
  4. vo2_max は29件でなく28件
  5. tier 分類の所属ファイルが記憶と違う
  6. Curcumin の説明文に「捏造」の残骸(後述)
  7. R2 整合性チェックの詳細確定

特に1と2の組み合わせが痛烈だった。週1サマリーを裁定した動機は「黙って止まっているジョブの検知」——そして net-sentinel がまさに黙って止まっていたのに、その対策として決めた機能が実装から漏れていた。「沈黙=正常」運用の危険が、二重に実証された形である。

net-sentinel の死因:「完成」と「稼働」は別の状態

net-sentinel の停止原因を法医学的に調査させると(ファイルの mtime と git 履歴の突き合わせ)、意外な真相が出た。「8/4に停止した」のではない。一度も稼働していなかった。8/4 の夜、ツールは完成し、webhook の不具合まで直し、最終コミットを打ち——そして launchd への登録(root 権限が要る、私の作業)だけが実施されないまま、全員が「完了」と認識して2週間が過ぎた。

これは分業体制の構造的な穴だ。実装は AI、root 作業だけ人間、というハンドオフの最後の1手順が、タスク管理のどこにも載っていなかった。DECISIONS.md に刻んだ教訓——「ツール完成と稼働開始は別の状態。root 作業が挟まるハンドオフは、完了確認までをタスクに含める」。この教訓は即日で初適用され、週1サマリーと net-sentinel の launchd 登録は報告を受けたその場で実行した(net-sentinel は2週間の眠りから覚めて15分後に初仕事をした)。

ドキュメントの腐敗防止

ドキュメントは書いた瞬間から腐り始める。実際、調査中に README が既に腐っていた(旧スナップショット方式のまま)ことも見つかった。対策として CLAUDE.md に規約を1行入れた——「実装変更が docs/ の記述に影響する場合、同一コミットで該当ドキュメントを更新する」。全ドキュメントの冒頭には Last verified 日付。完璧な鮮度は無理でも、腐敗の速度と検出可能性は制御できる。


6. 通奏低音:AIの記憶とどう付き合うか

さて、本題である。この2日間、私は Claude の「記憶」の誤りを立て続けに踏んだ。確認できたものだけでも:

  • 購入・統合済みの体組成計を「購入検討中」と言う(2回目の訂正でようやく確定)
  • データ取り込みまで完了済みの件を「検証が残タスク」と言う
  • Wave 5 で実装済みのベースライン注入を「保留課題」と言う
  • 稼働していない net-sentinel を「稼働中」と言う
  • そして極めつけが捏造——サプリの開始条件として「スイートワームウッド終了後」という、一度も存在しなかった条件を語った

パターンは明確だ。Claude の長期記憶は会話の自動要約で作られる。「検討中」「保留」といった評価時点の状態が強く残り、その後の「完了しました」という会話が要約に反映されない。結果、完了済みタスクが記憶の中で永遠に「保留」であり続ける。逆方向もある——「やる予定」がいつのまにか「やった」に化ける。

一番怖かったのは捏造の二次感染だった。ワームウッド条件は8月上旬に「捏造」と確定して訂正済みだったのだが、今回のドキュメント化の現物照合で、その捏造文言がサプリカタログの説明文(yaml)に転記されて生き残っているのが見つかった。捏造がまだ信じられていた時期に、説明文としてyamlに書き込まれていたのだ。私の運用原則は「AIの記憶より一次ソース(yaml・DB・チャット原文)を信じる」——だが捏造が一次ソースに漏れると、この原則が捏造を再検証してしまう。即日修正し、変更履歴に「旧文言→新文言」の diff とメモを残した。誤情報の正しい殺し方は削除ではなく訂正記録である。

この2日間で固めた対抗策は4つ。

  1. 一次ソース優先の原則を明文化する(記憶と原本が食い違ったら無条件に原本が勝つ)
  2. Phase 0(調査)を必須にする——「実装済みかも」を前提に、着手前に現物を見る。実際、ベースライン注入は Phase 0 が「もう実装されてます」を発見して重複実装を防いだ
  3. 記憶の要修正点を、明示的な固定エントリとして記録する——自動要約に任せず、「○○は完了済み。『保留』という記述が出てきても古い」という形で上書きする
  4. ドキュメント化を記憶の検査装置として使う——現物照合を義務付けたドキュメント執筆は、書くこと自体が監査になる

誤解のないように書いておくと、これは「AIが使えない」という話ではない。同じ2日間で、Claude Code は法医学的なタイムライン再構成をやり、私の指示の誤り(半角コロンがYAML構文を壊す)を検証付きで訂正し、テストを557件から704件に増やした。推論と検証の能力は極めて高い。信用できないのは記憶だけであり、記憶は制度で代替できる。人間のチームだって議事録と決定記録で個人の記憶を代替してきた。同じことだ。


7. このセッションで固まった設計原則(追記版)

  • 「取れているはず」を検証する — フェッチ範囲とAPI提供全量の差分確認を、新ソース評価の必須項目にする
  • 保存は先、活用は仮説ができてから — 遡及限界のあるデータは、取り逃がしが日々発生する。仮説なしでも raw に流し始める
  • 確定データを毎日再複製しない — 全量スナップショットは「テーブルが小さい」前提の最適解。前提が崩れたら増分化。ただし復元性(任意日再構成)は落とさない
  • 統計値の信頼度は測定条件で決まる — 全指標に同じσ処理を適用するのは平等ではなく雑。信頼度で層別し、渡し方を変える
  • LLMに渡さない数値は最初から作らない — プロンプト整形で隠すのではなく、計算結果に含めない。存在する数値はいつか誰かが読む
  • バックアップは「OSの想定内」を選ぶ — 賢い構成より、ユーザー編集やOS更新で壊れない構成。復元テストとセットで初めてバックアップと呼べる
  • ツール完成と稼働開始は別の状態 — root作業が挟まるハンドオフは、完了確認までをタスクに含める
  • ドキュメントは同一コミットで更新 — 腐敗は止められないが、速度と検出可能性は制御できる
  • AIの記憶は制度で代替する — 一次ソース優先・着手前調査・明示的な記憶修正・現物照合付きドキュメント化

アンチパターン集(追記)

  • 「全部取っている」という未検証の思い込み — フェッチ範囲は API 全量の部分集合かもしれない
  • 恒久NULL列が集計に混ざり続ける — エラーを吐かず、毎週 LLM に嘘の欠測を見せる
  • 表示日とデータ帰属日の暗黙ズレ — 保存の意味論を固定し、シフトは消費側のみ
  • 全指標一律のσ評価 — 生活周期や日内変動でσが荒れる指標が狼少年化する
  • OSの想定外状態を手で維持するバックアップ — nologin書き換え等は、UI操作やアップデートで黙って戻る
  • root作業だけ残して「完了」 — 登録されなかった監視ツールは2週間黙って眠る
  • AI記憶の捏造が一次ソースに漏れる — 「原本優先」の原則が捏造を再検証する前に、訂正記録で殺す

おわりに:自走フェーズへ

2日間の決算はこうなった。テスト557→704件。docs 5本と腐敗防止規約。バックアップ3階層(+週1の生存報告)。眠っていた180万サンプルの救出。σ3層のベースライン。そして、現物との食い違い7件の検出と、その対抗制度。

面白いのは、この2日間の起点が「外泊を記録するボタンが欲しい」という小さな要望だったことだ。そこから日付帰属の規律→ベースライン議論→棚卸し→未取得データの発覚→救出→スナップショット問題→バックアップ→ドキュメント化と、一本の糸で繋がって転がった。小さな要望の実装を丁寧にやると、システムの本当の弱点が芋づるで出てくる。

これで能動的な実装キューは空になった。次に手を動かすのは、魚油の増量実験の評価が満ちたときか、観察中の指標(呼吸数のσ、次のネットワーク障害での net-sentinel 初検知)が何か言ってきたときだ。それまで yuru は毎朝インサイトを届け、毎晩自分を3階層にバックアップし、日曜の朝に「今週も生きていた」と報告してくる。

その2の締めで「次は時間が仕事をする」と書いた。今回それに足すなら——時間に仕事をさせるには、記憶を時間に負けない場所に置く必要がある。コードはテストに、判断はDECISIONSに、データは3階層に、そしてAIの記憶の弱点は、それを前提とした制度に。どこかが忘れても、どこかが覚えている。そういう構造にしてから、安心して忘れることにする。


このシステムはローカル完結・個人利用。コードは private リポジトリ、健康データは一切外部に出していない(クラウドバックアップはクライアント側暗号化済み)。「個人の健康データ基盤『yuru』をゼロから作り直して運用に乗せるまでの全記録」「運用記 その2」の続編。