Files
worldmodel/plans/PRISM/03_data_schema.md
T
gaojie dbcbdbb59e chore: 为所有 md 文件添加 Hugo front matter
- 处理: 74 个 .md 文件
- 跳过: 0 个(无已存在的 front matter)
- 异常: 3 个(H1 缺失,用文件名兜底)
  - plans/PRISM/.research/readmes/3d-llm.md
  - plans/PRISM/.research/readmes/openmask3d.md
  - plans/PRISM/.research/readmes/openscene.md
2026-05-20 22:42:22 +08:00

25 KiB
Raw Blame History

title, date, draft, tags, categories
title date draft tags categories
Chapter 03 — 统一数据模型 (SpatialMemory Schema) 2026-05-20 false
PRISM
世界模型
空间记忆
iOS
数据结构
worldmodel

Chapter 03 — 统一数据模型 (SpatialMemory Schema)

本章目标:给出 可直接复制运行 的 Python schema、磁盘目录布局、JSON/USD 序列化规范,作为 PRISM 全系统的"数据宪法"。


3.1 设计原则

原则 含义
单一真理源 所有层共享 SpatialMemory 一个根对象;不允许"iPhone 数据库 + ZED 数据库"并立
自描述 每个节点带 source/level/confidence/timestamps,断网恢复后能自解释
可演化 Schema 加字段不破坏旧数据;用 schema_version 标记
可序列化 JSON 用于人读 + Git diffHDF5/PLY/GLB 用于大数据;USD 用于与 Omniverse/Isaac 对接
可索引 关键查询("附近的""相似的""最近见到的")都有 O(log n) 索引支持

3.2 完整 Python Schema

# spatial_memory/schema.py
"""PRISM Spatial Memory — canonical schema v1.0"""
from __future__ import annotations
from dataclasses import dataclass, field, asdict
from typing import Dict, List, Optional, Literal, Any, Tuple
from enum import Enum
import numpy as np
import time
import uuid

SCHEMA_VERSION = "1.0.0"

# ────────────────────── 基础类型 ──────────────────────

@dataclass
class Pose:
    """SE(3) 位姿;统一在 `map` 帧;右手系,z 向上"""
    position: np.ndarray              # (3,) float32  [x,y,z]  单位 m
    quaternion: np.ndarray            # (4,) float32  [w,x,y,z]  单位四元数
    frame_id: str = "map"

    def to_matrix(self) -> np.ndarray:
        # 返回 4×4 齐次矩阵
        from scipy.spatial.transform import Rotation as R
        T = np.eye(4)
        T[:3, :3] = R.from_quat(
            [self.quaternion[1], self.quaternion[2],
             self.quaternion[3], self.quaternion[0]]).as_matrix()
        T[:3, 3] = self.position
        return T


class MemoryLevel(str, Enum):
    L1 = "L1"   # 感知缓冲
    L2 = "L2"   # 度量
    L3 = "L3"   # 拓扑
    L4 = "L4"   # 语义


class Source(str, Enum):
    IPHONE = "iphone"
    ZED2I  = "zed2i"
    VLM    = "vlm"
    FUSED  = "fused"
    HUMAN  = "human"   # 人工标注/纠正


# ────────────────────── 节点 ──────────────────────

@dataclass
class SpatialNode:
    """统一节点:可表示房间(L3)/家具(L4)/物品(L4)/路点(L2,可选)

    > ⚠️ v1.5 更新:本类新增 `keyframe_evidence: List[KeyframeEvidence]` 字段,
    >    用于保留 per-frame 独立"翻案证据"。完整动机、字段定义、存储预算与
    >    retention 策略详见 § 3.2.1(紧随本 Schema 块之后的新增小节)。
    >    旧字段语义不变;本注解仅作为升级提示,原 v1.0 字段全部保留。
    """
    # ── 标识 ──
    uid: str
    label: str
    level: MemoryLevel
    source: Source
    confidence: float = 1.0           # 0~1

    # ── 几何 ──
    pose: Optional[Pose] = None
    bbox_3d: Optional[np.ndarray] = None        # (8,3) OBB 顶点
    polygon_2d: Optional[np.ndarray] = None     # (N,2) 仅 L3 房间地面用
    mesh_uri: Optional[str] = None              # 相对 robot_memory/ 的路径
    gaussians_uri: Optional[str] = None

    # ── 语义 ──
    category: Optional[str] = None              # 'wall','furniture','small_item','room'
    attributes: Dict[str, Any] = field(default_factory=dict)
    # attributes 常用键:
    #   color, material, state(on/off/open/closed),
    #   mobile: bool,  fragile: bool,
    #   is_anchor: bool,   no_update_zone: bool
    clip_embedding: Optional[np.ndarray] = None   # (512,) float16

    # ── 时序 ──
    first_seen: float = field(default_factory=time.time)
    last_seen:  float = field(default_factory=time.time)
    observation_count: int = 1

    # ── 关联 ──
    parent_uid: Optional[str] = None              # L4 物品挂在哪件家具上
    parent_room: Optional[str] = None             # 反向:所属 L3 房间
    children: List[str] = field(default_factory=list)

    @staticmethod
    def new_uid(prefix: str = "n") -> str:
        return f"{prefix}_{uuid.uuid4().hex[:8]}"


# ────────────────────── 边 ──────────────────────

RelationT = Literal[
    "contains", "in",              # 容纳
    "on", "under",                 # 支撑
    "next_to", "front_of", "behind",  # 邻接
    "connects_to", "reachable_from",  # L3 通行
    "plugged_into", "inside_drawer",  # 特殊
]

@dataclass
class SpatialEdge:
    src_uid: str
    dst_uid: str
    relation: RelationT
    weight: float = 1.0                 # 距离/通行成本/置信度
    source: Source = Source.FUSED
    timestamp: float = field(default_factory=time.time)


# ────────────────────── 稠密层(不入节点)──────────────────────

@dataclass
class DenseLayerRefs:
    """L2 稠密表示:用文件 URI 引用,不放内存"""
    occupancy_grid_uri: Optional[str] = None   # .bt (OctoMap)
    tsdf_uri: Optional[str] = None             # .vbg / .npz
    global_mesh_uri: Optional[str] = None      # .glb
    global_3dgs_uri: Optional[str] = None      # .ply (gsplat 格式)
    prior_mask_uri: Optional[str] = None       # .npy (1=iPhone 保护)
    no_update_zone_uri: Optional[str] = None   # .npy (1=镜面)


# ────────────────────── 锚点 ──────────────────────

@dataclass
class Anchor:
    """用于 ZED 上线时与先验地图配准的锚点"""
    anchor_uid: str                   # 引用 SpatialNode.uid
    node_label: str                   # 'bed','tv',...
    is_mobile: bool                   # mobile=True 则不作 anchor
    clip_embedding: np.ndarray
    geometric_signature: Dict         # FPFH 直方图等几何特征
    last_validated: float


# ────────────────────── Delta(差异记忆)──────────────────────

@dataclass
class DeltaEvent:
    """ZED 在线发现的、与 LTM 不一致的事件"""
    event_id: str
    event_type: Literal["object_moved", "object_removed",
                        "object_added", "geometry_changed"]
    target_uid: Optional[str]         # 涉及的 LTM 节点(若有)
    new_pose: Optional[Pose] = None
    new_bbox: Optional[np.ndarray] = None
    evidence: List[str] = field(default_factory=list)  # 关键帧 ID 列表
    observation_count: int = 1
    first_observed: float = field(default_factory=time.time)
    last_observed:  float = field(default_factory=time.time)
    status: Literal["pending","confirmed","rejected","applied"] = "pending"


# ────────────────────── 根对象 ──────────────────────

@dataclass
class SpatialMemory:
    schema_version: str = SCHEMA_VERSION
    world_frame: str = "map"
    origin_description: str = "scan start point of iPhone session 1"
    gravity: np.ndarray = field(default_factory=lambda: np.array([0,0,-9.81]))

    nodes: Dict[str, SpatialNode] = field(default_factory=dict)
    edges: List[SpatialEdge] = field(default_factory=list)
    dense: DenseLayerRefs = field(default_factory=DenseLayerRefs)
    anchors: List[Anchor] = field(default_factory=list)
    deltas: List[DeltaEvent] = field(default_factory=list)

    # —— 索引(运行时构建,不序列化)——
    _kdtree: Any = None               # 空间近邻
    _faiss:  Any = None               # CLIP 向量

    # —— 便捷方法 ——
    def add_node(self, node: SpatialNode) -> None:
        assert node.uid not in self.nodes, f"duplicate uid {node.uid}"
        self.nodes[node.uid] = node

    def add_edge(self, edge: SpatialEdge) -> None:
        assert edge.src_uid in self.nodes
        assert edge.dst_uid in self.nodes
        self.edges.append(edge)

    def nodes_of_level(self, lvl: MemoryLevel) -> List[SpatialNode]:
        return [n for n in self.nodes.values() if n.level == lvl]

    def nodes_in_room(self, room_uid: str) -> List[SpatialNode]:
        return [n for n in self.nodes.values() if n.parent_room == room_uid]

3.2.1 v1.5 新增:KeyframeEvidenceper-frame 独立证据)

动机

v1.4 之前 PRISM 的 L2 是融合表示——TSDF 把每帧深度加权累计到体素、OctoMap 把每条 ray 更新到八叉树占据概率。融合的代价是累计误差不可回溯:一旦 L2 在长走廊里漂了 30 cm"第 t=12.3 s 那一帧 ZED 看到桌子在哪"就再也拿不回来——它已经被融进了几百万个体素的加权平均里。

借鉴 Lyra 2.0 § 3.2(a)"3D 缓存绝不融合(never fuse"的设计,v1.5 让每个 L3 节点额外保留 ≤ 5 个独立 keyframe 作为"翻案证据":当下游 L4 发现 bbox 估计与节点对不齐、或巩固期需要回溯某个语义改变是否真实发生时,可以绕过已融合的 L2,直接从原始 keyframe 重新估计。完整原则陈述与风险分析见 18_lyra_inspirations.md § 18.3

注意:这不是把 TSDF 扔掉,而是让 TSDF(路由 + 避障)与 keyframe 列表(翻案证据)并存——二者承担不同任务、对几何精度有不同容忍度。

完整 KeyframeEvidence Dataclass

# spatial_memory/schema.py  (v1.5 新增,接在原 SpatialNode 之后)
@dataclass
class KeyframeEvidence:
    """
    每个 L3 节点保留的 per-frame 独立证据。
    一律 append-only,绝不被 L2 融合操作覆盖(immutable=True)。

    设计要点:
      - lazy load: depth_path / rgb_path 只存路径,原始张量留磁盘
      - 视觉特征预先算好 CLIP 嵌入并 inline(只有 1.5 KB,索引快)
      - visibility_score 是 retention 决策的唯一依据(见下文 retention 策略)
    """
    # ── 标识 ──
    kf_id:            str                       # 全局唯一,匹配 stm/keyframes/{kf_id}/
    ts:               float                     # 采集时间戳 (epoch sec)
    source_pipeline:  Literal["C_online", "A_offline", "D_consolidation"]
                                                # 该证据由哪条管线产生

    # ── 几何 ──
    T_cam_world:      np.ndarray                # (4,4) float32 相机→世界 SE(3)
    pose_uncert:      Optional[np.ndarray] = None  # (6,6) 协方差,可空

    # ── 内容引用(lazy load) ──
    rgb_path:         str = ""                  # 相对 robot_memory/ 的 JPEG 路径
    depth_path:       str = ""                  # 相对路径,uint16 PNG(mm 单位)
    intrinsics:       Optional[np.ndarray] = None  # (3,3) 该帧内参 K

    # ── 内联特征(小,索引友好) ──
    clip_emb:         Optional[np.ndarray] = None  # (768,) float16, ~1.5 KB

    # ── 路由元数据 ──
    routed_l3:        Optional[str] = None      # 当时路由到的 L3 节点 uid
    routed_l4:        List[str] = field(default_factory=list)  # 关联 L4 uid 列表
    visibility_score: float = 0.0               # [0,1] 该帧对节点的可见度/信息量
                                                # 用于 retention 时挑选最差帧替换

    # ── 不变性保护 ──
    fused_into_l2:    bool = False              # 是否曾参与 L2 TSDF 融合
    immutable:        bool = True               # 写入后禁止修改;违反者 raise

字段从 18_lyra_inspirations.md § 18.3KeyframeEvidence 扩展而来:保留 Lyra 风格的 append-only 与 lazy-load 语义,并补齐了 PRISM 专用的 rgb_path / intrinsics / visibility_score / source_pipeline 字段以贴合 "L3 节点级别保留"这一更具体的工程目标。

SpatialNode 字段扩展示例

在原 SpatialNode 定义(§ 3.2 中段,第 72 行起)的末尾新增一个字段,其他字段全部保留:

@dataclass
class SpatialNode:
    # ... 原 v1.0 所有字段保持不变(uid/label/level/source/confidence/
    #     pose/bbox_3d/.../parent_room/children) ...

    # ── v1.5 新增 ──
    keyframe_evidence: List[KeyframeEvidence] = field(default_factory=list)
    # 最多保留 5 条,append-only;满后用 visibility_score 替换最差(见下)

序列化时 keyframe_evidence 走与 clip_embedding 同样的"大向量外链"策略: JSON 中仅留 kf_id + rgb_path + depth_path + visibility_score + ts 等元数据, clip_embembeddings/{kf_id}.clip.npy

存储预算估算

按典型酒店楼层(500 L3+L4 节点、每节点保留 5 帧)做单帧 + 总量两级估算:

编码 单帧大小 备注
RGB 320×240 JPEG q=85 ~30 KB ZED/iPhone 下采样后足够做事后翻案
Depth 320×240 uint16 PNG ~50 KB mm 单位,压缩率 ~3×
CLIP 嵌入 768D float16 1.5 KB inline 进 JSON 索引
内参 + 位姿 + meta JSON ~0.5 KB T_cam_world + intrinsics + pose_uncert
单帧合计 ~82 KB 取整 ≈ 80 KB
维度 数值 总量
每节点保留帧数 5 5 × 80 KB ≈ 400 KB / 节点
节点数(典型一层) 500 500 × 400 KB ≈ 200 MB / 楼层
节点数(大型场馆 5 层) 2500 2500 × 400 KB ≈ 1.0 GB / 整馆
旁通存储(NVMe SSD 1 GB 完全可接受(snapshots/ 同盘)

结论:单层 ~200 MB,整馆 ~1 GB,相比 dense/octomap.bt(典型 50200 MBdense/3dgs.ply(典型 100 MB–1 GB / 房间)属于同量级,没有引入新数量 级的存储瓶颈。若磁盘吃紧,可把 rgb_path 进一步压到 160×120 JPEG ~12 KB / 帧)把整馆压到 ~250 MB。

Retention 策略(满 5 帧后如何替换)

keyframe_evidence 容量上限默认 5 帧/节点。新证据写入时若已满,按以下规则替换:

def admit_evidence(node: SpatialNode, new_kf: KeyframeEvidence,
                   capacity: int = 5) -> None:
    """append-only 语义下的 admission control:
       capacity 未满则直接 append;满则用 visibility_score 替换最差帧。
       注意:被替换的 KeyframeEvidence 在 Pipeline D 巩固期归档到
       robot_memory/snapshots/ 而非硬删,保留可追溯性。
    """
    if len(node.keyframe_evidence) < capacity:
        node.keyframe_evidence.append(new_kf)
        return
    # 已满:挑当前 visibility_score 最低的一帧
    worst_idx = min(range(capacity),
                    key=lambda i: node.keyframe_evidence[i].visibility_score)
    if new_kf.visibility_score > node.keyframe_evidence[worst_idx].visibility_score:
        archive_to_snapshot(node.keyframe_evidence[worst_idx])  # 归档,不硬删
        node.keyframe_evidence[worst_idx] = new_kf
    # 否则新帧也不如最差帧好,直接丢弃(由 Pipeline D 决定是否进 snapshots/)

visibility_score ∈ [0, 1] 的计算遵循 geometry-aware retrieval Lyra 2.0 § 3.2(b)):综合 (a) 该帧对节点 OBB 的覆盖面积比例、 (b) 视角与已有保留帧的角度差异、(c) 深度有效像素占比,三者加权平均。 直觉是"保留信息互补、视角多样、深度可信的 5 帧",而不是"最近 5 帧"。

回链:本小节落实了 18_lyra_inspirations.md § 18.3 "原则二:Per-frame 独立 keyframe 证据"在数据 schema 上的承接。 13_evaluation.md 后续需新增"翻案命中率 / 翻案误报率" 两项指标来闭环验证本字段的工程价值。


3.3 序列化规范

3.3.1 JSON(人读 + Git 友好)

# spatial_memory/io_json.py
import json
import numpy as np
from .schema import SpatialMemory, SpatialNode, SpatialEdge

def _np_encoder(o):
    if isinstance(o, np.ndarray):
        return {"__ndarray__": True, "dtype": str(o.dtype),
                "shape": list(o.shape), "data": o.flatten().tolist()}
    if isinstance(o, (np.float32, np.float16)):
        return float(o)
    raise TypeError(f"non-serializable: {type(o)}")

def _np_decoder(d):
    if d.get("__ndarray__"):
        return np.array(d["data"], dtype=d["dtype"]).reshape(d["shape"])
    return d

def save(mem: SpatialMemory, path: str) -> None:
    from dataclasses import asdict
    blob = asdict(mem)
    # 大向量不入 JSON:替换为 .npy 外链
    for uid, node in blob["nodes"].items():
        if node["clip_embedding"] is not None:
            np.save(f"{path}.{uid}.clip.npy", node["clip_embedding"])
            node["clip_embedding"] = {"__npy__": f"{uid}.clip.npy"}
    with open(path, "w") as f:
        json.dump(blob, f, indent=2, default=_np_encoder)

def load(path: str) -> SpatialMemory:
    with open(path) as f:
        blob = json.load(f, object_hook=_np_decoder)
    # 反向恢复 dataclass
    ...
    return SpatialMemory(**blob)

3.3.2 USD(与 Omniverse / Isaac Sim / Polycam 互通)

每个 L4 节点 → 一个 USD Xform prim;几何挂在子 prim

/World/Hotel
    /Room_301        (Xform, custom attr: room_label="Bedroom")
        /Bed_301     (Xform, ref=bed.usd)
        /TV_301      (Xform, ref=tv.usd)
    /Hallway_3F
        ...

转换器:

# spatial_memory/io_usd.py
from pxr import Usd, UsdGeom, Gf

def memory_to_usd(mem: SpatialMemory, usd_path: str):
    stage = Usd.Stage.CreateNew(usd_path)
    world = UsdGeom.Xform.Define(stage, "/World")

    # L3 房间作为父 Xform
    for room in mem.nodes_of_level(MemoryLevel.L3):
        room_xform = UsdGeom.Xform.Define(stage, f"/World/{room.uid}")
        room_xform.GetPrim().CreateAttribute(
            "prism:label", Sdf.ValueTypeNames.String).Set(room.label)

        for obj in mem.nodes_in_room(room.uid):
            obj_xform = UsdGeom.Xform.Define(
                stage, f"/World/{room.uid}/{obj.uid}")
            T = obj.pose.to_matrix()
            obj_xform.AddTransformOp().Set(Gf.Matrix4d(T.tolist()))
            if obj.mesh_uri:
                obj_xform.GetPrim().GetReferences().AddReference(obj.mesh_uri)

    stage.GetRootLayer().Save()

3.3.3 二进制(生产部署)

为了在 Jetson 上加载快,关键张量走二进制:

数据 格式 工具
点云 .ply / .las Open3D
TSDF .vbg Open3D VoxelBlockGrid
3DGS .ply (gsplat 标准布局) gsplat / nerfstudio
OctoMap .bt octomap-cpp
Mesh .glb (gltf 2.0) trimesh
CLIP 向量库 .faiss Faiss

JSON 仅存索引和元数据,数据走文件引用。


3.4 磁盘目录布局(落盘规范)

robot_memory/
├── manifest.json                # 总入口,含 schema_version & 各文件 SHA256
├── ltm/                         # 长期记忆(iPhone 主导,写入后近乎只读)
│   ├── spatial_memory.json      # SpatialMemory 主体(不含大向量)
│   ├── embeddings/
│   │   └── {uid}.clip.npy       # 每节点 CLIP 向量
│   ├── meshes/
│   │   ├── room_301.glb
│   │   └── bed_301.glb
│   ├── pointcloud/
│   │   └── room_301.ply
│   ├── dense/
│   │   ├── octomap.bt
│   │   ├── tsdf.vbg
│   │   ├── 3dgs.ply
│   │   ├── prior_mask.npy
│   │   └── no_update_zone.npy
│   ├── anchors.json
│   └── usd/
│       └── hotel.usdz           # 给 Omniverse / 仿真用
│
├── stm/                         # 短期/工作记忆(运行时,环形覆盖)
│   ├── current_pose.txt         # 单行最新位姿
│   ├── trajectory.tum           # TUM 格式轨迹(追加写)
│   ├── keyframes/
│   │   └── {ts}_{idx}/
│   │       ├── rgb.jpg
│   │       ├── depth.png
│   │       └── meta.json
│   ├── live_octomap.bt          # 实时局部
│   └── live_tsdf.vbg
│
├── delta/                       # 差异记忆(待巩固)
│   ├── pending.jsonl            # 一行一个 DeltaEvent
│   ├── confirmed.jsonl
│   └── rejected.jsonl
│
├── snapshots/                   # 版本化历史 LTM
│   ├── 2026-05-16_v1.tar.zst
│   └── 2026-06-01_v2.tar.zst
│
└── logs/
    ├── relocalize.log
    ├── consolidation.log
    └── metrics.parquet          # 评测指标时间序列

manifest.json 示例:

{
  "schema_version": "1.0.0",
  "world_frame": "map",
  "created_at": "2026-05-16T10:00:00+08:00",
  "scene_name": "Hotel-Demo-Floor3",
  "ltm_version": 5,
  "files": {
    "ltm/spatial_memory.json": {
      "sha256": "ab12...",
      "size_bytes": 1048576
    },
    "ltm/dense/octomap.bt": { "sha256": "...", "size_bytes": 52428800 }
  }
}

3.5 数据库映射(生产部署用 Neo4j)

原型期可用 JSON + NetworkX,生产期建议把 L3+L4 进 Neo4j(节点 + 关系),稠密数据仍走文件:

// 创建房间节点
MERGE (r:Room {uid: 'room_301'})
SET r.label='Bedroom',
    r.center=point({x:1.2, y:3.4, z:0.0}),
    r.confidence=0.95;

// 创建家具节点 + 关系
MERGE (b:Furniture {uid: 'bed_301'})
SET b.label='bed', b.mobile=false,
    b.pose_x=2.0, b.pose_y=3.5, b.pose_z=0.3;

MERGE (r)-[:CONTAINS]->(b);

// 小物品 on 家具
MERGE (rc:Item {uid: 'remote_xyz'})
SET rc.label='remote_control', rc.mobile=true;
MERGE (rc)-[:ON {confidence:0.85}]->(b);

LLM Agent 查询时可以直接发 Cypher

MATCH (i:Item {label:'remote_control'})-[:ON]->(f)<-[:CONTAINS]-(r:Room)
RETURN i, f, r;

3.6 版本化与迁移

# spatial_memory/migrate.py
def migrate(blob: dict) -> dict:
    v = blob.get("schema_version", "0.0.0")
    if v == "1.0.0":
        return blob
    if v == "0.9.0":
        # 0.9 → 1.0:把 'class' 字段重命名为 'category'
        for node in blob["nodes"].values():
            node["category"] = node.pop("class", None)
        blob["schema_version"] = "1.0.0"
        return migrate(blob)
    raise ValueError(f"Unsupported schema version: {v}")

每次 schema 升级 → 写一个迁移函数 + 在 snapshots/ 留备份。


3.7 一致性校验

写完任何节点都跑:

def validate(mem: SpatialMemory) -> List[str]:
    errs = []
    for uid, node in mem.nodes.items():
        if uid != node.uid:
            errs.append(f"uid mismatch: {uid} vs {node.uid}")
        if node.level == MemoryLevel.L4 and node.parent_room is None:
            errs.append(f"L4 node {uid} missing parent_room")
        if node.confidence < 0 or node.confidence > 1:
            errs.append(f"{uid} confidence out of [0,1]")
    for edge in mem.edges:
        if edge.src_uid not in mem.nodes:
            errs.append(f"dangling edge src: {edge.src_uid}")
        if edge.dst_uid not in mem.nodes:
            errs.append(f"dangling edge dst: {edge.dst_uid}")
    return errs

CI 里跑 validate() 防止 schema 退化。


3.8 API 设计原则(给上层 Agent)

不要让 Agent 直接访问 mem.nodes;提供高层方法:

class SpatialMemoryAPI:
    def find(self, text: str, top_k: int = 5) -> List[SpatialNode]: ...
    def locate(self, query: SpatialNode) -> Pose: ...
    def neighbors(self, uid: str, radius: float = 1.0) -> List[SpatialNode]: ...
    def path_rooms(self, src_room: str, dst_room: str) -> List[str]: ...
    def changes_since(self, t: float) -> List[DeltaEvent]: ...
    def describe(self, uid: str) -> str:
        """生成自然语言描述供 LLM 消化"""
        node = self.nodes[uid]
        room = self.nodes[node.parent_room]
        return f"A {node.attributes.get('color','')} {node.label} " \
               f"in {room.label}, last seen {ago(node.last_seen)}."

→ Agent 看不见底层格式变更,只用 API。


3.9 本章小结

关键约定 一句话
根对象 SpatialMemory(含 nodes/edges/dense/anchors/deltas
节点 SpatialNode(带 level + source + confidence + 时间戳)
SpatialEdge(含 relation + weight
稠密数据 不入节点,走文件 URI
磁盘 三主目录:ltm/ stm/ delta/ + snapshots/
数据库 原型 NetworkX/JSON,生产 Neo4j
序列化 JSON 主,二进制大数据走外链;USD 用于仿真桥接
版本 schema_version + 迁移函数

读完本章你应能:

  • schema.py 复制进项目就开始写代码
  • 知道一份 LTM 在磁盘上长什么样
  • 给 LLM Agent 提供安全的 API

下一章 04_pipeline_A_iphone_offline.md 用这套 schema 实现"iPhone 扫描 → SpatialMemory 灌入"的完整管线。


章节版本v1.0 估计阅读时间15 分钟 关键收获:拿到可立即使用的 dataclass + 磁盘布局