bundle

Bundle 仕様 (schema 1.0)

Studio と Workspace の唯一の越境物。物理構造・manifest.json 完全スキーマ・Parquet 列定義・integrity 検証手順・canonical hash 計算・派生 (append_window etc) と PII 禁止フィールドを定義します。

Bundle の物理構造

Bundle は object storage 上の不変プレフィクスとして表現されます。本番は GCS、開発は MinIO (S3 互換)。

gs://{bucket}/bundles/{bundle_id}/
├── manifest.json                      必須: バンドル全体のメタデータと整合性ハッシュ
├── _READY                             必須: 登録完了マーカー (空ファイル)
├── windows/
│   ├── win_0001.parquet               ECG 生波形 × Window 0 (全 lead)
│   ├── win_0002.parquet               ECG 生波形 × Window 1
│   └── ...
├── attached/
│   ├── acc_win_0001.parquet           加速度 3 軸 (25 Hz, G 単位)
│   ├── opt_win_0001.parquet           PPG 4ch (50 Hz, raw counts)
│   ├── bioz_win_0001.parquet          BioZ mag/phase (1 Hz)
│   ├── vital_win_0001.parquet         皮膚温・電池・lead-off (1 Hz)
│   └── ...
├── derived/
│   ├── r_peaks.parquet                R ピーク列 (全 Window 通し)
│   ├── rr_hrv.parquet                 RR 間隔 + rolling HRV
│   ├── invalid_intervals.parquet      無効区間 (体動・lead-off 等)
│   └── beat_labels.parquet            拍ラベル (v1 はスキーマのみ、行ゼロ)
└── pyramid/
    ├── L0/                            原信号チャンク (256 サンプル/チャンク)
    │   └── win_0001.parquet
    ├── L1/                            250 ms min/max/mean
    ├── L2/                            1 s
    └── L3/                            10 s

命名規則 (重要):

  • {bundle_id}bdl_ prefix 付き ULID (例: bdl_01HZMXXX...)。
  • Window ファイル名の連番は 1-based 4 桁ゼロ埋め (win_0001, win_0002, …)。
  • manifest の windows[].ordinal0-based で、ファイル名 win_{ordinal+1:04d}1 ずれる 点に注意。
  • _READY は空ファイル。Registry は manifest 検証と全 hash 検証が通った後にのみ書く。_READY が無い bundle は ready 扱いしてはならない

manifest.json のトップレベルキー

UTF-8 JSON ファイル。15 個のトップレベルキーを持ちます。前方互換のため x- prefix の拡張のみ例外的に許可されます。

キー必須説明
schema_version string 必須 ^\d+\.\d+$ 形式。現行 Phase 1: "1.0"
bundle_id string 必須 bdl_ prefix ULID
tenant_id string 必須 tnt_ prefix ULID
subject object 必須 §subject (canonical_key / public_id / cohort_tags)
source object 必須 §source (source_key / dataset / table / signal_family / sample_rate_hz / leads)
windows array 必須 §windows (要素数 ≥ 1)
signals object 必須 §signals (ecg / attached の Parquet 相対パス)
derived object 必須 §derived (r_peaks / rr_hrv / invalid_intervals / beat_labels)
preprocessing object 必須 §preprocessing (preset_id / hp_hz / lp_hz / notch_hz / detector / hrv_window_sec)
quality object 必須 §quality (invalid / wearing / leadoff ratio)
integrity object 必須 §integrity (files[] + manifest_sha256)
lineage object 必須 §lineage (parent / hashes / versions)
privacy object 必須 §privacy (pii_redaction_version / signed_url_ttl_sec / audit_event_ids)
time_semantics string 必須 定数 "epoch_ms_half_open" (schema_version 1.x で不変)
created_at string 必須 ISO 8601 UTC (例: "2026-05-25T12:34:56.789Z")
built_by string 任意 usr_ prefix ULID。ジョブ起動ユーザ
cost_usd number 任意 BQ クエリの実コスト見積もり (USD)

subject オブジェクト

raw uid・email・SA 認証情報は絶対に現れてはなりません。 (詳細は §PII 禁止)

canonical_key
必須 · string (64 文字 hex) · HMAC-SHA256(tenant_secret, uid) の hex digest。PII-safe な不変識別子。canonical hash の入力として subject_canonical_key に使われる。
public_id
必須 · string · stable_hash(uid, tenant_salt) で算出した sub_ prefix ULID。UI 表示・Registry API で使う。raw uid に逆算不可。
cohort_tags
必須 · array of string · コホートタグ。空配列可。例: ["XHRO-04", "XH015"]

source オブジェクト

source_key
必須 · allowlist 通過済みの BQ ソース識別子 (例: "xhro_04.eeg")
dataset
必須 · BQ データセット名 (例: "xhro_04")
table
必須 · BQ テーブル名 (例: "eeg")
signal_family
必須 · "ecg" (Phase 1 固定)。将来: "eeg" / "ppg" など
sample_rate_hz
必須 · integer · 主信号サンプリング周波数 (Hz)。XHRO ECG: 250
leads
必須 · array of string · 使用誘導リスト。例: ["ch1", "ch2"]

XHRO 注意: eeg テーブルには ECG 用 (ch1, ch2) と EEG 用 (ch3, ch4) が同居します。Phase 1 では leads["ch1", "ch2"] のみを入れ、EEG チャンネルは含めません。

windows 配列要素

各要素は 半開区間 [start_ms, end_ms) で定義された Window を表します。

ordinal
必須 · integer · 0-based インデックス。ファイル名 win_{ordinal+1:04d} と 1 ずれる。
label
任意 · string or null · ユーザ定義ラベル (例: "rest-1")。canonical hash 計算には含まれない (ラベル違いの重複を防ぐ)。
start_ms
必須 · integer · 区間開始 epoch ms (inclusive)
end_ms
必須 · integer · 区間終了 epoch ms (exclusive)

time_semantics: "epoch_ms_half_open"[start_ms, end_ms) が常に半開区間であることを示します。Parquet の ts_ms カラムも start_ms <= ts_ms < end_ms を満たします。隣接 Window でサンプル重複なし ([t0,t1)[t1,t2) は排他)。

Parquet カラムスキーマ

各 Parquet ファイルの列定義。Parquet ファイルメタデータ (key_value_metadata) には sample_rate_hz / window_ordinal / bundle_id / signal_family を必ず付与します。

ファイル内容カラム
windows/win_NNNN.parquet ECG 生波形 × Window N uid_canonical / ts_ms / lead / value_uv
attached/acc_win_NNNN.parquet 加速度 3 軸 (25 Hz, G 単位) ts_ms / x / y / z
attached/opt_win_NNNN.parquet PPG 4ch (50 Hz, raw counts) ts_ms / ch1_ir / ch2_r / ch3_g / ch4_b
attached/bioz_win_NNNN.parquet BioZ mag/phase (1 Hz) ts_ms / mag (Ω) / phase (rad)
attached/vital_win_NNNN.parquet 皮膚温・電池・lead-off (1 Hz) ts_ms / temp_c / fg / loff_ch1..4
derived/r_peaks.parquet R ピーク列 (全 Window 通し) window_id / sample_idx / ts_ms / lead / confidence
derived/rr_hrv.parquet RR 間隔 + rolling HRV window_id / t_ms / rr_ms / sdnn / rmssd / pnn50 / lf / hf
derived/invalid_intervals.parquet 無効区間 (体動・lead-off 等) window_id / start_ms / end_ms / reason
derived/beat_labels.parquet 拍ラベル (v1 はスキーマのみ、行ゼロ) window_id / sample_idx / ts_ms / lead / label / annotator / confidence
pyramid/L0/win_NNNN.parquet 原信号チャンク (256 sample/chunk) window_id / chunk_idx / samples (LIST[DOUBLE])
pyramid/L{1,2,3}/*.parquet min/max/mean 集約 (250ms / 1s / 10s) window_id / bin_idx / min / max / mean

integrity オブジェクト

Bundle の完全性検証用。files 配列には manifest.json 以外の全ファイル (windows/, attached/, derived/, pyramid/, _READY) のエントリが並びます。

"integrity": {
  "files": [
    {
      "rel_path": "windows/win_0001.parquet",
      "bytes": 1245678,
      "sha256": "a1b2c3d4e5f6...64chars"
    },
    {
      "rel_path": "_READY",
      "bytes": 0,
      "sha256": "e3b0c44298fc...SHA256 of empty"
    }
    // ... 全ファイル分
  ],
  "manifest_sha256": "0f1e2d3c4b5a...64chars"
}

manifest_sha256 の計算手順

manifest.json 自身の内容から計算されます。循環参照を避けるため、integrity.manifest_sha256 フィールド自体を除外して計算します。

  1. manifest dict を組み立て、integrity.manifest_sha256 キーを null または省略した状態にする
  2. json.dumps(manifest_dict, sort_keys=True, separators=(",", ":")) でシリアライズ
  3. その UTF-8 バイト列の SHA-256 hex digest を manifest_sha256 として格納
  4. manifest.json として書き出す

Workspace 側の検証コード (4 step)

# Step 1: manifest.json をパース
import json, hashlib, os, copy

with open("manifest.json", "rb") as f:
    raw_bytes = f.read()
manifest = json.loads(raw_bytes)
# schema_version 確認 (§schema-version)

# Step 2: 各ファイルの sha256 + bytes を検証
for entry in manifest["integrity"]["files"]:
    rel_path = entry["rel_path"]
    with open(rel_path, "rb") as f:
        data = f.read()
    assert hashlib.sha256(data).hexdigest() == entry["sha256"]
    assert len(data) == entry["bytes"]

# Step 3: _READY マーカーの存在
assert os.path.exists("_READY"), "Bundle is not committed (_READY missing)"

# Step 4: manifest_sha256 の検証 (推奨)
m = copy.deepcopy(manifest)
saved_hash = m["integrity"].pop("manifest_sha256", None)
serialised = json.dumps(m, sort_keys=True, separators=(",", ":")).encode()
assert hashlib.sha256(serialised).hexdigest() == saved_hash

lineage オブジェクト

Bundle の系譜情報。Registry の bundles.canonical_hash と一致する canonical_hash を含みます。

parent_bundle_id
任意 · string or null · 派生元 Bundle ID
derive_kind
任意 · string or null · "append_window" / "re_preset" / "attach_signal"
bq_query_hash
必須 · BQ SQL を normalize して SHA-256 した hex digest
dry_run_id
任意 · 対応する dry-run ジョブの ID
code_version
必須 · ecg-core パッケージバージョン。例: "ecg-core@0.1.0"
build_version
必須 · bundle-build パッケージバージョン
template_version
必須 · BQ SQL テンプレバージョン。例: "window_extract@0.1.0"
settings_snapshot_hash
必須 · 解決済み設定値の SHA-256
resolved_plan_hash
必須 · ResolvedBuildPlan の canonical_hash
canonical_hash
必須 · idempotency キー。Registry の bundles.canonical_hash と一致

canonical_hash 計算の正規化手順

canonical.py::resolved_plan_hash(plan) が計算します。入力は ResolvedBuildPlan.model_dump() で取得した dict。計算前に以下の正規化を行います。

  1. windows_canonicalstart_ms で昇順ソート
  2. attached_signals をアルファベット昇順ソート
  3. preset_params.leads をアルファベット昇順ソート
  4. derived_feature_flags をキーでアルファベット昇順ソート

その後 json.dumps(normalised, sort_keys=True, separators=(",", ":"), default=str) でシリアライズし、UTF-8 バイト列の SHA-256 hex digest とします。

ResolvedBuildPlan には以下が含まれます: source_key, subject_canonical_key (HMAC、生 uid 不可), windows_canonical, attached_signals, preset_id, preset_params (hp/lp/notch/detector/rr_min/rr_max/hrv_window/leads), ecg_core_version, bundle_build_version, sql_template_version, schema_version, settings_snapshot_hash, derived_feature_flags

派生 Bundle (常に新 ID を発行)

Bundle は 完全 immutable。Window 追加・preset 変更・付帯信号追加は どれも新 bundle_id の発行 で表現し、親側の manifest は一切書き換えません。「同一 Bundle の追記版」という表現は使いません (用語の混乱を避けるため)。

append_window

同一 source / subject / preset で Window を追加した子 Bundle。例: bdl_v1 に夜間 Window を 3 本追加して bdl_v1_append_window を作る。

re_preset

同じ Window 群に別プリセットをかけた子 Bundle。例: holter-overnightnoisy-environment

attach_signal

同じ Window に新しい付帯信号を加えた子 Bundle。例: ACC + PPG だった親に BioZ を追加。

  • 子 Bundle の manifest には parent_bundle_idderive_kind を必ず記録 (lineage.parent)。
  • 親 Bundle は子の存在を 知らない (親は閉じている。系譜は Registry の bundle_lineage ビューで辿る)。
  • 削除: 物理削除は admin のみ。通常は archived フラグ。被験者 erasure 時のカスケード規則は別途定義。

stale 判定

Workspace の evidence は Bundle ID をキャッシュキーに含むため、親 Bundle を直接書き換える操作はありません。stale が起きるのは:

  • Project に紐付く Bundle 集合 (linked_bundles) を 新 ID に差し替えた 時 (例: bdl_v1bdl_v1_re_preset)
  • 既存 Bundle 集合に 新派生 Bundle を追加 した時 (例: append_windowbdl_v2 を追加)
stale(evidence) := evidence.bundle_set_hash != current_project.bundle_set_hash
              OR  any(b in evidence.bundles where b.archived == true)

stale は 再計算しません (FR-W9)。UI には黄色バッジで「依存 Bundle が更新されています — 再実行」ボタンを出すのみ。Window 単位の部分 stale は v1 ではサポートしません (Project = Bundle 集合 単位での粒度に揃える)。

schema_version マイグレーションポリシー

MAJOR バンプ (例: 1.x2.0)

  • manifest の後方互換を壊す変更: トップレベルキーの削除、型変更、必須フィールドの意味変更。
  • 既存 Bundle に対する再建構 (re-build) または status = "archived_legacy" への遷移が必要。
  • Workspace は schema_version の MAJOR が未知であればエラーを発生させ、読み取りを拒否する。

MINOR バンプ (例: 1.01.1)

  • 加法的変更のみ: 新しい任意フィールドの追加、qualityprivacy の拡張。
  • 旧 Bundle を再ビルドする必要はない。
  • Workspace は 知らないフィールドを無視 し、既知のフィールドだけを読む (Forward-compatible)。

パッチバンプは存在しません。schema_versionMAJOR.MINOR の 2 段のみ (^\d+\.\d+$)。

Workspace の unknown-field 扱い (必須ルール)

  1. Strict ではなく Partial パース: BundleManifest の Pydantic モデルは model_config = ConfigDict(extra="ignore") または extra="allow" を使う。extra="forbid" を設定してはならない。
  2. ラウンドトリップ保存: Workspace が manifest を読み込んで再書き込みする場合、読み取り時に無視したフィールドも保存し直す。Pydantic の model_dump(exclude_unset=False) + 元の raw dict をマージする方式が安全。
  3. unknown-field の記録: Workspace ログに MINOR 不一致で警告を出すことを推奨するが、エラーにしてはならない。

PII 禁止フィールド

以下のデータは Bundle の いかなる場所にも 平文で出現してはなりません。これはドキュメントポリシーであると同時に、CI の test_pii_grep で自動検証されます。

場所禁止データ正しい代替
manifest.json (subject) Firebase Auth raw uid (28 文字英数字) subject.canonical_key (HMAC) または subject.public_id
manifest.json (subject) 運用メール (u004@xhro.org 等) 含めない
manifest.json (任意) SA 秘密鍵 JSON (.keys/ 配下) 含めない
manifest.json (任意) 署名付き URL (?X-Goog-Signature=...) 含めない
lineage lineage に raw uid を使う subject_canonical_key は HMAC であること
windows/win_*.parquet uid_canonical カラムに Firebase raw uid HMAC または public_id を使う
attached/* BQ の uid カラムを Parquet に出力 uid カラムは含めない
derived/* uid カラム 禁止
Parquet key_value_metadata raw uid / email / SA credentials 含めない
bundle_audit.payload / audit_log.payload raw uid / email / signed URL 含めない

CI grep テストの対象

  • manifest.json (全 Bundle)
  • Parquet ファイルメタデータ
  • Parquet の文字列カラム値
  • Copilot 出力ストリーム
  • audit_log.payload
  • SSE progress events

manifest.json 完全例

5 分 × 2 Window の ECG-only Bundle に ACC を付帯させた Phase 1 典型例です。JSON コメントは説明用で、実際の JSON には含まれません。

{
  "schema_version": "1.0",
  "bundle_id": "bdl_01HZMX9P2QR3S4T5U6V7W8X9Y",
  "tenant_id": "tnt_01HZMX0000000000000000001",
  "time_semantics": "epoch_ms_half_open",
  "created_at": "2026-05-25T12:34:56.789Z",
  "built_by": "usr_01HZMX0000000000000000099",
  "cost_usd": 0.0023,

  "subject": {
    "canonical_key": "a3f8c2e1d4b9071256f3e8c4a1d9b27e5f6c3a2b1d8e4f7c9b0a3d2e1f5c8b6a",
    "public_id": "sub_01HZMX9ABCDE00000000XH015",
    "cohort_tags": ["XHRO-04", "XH015"]
  },

  "source": {
    "source_key": "xhro_04.eeg",
    "dataset": "xhro_04",
    "table": "eeg",
    "signal_family": "ecg",
    "sample_rate_hz": 250,
    "leads": ["ch1", "ch2"]
  },

  "windows": [
    { "ordinal": 0, "label": "rest-1",
      "start_ms": 1699578000000, "end_ms": 1699578300000 },
    { "ordinal": 1, "label": "rest-2",
      "start_ms": 1699578300000, "end_ms": 1699578600000 }
  ],

  "signals": {
    "ecg": {
      "windows": [
        { "ordinal": 0, "rel_path": "windows/win_0001.parquet" },
        { "ordinal": 1, "rel_path": "windows/win_0002.parquet" }
      ]
    },
    "attached": {
      "acc": [
        { "ordinal": 0, "rel_path": "attached/acc_win_0001.parquet" },
        { "ordinal": 1, "rel_path": "attached/acc_win_0002.parquet" }
      ]
    }
  },

  "derived": {
    "r_peaks": {
      "rel_path": "derived/r_peaks.parquet",
      "algorithm": "pan_tompkins_v1",
      "algorithm_version": "ecg-core@0.1.0"
    },
    "rr_hrv": {
      "rel_path": "derived/rr_hrv.parquet",
      "hrv_window_sec": 300
    },
    "invalid_intervals": { "rel_path": "derived/invalid_intervals.parquet" },
    "beat_labels":       { "rel_path": "derived/beat_labels.parquet", "schema": "mit_bih_v1" }
  },

  "preprocessing": {
    "preset_id": "xhro-default",
    "hp_hz": 0.5, "lp_hz": 40.0, "notch_hz": 50,
    "detector": "pan_tompkins_v1",
    "detector_params": { "rr_min_ms": 200.0, "rr_max_ms": 2000.0 },
    "hrv_window_sec": 300
  },

  "quality": {
    "invalid_intervals_count": 3,
    "invalid_ratio": 0.012,
    "wearing_ratio": 0.997,
    "leadoff_ratio": 0.003
  },

  "integrity": {
    "files": [
      { "rel_path": "windows/win_0001.parquet", "bytes": 1245678, "sha256": "a1b2..." },
      { "rel_path": "windows/win_0002.parquet", "bytes": 1243001, "sha256": "b2c3..." },
      { "rel_path": "attached/acc_win_0001.parquet", "bytes": 18432, "sha256": "c3d4..." },
      { "rel_path": "derived/r_peaks.parquet", "bytes": 4096, "sha256": "e5f6..." },
      { "rel_path": "_READY", "bytes": 0,
        "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }
    ],
    "manifest_sha256": "0f1e2d3c4b5a..."
  },

  "lineage": {
    "parent_bundle_id": null,
    "derive_kind": null,
    "bq_query_hash":          "9a8b7c6d5e4f...",
    "dry_run_id":             "job_01HZMXDRYRUN000000000001",
    "code_version":           "ecg-core@0.1.0",
    "build_version":          "bundle-build@0.1.0",
    "template_version":       "window_extract@0.1.0",
    "settings_snapshot_hash": "1234abcd5678...",
    "resolved_plan_hash":     "fedcba987654...",
    "canonical_hash":         "0123456789ab..."
  },

  "privacy": {
    "pii_redaction_version": "v1",
    "signed_url_ttl_sec": 900,
    "audit_event_ids": ["1234567", "1234568"]
  }
}

本仕様 vs 実装の不整合 (要フォローアップ)

本ドキュメント作成時点で実装コードとの差異として確認された不整合の一覧です。実装を本仕様に合わせる PR が必要です (Workspace 側は前方互換ルール (§schema-version) により欠損カラムを NaN で扱えば動作します)。

場所現実装の状態本仕様の要求
manifest.py l.57 / ManifestFileEntry.path integrity.files[].path キー名を使用 本仕様では rel_path。Pydantic モデルか本仕様のどちらかを揃える必要あり
derived.py::r_peaks_to_bytes の ts_ms Window 先頭からのオフセット ms の可能性 (コメント不足) epoch ms の絶対値であるべき。検証テストで固定する
derived.py::rr_hrv_to_bytes sdnn rmssd pnn50 lf hf カラムを出力しない 本仕様はこれら 5 カラムを定義。Workspace は欠損時 NaN として扱う (前方互換)
derived.py::invalid_intervals_to_bytes の reason "artifact" 固定 本仕様は "acc_motion" / "leadoff" / "manual" を将来値として定義
derived.py::beat_labels_to_bytes ts_ms lead annotator confidence カラムなし 本仕様はこれら 4 カラムを v1 スキーマとして定義
pyramid.py::pyramid_to_bytes bucket_start_ms カラムなし。bin_idx のみ 本仕様は bucket_start_ms を将来追加予定 (現状は bin_idx * bin_width_ms + window.start_ms で自力計算)
BundleManifest (studio_api/schemas/bundle.py) tenant_id / subject / quality / privacy フィールドなし 本仕様のトップレベルキーと不一致。M0 スキーマ固定作業で解消
packages/schemas/src/schemas/bundle.py bundle_idschema_version のみ (ほぼ空) 正本スキーマを apps/studio-api/... に一本化し、packages/schemas は thin re-export