EdgeMemory 固件规格书

EdgeMemory-MVP 分册 02 | ESP-IDF v6.1 / ESP32-S3 | v1.0(定稿) | 2026-10-26
本分册是固件团队的实现依据:分层与目录、引擎 HAL 接口、数据模型、.emf 格式、存储布局、命令集、HTTP API、闭环实现、构建规范。
接口一旦定稿不得随意变更;变更须走 ADR 并同步更新本分册、03 号网络分册与 04 号 Studio 分册。

1. 软件栈分层与目录结构

firmware/
├── main/                        ← 通用层(改必两板回归)
│   ├── app_main.c               启动装配
│   ├── emgw.h / emgw_version.h
│   ├── cli_json.c               行协议命令分发(串口 / BLE / USB CDC 共用)
│   ├── http_srv.c               HTTP + CORS(Studio 直连)
│   ├── ota_mgr.c                双槽 OTA + rollback + 启动自证【已存在】
│   ├── kb_ota.c                 .emf 镜像 OTA(POST /kbota)【已存在】
│   ├── transport.c  wifi_mgr.c  ble_mgr.c  hub_mgr.c  em_agent.c
│   ├── em_runtime.c             【新增】策略层:遗忘 / 去重 / 冲突消解 / 时效
│   ├── em_ts.c                  【新增】时序源:合成数据源 + 特征化
│   ├── em_usb.c                 【新增】TinyUSB CDC + MSC 复合设备
│   └── (MVP 删除)cam_mgr.c  mic_mgr.c  gps_mgr.c 及其命令
├── components/edgehippom/          ← 【新增】引擎层,板无关
│   ├── include/edgehippom.h        HAL 公开接口(本章第 2 节)
│   ├── em_hal_dispatch.c        后端分派(桩/实现互斥的唯一入口,见 §1.1)
│   ├── emlite/                  自研后端(先行)
│   │   ├── em_store_blk.c       块管理 + 4 KB 页对齐 + 区域相对 IO 入口
│   │   ├── em_flash_io.c        flash 抽象(PC 上用文件仿真)【唯一允许含平台 #if 的文件】
│   │   ├── em_wal.c             WAL 环形介质结构:落位 / 记录读写 / 页回收
│   │   ├── em_wal_rec.c         【ADR-016 新增】启动恢复:扫描与前滚 / 回滚判定
│   │   ├── em_tx.c              【ADR-016 新增】事务:脏页表 / 原子提交 / abort / 前滚执行
│   │   ├── em_mem.c             【ADR-016 新增】记录编解码 + 追加 / 取回 / 墓碑
│   │   ├── em_core.c            【ADR-016 新增】后端装配体:句柄生命周期与 API 编排
│   │   ├── em_pool.c            【ADR-016 新增】预分配内存池(malloc-free)
│   │   ├── em_util.c            【ADR-016 新增】CRC32 / UTF-8 安全截断
│   │   ├── em_ann.c             IVF + graph 混合 ANN(Sprint 2)
│   │   └── em_ts_col.c          列式时序环 L1/L2(Sprint 2)
│   └── emdb/                    eXtremeDB 后端(授权到位后实现,接口不变)
├── boards/                      ← 板级真源,禁止跨板拷贝
│   ├── sdkconfig.xiao_esp32s3_n8r8
│   └── sdkconfig.devkitc_esp32s3_n16r8
├── partitions_emgw.csv          8 MB(Dev-A)
└── partitions_emgw_16m.csv      16 MB(Dev-B)

1.1 分层职责边界

层代码职责硬约束
L5 宿主接口main/cli_json.c
main/http_srv.c
main/em_usb.c
把宿主请求翻译成引擎调用不得直接访问 flash;不含业务规则
L4 网络main/transport.c
link_mgr / hub_mgr
EdgeMesh 发现与接力(见 03 号分册)不得直接写库,须经 L3
L3 策略 Runtimemain/em_runtime.c遗忘、去重(余弦 > 0.95 合并)、冲突消解、时效、优先级不得直接访问 flash,只经 HAL
L2 引擎 Corecomponents/edgehippom/存储、事务、ANN、时序HAL 接口在 emdb 替换时不得变更
L1 平台ESP-IDF / 驱动flash、PSRAM、USB、Wi-Fi、BLE—
为什么引擎放在 components/ 而不是 main/:引擎是板无关的(Dev-A / Dev-B / 未来的 α 模组共用),放在组件里才能做到「换板不换引擎」,也才能在 PC 上跑单测(配合 em_flash_io 文件仿真)。

1.2 板级形态标识(ADR-018:板级只有形态,没有角色)

两块板都是 EdgeHippo 外设记忆体,彼此对等、无上下级。板级属性一律用形态(form factor)表达,不再使用 gateway / edge 这类角色词(旧 EdgeMemory Gateway 架构遗留,已废止)。
项Dev-A(XIAO N8R8)Dev-B(DevKitC N16R8)
形态γ 磁吸卡
(form=gamma-card)
β USB Dongle
(form=usb-dongle)
Kconfig 选项
(二选一,编译期固化)
CONFIG_EMGW_FORM_GAMMA_CARDCONFIG_EMGW_FORM_USB_DONGLE
运行时字符串gamma-cardusb-dongle
设备信息字段sys.info 的 form 字段(原 role 字段已改名);互联邻居字段 peer_form(原 peer_role)
命令sys.form / sys.set_form(旧名 sys.role / sys.set_role 保留为兼容别名)
旧标识的处置:Kconfig 的 EMGW_ROLE_EDGE / EMGW_ROLE_GATEWAY 与运行时字符串 edge / gateway 一律废止;板位差异只由 CONFIG_EMGW_FORM_* 决定,与固件分层职责无关。板级差异仍一律走 boards/sdkconfig.<board_id> + 分区表 + CONFIG_EMGW_*,禁止跨板拷贝 sdkconfig。

2. 引擎双后端与 HAL 接口

2.1 为什么是双后端

eXtremeDB SDK 不在开发机上(全盘检索 C:\emgw-build 与 C:\Espressif 无任何 mco/extreme 头文件或库)。若把引擎当成前置依赖,项目第一天就停在等商务。

对策:emlite(自研)先跑通全部闭环,emdb(eXtremeDB)授权到位后替换,接口不变。授权谈判从「阻塞全部」降级成「性能优化项」——而且手上先有实测数据,谈价时地位完全不同。

2.2 后端能力对照

能力emlite(先行)emdb(eXtremeDB,后替换)
ANN 索引IVF + graph 混合(MCU 级推荐)原生 HNSW / Vamana,事务一致
时序自研列式环(L1 分钟聚合 / L2 pattern)原生 sequence 列式时序类型
ACIDWAL + CoW + 原子提交(自实现)原生事务(含 MVCC 变体)
内存分配全预分配池,运行期 malloc 0 次自研内存管理器,运行期不调用 malloc
复制 / 同步需自实现Active Replication Fabric:写事务 → commit → 上行复制 → 回收本地空间
footprint目标 .text ≤ 60 KBEdge Client ≤ 8 KB(官方口径,需实测)
Active Replication Fabric 的语义几乎就是 EdgeMesh 碎片同步要手写的东西。HAL 因此必须预留 em_sync_* 接口——将来是直接启用,而不是重写协议层。

2.3 HAL 公开接口(components/edgehippom/include/edgehippom.h)

/* ---------- 生命周期 ---------- */
int  em_open   (const em_cfg_t *cfg, em_db_t **out);
int  em_close  (em_db_t *db);
int  em_stat   (em_db_t *db, em_stat_t *out);      /* 条数/字节/水位/碎片率 */

/* ---------- 事务 ---------- */
int  em_tx_begin (em_db_t *db);
int  em_tx_commit(em_db_t *db);
int  em_tx_abort (em_db_t *db);

/* ---------- 记忆条目 ---------- */
int  em_mem_put (em_db_t *db, const em_mem_t *rec);   /* 单条(隐含事务) */
int  em_mem_get (em_db_t *db, em_id_t id, em_mem_t *out);
int  em_mem_del (em_db_t *db, em_id_t id);

/* ---------- 混合查询 ---------- */
/* 【ADR-014】reject 可为 NULL;但当 n==0 时调用方必须传它才能区分拒因 */
int  em_query  (em_db_t *db, const em_query_t *q, em_hit_t *hits, int max_hits, em_reject_e *reject);

/* ---------- 时序 ---------- */
/* 【ADR-019】t0/t1 构成左闭右开区间 [t0, t1):含 t0 所属分钟、不含 t1 所属分钟。
 *   逐分钟查询请写 t1 = 下一分钟起点。两个函数必须用**同一套分钟序号**判定
 *   (minute = t / EM_TS_MIN_PER_US),不可一个比微秒、一个比整除分钟——
 *   t1 非整分钟时二者会分叉。闭区间的实测后果见 ADR-019 取证。 */
int  em_ts_append    (em_db_t *db, uint32_t stream, int64_t ts_us, const void *sample, size_t n);
int  em_ts_window    (em_db_t *db, uint32_t stream, int64_t t0, int64_t t1, em_ts_iter_t *it);
int  em_ts_agg       (em_db_t *db, uint32_t stream, int64_t t0, int64_t t1, em_agg_e op, em_agg_t *out);
int  em_ts_downsample(em_db_t *db, em_ts_level_e from, em_ts_level_e to);

/* ---------- 碎片 ---------- */
int  em_frag_import (em_db_t *db, const uint8_t *emf, size_t len, const em_frag_opt_t *opt, em_frag_rpt_t *rpt);
int  em_frag_export (em_db_t *db, const em_frag_sel_t *sel, uint8_t **buf, size_t *len);
int  em_frag_list   (em_db_t *db, em_frag_info_t *out, int max);

/* ---------- 同步(预留 Active Replication Fabric) ---------- */
int  em_sync_push  (em_db_t *db, const em_sync_desc_t *d);
int  em_sync_pull  (em_db_t *db, const em_sync_desc_t *d);
int  em_sync_status(em_db_t *db, em_sync_stat_t *out);

2.3 关键结构体

typedef struct {
  em_id_t   id;        /* 0 = 由引擎分配 */
  uint32_t  frag_id;   /* 所属碎片 */
  int64_t   ts_ms;
  uint16_t  dim;       /* 向量维度 */
  uint8_t   dtype;     /* EM_VEC_NONE / INT8 / FP16 */
  uint16_t  prio;      /* 0–1000,越大越不易被遗忘 */
  uint16_t  ttl_h;     /* 时效(小时),0 = 永不过期 */
  uint16_t  vec_len;   /* 向量字节数 */
  uint16_t  text_len;
  uint16_t  meta_len;
  const void *vec;     /* 序列化时置于记录末尾(ADR-010) */
  const char *text;
  const char *meta;    /* JSON 片段 */
} em_mem_t;

typedef struct {
  const int8_t *vec;   /* NULL = 纯结构化查询 */
  uint16_t dim, k;
  const char *filter;  /* 迷你表达式:dev_id==3 && ts_ms>t0 */
  uint32_t flags;      /* EM_Q_ANN|EM_Q_TS|EM_Q_FILTER|EM_Q_EXPLAIN */
  uint16_t min_score_q8; /* 【ADR-014】拒答阈值 ×256,0 = 不启用 */
} em_query_t;

typedef struct {
  em_id_t   id;
  uint32_t  frag_id;      /* 出处的碎片(对应「这是谁 / 哪份资料的记忆」) */
  int64_t   ts_ms;        /* 出处时间戳 */
  uint16_t  score_q8;     /* 置信度 ×256 */
  uint8_t   matched_by;   /* EM_M_ANN / EM_M_FILTER / EM_M_TS */
  uint16_t  text_len;
  const char *text;       /* 摘要(≤150 B) */
  const char *src;        /* 【ADR-014】出处标识,取自 meta.src */
} em_hit_t;

typedef enum {
  EM_REJECT_NONE    = 0,  /* 正常命中 */
  EM_REJECT_LOW     = 1,  /* 全库最高分仍低于 min_score_q8 */
  EM_REJECT_REDLINE = 2,  /* 命中红线条目(meta.refusal),不得输出 */
  EM_REJECT_EXPIRED = 3,  /* 命中条目已过 ttl_h */
  EM_REJECT_EMPTY   = 4   /* 碎片为空 / 无匹配 token */
} em_reject_e;

typedef struct {
  uint16_t        vec_bytes;      /* 向量字节数(桶大小) */
  uint16_t        text_max;       /* 摘要上限,默认 150 */
  uint32_t        wal_bytes;      /* WAL 大小,默认 512 KB */
  uint16_t        ann_M;          /* graph 出度,默认 16 */
  uint16_t        ann_ef_search;  /* 查询宽度,默认 32 */
  uint8_t         ivf_nlist;      /* IVF 聚类数(MCU 级建议 32–64) */
  uint8_t         merge_threshold_q8; /* 去重阈值 ×256,默认 0.95*256 = 243 */
  bool            malloc_free;    /* true = 全预分配,运行期禁止 malloc */
} em_cfg_t;
三条不可让步的接口纪律:
① ANN 必须事务一致——禁止「先写数据、后异步建索引」这类最终一致方案(ADR 原则③)。
② em_sync_* 必须在 emlite 阶段就定义并实现为空操作或最小实现,不得留到 emdb 阶段才设计。
③ 所有返回 em_hit_t 的查询必须带 score_q8 与 matched_by(区分 ANN / 过滤 / 时序),否则策略层无法做冲突消解。

2.4 拒答语义(ADR-014:让思考有边界)

EdgeHippo 的产品承诺包含「能在没有依据时说不知道」。这不是策略层的可选行为,而是引擎层必须支持的语义——否则上层拿不到足够信息做判断。em_query 的拒答码通过出参 em_reject_e *reject 返回(可为 NULL 表示调用方不关心)。

返回情形em_query 返回策略层 / 宿主的应然行为
有命中且分数达标n > 0,填充 hits正常引用。必须连同 frag_id / ts_ms / score_q8 一起交给宿主,让宿主能说「依据是你在 X 时间留下的 Y,置信度 Z」
有命中但全不达标n = 0,EM_REJECT_LOW返回「不在我的知识范围内」+ 拒因。严禁回退为「返回最接近的一条」
命中红线条目n = 0,EM_REJECT_REDLINE拒绝回答,且不透露该条目存在的事实(只回「此话题不在范围内」)
命中已过期条目n = 0,EM_REJECT_EXPIRED可回「我记得过,但已过期」——允许说明时效,这比沉默更有价值
碎片为空n = 0,EM_REJECT_EMPTY区分「没装东西」与「装了但没答上来」,二者运维含义完全不同
实现纪律(三条,写死):
① min_score_q8 必须是引擎层过滤,不是「上层拿到结果再扔掉」——后者会让 top-k 被低分项占满而真正的高分项被截断,属于实现缺陷。
② 拒答必须带拒因码。返回 0 条却不给 reject,视为缺陷——宿主无法区分「不知道」与「库空了」。
③ src 允许为空(老碎片无该字段),但新建碎片必须写,由 Studio 打包器强制填充。

2.5 结构体字段的唯一权威(2026-10-26 补齐空档)

[重要] 本分册此前只给了函数签名,未定义 sync / frag / ts / agg 各组结构体的字段——这是一个契约空档,已直接导致工程事故。
后果:QA 侧的 tests/emcontract.h 自行 typedef 了一整套 em_cfg_t / em_stat_t / em_hit_t / em_agg_t / em_ts_iter_t / em_sync_* / em_frag_*,形成第二份独立的 HAL 契约副本。两份契约在同一编译单元内不可能共存,致使真实引擎无法链入 QA 测试二进制——这正是 G7 长期挂「未验证」的真根因。
处置:上述 8 个结构体的字段定义已钉死于 components/edgehippom/SPEC.md §9,该节为唯一权威。本分册不再重复抄录(复制必然漂移)。
结构体字段裁定判据
em_sync_desc_t{peer_id, frag_id, max_bytes}弃 QA 侧的 src_uri:MVP 是断网环境(G6),网络地址在设备端无意义
em_frag_rpt_t拆为 skipped(按规则跳过)+ crc_bad(坏数据)QA 侧单一 rejected 桶把「灌库文件坏了」与「策略挡了」混在一起,诊断时无法区分,属静默信息丢失
em_frag_info_t.name定长 char[64] 改为 const char*固定 64 B 会在 UTF-8 中文名上截断
em_frag_opt_t位标志 flags 改为具名字段位的含义散落在实现里,易漂移且不可自描述
em_stat_t.bytes定 u32(QA 侧 u64 作废)记录区上限 15 MB 量级,u32 足够;与 page_total / rec_count 同量级口径
em_sync_stat_t{pushed, pulled, last_ms, link_up}(字段不变,QA 侧 {pending, bytes_out, bytes_in} 作废)MVP 阶段 em_sync_* 是预留(无实际传输),应上报「传了多少、链路是否通」而非流量字节统计;pending 无数据来源,不得凭空返回
em_agg_t{double v[8]; uint32_t n}:v[0]=所求 op 结果,v[1..7] 必须置 0,n=参与聚合的样本数QA 侧 {double v[6]} 无 n;且未定义保留位语义,实现会填未定义值
em_ts_iter_t{level, stream, t0_us, t1_us, row, rows};row 相对本流非全局,区间为半开 [t0, t1)QA 侧为 {int dummy} 占位;row 的参照系不写死会在多流时算错窗口
em_frag_sel_t{frag_id, include_vec}(QA 侧 {frag_sel, flags} 作废)与 em_frag_opt_t 同理:具名字段优于位标志
em_stat_t新增 rec_pages_used占用率判据是页数口径,测试须能直接读页数
em_cfg_t.ts_streamsuint16_t,0 = 取默认 3;取值 1..min(16, ts_l1_pages);解析后的 n > ts_l1_pages 时 em_open 返回 EM_E_INVAL流数决定 L1 每流页数(ts_l1_pages / n),进而决定保留期。每流至少 1 页是 base_minute 语义成立的最低要求;按 0 页运行会让该流一条都存不下,是静默逻辑错误,必须拒绝
em_cfg_t.ts_l1_pagesuint16_t,0 = 取默认;非 0 时取值 1..ts_pages−1。取证区槽数 = ts_pages − ts_l1_pages(派生量,不单列字段)用绝对页数,不用比例:真正生效的量是页数,比例与生效值之间隔了一层取整,而本项目明令禁止用派生量反推基础量;且三套几何的 L1 占比本就不一致(112→96 是 85.7%,24→16 是 66.7%),没有共同比例可写。取证区至少留 1 槽,否则取证写入无处可落
时序切分两字段的解析顺序与校验表见 SPEC.md §2.8.0,字段本身以 SPEC.md §9.11 为唯一权威。本分册不复制整表(复制必然漂移,见下方规则①)。[注意] L1 保留期是派生量,不得在本分册硬写。换算链(每一环均为整数运算): ts_l1_pages ÷ ts_streams = 页/流 → × 每页行数 → ÷ 1440 = 天。 当前值快照(2026-10-26 定稿):行 12 B(数据 8 B + mark 4 B,最后写)→ 每页 340 行。保留期随时序区大小变化,两态并存,引用时必须注明是哪一个:
· 已批准扩容(目标值):Dev-B 记录区 2,346 / 时序区 139 → L1 123 页 → 3 流 → 41 页/流 × 340 = 13,940 行 → 9.68 天;Dev-A 619 / 122 → L1 106 → 35 页/流 × 340 = 11,900 行 → 8.26 天
· 扩容实施前(当前实现):时序区 112 → L1 96 → 32 页/流 × 340 = 10,880 行 → 7.56 天(PC 1,700 行 = 1.18 天)
分配原则(请一并引用):PSRAM 上限恒为 12,603 → rec_cap 无论记录区多大都不可能超过 12,600。故记录区只需够到临界点(X ≥ 2,340 页且取整余量 ≥ 30 条),多给纯属浪费,省下的全给时序区。本次定案取整余量 33 条,两块板 rec_cap 均零损失(Dev-B 12,600 / Dev-A 3,300)。
· 扩容实施前(当前实现):时序区 112 → L1 = 96 → 32 页/流 × 340 = 10,880 行 → 7.56 天(PC 为 1,700 行 = 1.18 天)
判据:mark == 0xA55AA55A 才算已提交,否则终止扫描。撕裂页按「已满」处理、强制下一次追加翻页——理由是扫描在撕裂行处终止,其后写入的行根本不可达(不是跳到下一行继续写就行)。std 钳位保留(防溢出),但不再是承重不变量。
为什么是 12 B 步长而不是 9 B:A2 要求写入的 off 与 len 均 4 字节对齐(esp_partition_write 的硬件要求,非我们的选择)。9 B 步长时行首偏移为 16 / 25 / 34 / 43,25 非 4 的倍数,写第 2 行即返 EM_E_INVAL。
[备选 B 案 · 待架构师裁定,未采用]:行仍 8 B,把 mark 集中为页尾位图(64 B,每 bit 对应一行,提交时清位 1→0)。row_cap = (4080−64)÷8 = 502 → 每流 16,064 行 → 11.15 天(PC 2,510 行 = 1.74 天)。判据语义与终裁一致,只是存放位置从「行尾」改为「页尾位图」。卡点:其清位是对已编程字节的再次编程(0xFE → 0xFC),需裁定 INV-2 的边界是页级还是字节级;裁定前不得动手,且实现后必须用位级注入实证。
[已实证,不再是论证]本判据已用 EM_PCUT_BIT_AT(位级撕裂注入:第 N 字节只编程 bit_mask 指定的位,其余保持原值,符合 NOR 只能 1→0)实测:直接对 mark 字节做位级部分编程(只写最低 1 位)→ 结果 ≠ 目标值 → 检出,有效样本数保持正确。mark 判据在位级撕裂下 100% 检出,且不依赖数据取值、不依赖端序。
为什么不采用零成本的 std < 0 判据:它只在「字节级撕裂」假设下完备;在「字节内可部分编程」前提下枚举 std 高字节 256 种部分编程态,128 种检出 / 127 种漏检(约 50%)。这正是仿真粒度决定你能证明什么的实例——原有仿真只有字节级注入,用例全过≠判据完备。现已补位级注入,可反向实证其漏检。
【教训】本次反复的根因是「先算容量、后(或根本没)检查可落性约束」。凡布局改动,先过硬约束清单(A2 四字节对齐 / A4 写前必擦 / INV-2 / A6 抗撕裂顺序),再算收益——容量表是结果不是输入。另:「完备」是有前提的词,声称完备必须写下前提并确认有能力验证;验证不了就登记为残留风险,不能当作已证。
由此提炼出两条通用规则,全项目适用:
① 测试契约不得复制生产契约。emcontract.h 须改为 #include "edgehippom.h",只保留 QA 骨架自身的东西(用例注册表、em_halvt_t、EM_ERR_NOT_IMPL、探针接口)。今日已因「复制一份」吃两次亏:em_crc32 在仓库里出现三份实现、结构体又分叉一次。复制必然漂移,这是定律不是概率。
② 禁止用派生量反推基础量。本项目已犯两次同类错误——用 vec_len 反推 dim(int8 与 fp16 换算不同,猜错即静默逻辑错误);用 bytes / 4080 反推页数(漏掉页头与碎片)。凡是可直接记录的量,一律直接记录,不做反推。
③ 能直接记录的一律直接记录,能推导的一律推导,两者都不做中间态。反面二是「硬编码」:把可推导的量固化成常量同样会漂移(rec_cap 即可由几何与 PSRAM 上限完全推导,故不加进 em_cfg_t,改为 open 时推导)。

2.6 返回码域定义(2026-10-26 定案,S-04)

2.3 节给出了 em_err_e 的码值,但此前未定义语义边界——各码在什么条件下返回、调用方应如何处理没有正式约定,测试侧只能自行推断一套建议码顶着。本节定死语义边界,自本节起不得新增码值或复用含义相近的码。

三条总则(先钉死再给表):
① 错误码不等于异常。EM_E_NOENT、空窗口、空查询都属于正常分支。空结果一律走 EM_OK + n = 0 / rows = 0,不得用错误码表达「没找到」。
② 可恢复情况禁止升级为不可恢复。单条记录 CRC 失败、重放失败、事务回滚——都不得升级为 EM_E_CORRUPT 或 em_open 失败。这条直接关系 G7「随机掉电 100 次,库 100% 可开」。EM_E_CORRUPT 只留给「库整体不可信」。
③ 每个码必须可判定:触发条件要能写成 if,不能是「某种异常情况下」。
码值触发条件(精确,可写成 if)调用方应然处理可重试
EM_OK0成功——
EM_E_INVAL-1① 必填指针为 NULL;② cfg.ts_streams > cfg.ts_l1_pages;③ em_blk_reg_* 的 off/len 非 4 对齐或越出 region;④ 记录体 > 632 B、text_len > 150、meta_len > 64、vec_len > 384修参数。同样参数重试必失败否
EM_E_NOMEM-2① cfg.arena == NULL 或 arena_bytes < EM_ARENA_MIN;② em_pool_alloc 失败加大 arena(按 PSRAM / DRAM 拆分),或先淘汰再重试部分
EM_E_FULL-3① 记录区无空闲页可分;② ANN 节点数已达 rec_cap;③ 时序 L1 无页可写。取证区不返此码(定长槽,满则覆盖最旧)先触发策略层遗忘再重试;或向宿主报「容量已满」是(淘汰后)
EM_E_NOENT-4em_mem_get / em_mem_del 的 id 不在记录集;em_frag_export 的 frag_id 不存在正常分支,不是错误。按「没有」处理—
EM_E_EXISTS-5① em_mem_put 指定 id 且该 id 已存在(非覆盖语义);② L1 同一分钟重复写:8 B 行没有样本计数字段,覆盖要擦整页,会连坐同页其它分钟改用新 id,或先删再写。引擎不得静默覆盖否
EM_E_TX-6无活动事务时 commit / abort;嵌套 em_tx_begin;事务外调用需事务上下文的写操作修调用流程否
EM_E_TXFULL-7① 脏页数 > cfg.max_dirty_pages;② WAL 剩余空间 < 本事务需求分批提交——这是设计意图不是失败:拆成多个较小事务后重试是(分批后)
EM_E_IO-8① em_blk_reg_write 撞 INV-2(目标页未擦);② 底层介质读写失败。严禁做「读-改-写」兜底记日志;事务内则 abort。同样参数重试必失败否
EM_E_CORRUPT-9① 两槽 superblock 均无效(magic 或 CRC);② 记录结构自相矛盾(rec_len 越界、页链断裂)停止写入;尝试只读导出快照后重新格式化否
EM_E_NOSUPPORT-10① emdb 后端未授权时调用其专属能力;② em_sync_push / em_sync_pull 的 MVP 最小实现降级处理否
EM_E_POWER-11io 对象被 em_flash_pcut_arm 置为失效,其后所有 IO 均返回此码,直到重新 em_flash_open仅 PC 仿真存在,板端不会出现。生产代码不得依赖否

2.6.1 边界澄清(三条,最容易搞混)

易混点裁定
单条记录 CRC 失败 与 EM_E_CORRUPT前者是遍历终止条件,库仍可正常打开;后者是库整体不可信。混用会把一次可恢复的断电残留升级成「库坏了」
EM_E_NOENT 与 空结果集查询无命中走 EM_OK + n = 0 + reject;EM_E_NOENT 只用于「按 id 定位不到」这类明确定位失败
EM_ERR_NOT_IMPL(-999)不属于 HAL 契约,是测试桩专用码,不得出现在引擎实现里。引擎对未实现能力返回 EM_E_NOSUPPORT
归属与禁止:
① 引擎返回值必须来自 em_err_e,不得返回自定义负数或 errno;QA 骨架的自建码只在测试桩内部有效。
② 码值不得复用:语义相近也必须各自有码(「容量满」与「事务容量满」分别是 EM_E_FULL 与 EM_E_TXFULL,不得合并)。
③ 新增码需走架构评审,实现侧不得自行加值。

2.6.2 判定优先级阶梯(多个条件同时成立时,返回靠前的一个)

上表定义了每个码各自的触发条件,但没定义「同一条调用同时满足两个条件」时返回哪个。没有阶梯,同一个调用在不同模块会返回不同的码,QA 就写不出稳定断言。

L1  EM_E_POWER     介质对象已失效(仅 PC 断电注入)——任何 IO 之前先判,最高优先
L2  EM_E_INVAL     参数 / 句柄状态非法(调用方 bug)
L3  EM_E_TX        事务状态机错误
L4  EM_E_NOSUPPORT 本构建 / 本 cfg 下该能力不存在
L5  EM_E_NOMEM     arena 池耗尽(配置问题)
L6  EM_E_TXFULL    单事务装不下(可分批重试)
L7  EM_E_FULL      目标区满(compact / 淘汰后可重试)
L8  EM_E_EXISTS / EM_E_NOENT   存在性判定(正常业务分支)
L9  EM_E_IO / EM_E_CORRUPT     介质层给出的结果,不参与上层码值选择
三条使用说明:
① L2 早于 L7:参数非法时即使区已满也返回 EM_E_INVAL。调用方拿到 INVAL 就知道是自己的问题,不必去查容量。
② L4 早于 L8:能力不存在时不得退化成 EM_E_NOENT。「这台设备没有时序能力」与「这一分钟没有数据」对策略层是两个决策。
③ L9 不参与选择:IO / CORRUPT 是介质层给出来的结果,不是上层在候选里挑的。上层不得把「区满」改判成 IO,也不得把「配置与介质布局不匹配」升级成 CORRUPT。

2.6.3 两处对齐更正(时序切分字段定案后产生,以 SPEC §2.8.0 为准)

原表述更正理由
EM_E_FULL 触发条件③「时序 L1 无页可写」改为 EM_E_NOSUPPORTL1 是环形,按页回绕覆盖最旧页,不存在「写满」这种状态。所谓「无页可写」实为 L1 区 0 页,那是「本 cfg 下该区不存在」,属能力缺失。FULL 的语义是「区在但暂时没有落点,compact / 淘汰后可重试」,而 0 页区重试多少次都不会有——两者的调用方动作完全不同,写错会让上表的「可重试」列给出错误指引
EM_E_INVAL 触发条件②「cfg.ts_streams > cfg.ts_l1_pages」改为「解析后的 n > ts_l1_pages」ts_l1_pages == 0 表示取默认(SPEC §2.8.0 解析顺序 O2),拿 cfg 原值 0 去比较会把 ts_pages=8 这类合法配置误判为非法

3. emlite 设计约束与实现规格

3.1 三条来自 eXtremeDB 的设计约束(Andrei Gorine 的技术主张)

#主张emlite 的落地方式对应判据
1运行时不调用 malloc(自研内存管理器)全预分配池;em_cfg_t.malloc_free = true 时运行期 malloc 计数必须为 0G3 / G8
2flash 的不可预测性来自介质之上的层(FTL / 文件系统),不是 flash 本身擦除与 GC 只在事务边界外执行;查询路径只读 + 顺序写G2
3回滚成本必须绑定已知写集(CoW 显式跟踪修改页)CoW 显式跟踪脏页;abort 只回滚已记录页,耗时可分析G8(abort P99 ≤ 2×P50)

3.2 三项硬指标(G8)

指标目标测量方式
.text 体积≤ 60 KB解析 .map,统计 libedgehippom.a 的 .text 段
运行期 malloc 次数0包装 malloc/calloc/realloc,进入 RUNNING 状态后计数
abort 耗时抖动P99 ≤ 2 × P501,000 次 em_tx_begin → put ×N → abort,记录耗时分布

3.3 事务实现规格

  1. WAL 先写:任何修改先写 WAL(512 KB 环形),WAL 记录含 tx_id、页号、旧值长度、CRC32
  2. CoW 脏页跟踪:事务内修改的 4 KB 页登记到脏页表(预分配,上限由 em_cfg_t 决定)
  3. 原子提交:写 COMMIT 标记(4 KB 页内,单次编程)→ 提交成功后才更新 superblock 指针
  4. superblock 双份 A/B 交替:每次提交写入另一份,带单调 seq 与 CRC32;启动时取 seq 大且 CRC 有效的一份
  5. abort:按脏页表回滚,或用 WAL 反向应用;耗时与脏页数成正比,不与原页总数成正比
  6. 恢复:启动时若发现未提交 WAL → 回滚;若发现已提交但未更新 superblock → 前滚
eXtremeDB/rt 有「硬件无关 flash 仿真」,emlite 也必须做。
em_flash_io 抽象 + PC 文件仿真实现:引擎层单测全部在 PC 上跑,掉电测试还能精确控制断电时刻(写到第 N 个字节后中止)——这是 G7 能被测得可信的前提,也是最快拿到结论的路径。

3.4 ANN 实现规格(MCU 级)

项规格依据
索引结构IVF + graph 混合(不是纯 HNSW)eXtremeDB 官方白皮书在 MCU 级推荐 IVF+graph 混合
聚类数 nlist32–64(12,000 条约 每簇 200–400 条)需实测扫描簇数 nprobe 与 P99 的关系
落盘顺序离线聚类后按簇顺序落盘白皮书:建库是 I/O 主导而非 CPU 主导,顺序写可显著降低 flush 抖动
距离int8 内积 / 余弦,SIMD(ESP32-S3 有向量指令扩展)—
调优目标为可预测性调优,而非峰值召回G2 的 P99/P50 < 4

若 P99 超标,解法是这三件事(不是换 MCU):① 按簇顺序落盘;② IVF+graph 混合与 nprobe 调参;③ 为可预测性调优。量级不足时再考虑降维量化(W1-14)。

4. 数据模型与 schema

MVP 只有四种实体。三场景(S1 工业振动 / S2 野外巡检 / S3 私域知识库)共用同一份 schema——差异只在灌进去的碎片和宿主是谁。这是「做器官」的工程证明。

4.1 Memory(记忆条目)

字段类型说明约束
idu32全局唯一,0 表示由引擎分配主键
frag_idu32所属碎片。用于按碎片整体删除 / 导出 / 接力索引
ts_msi64记忆时间戳(写入时刻,非事件时刻)时效与遗忘依据
priou16优先级 0–1000。越大越不易被遗忘默认 500
ttl_hu16时效(小时),0 = 永不过期策略层判据
dim / dtypeu16 / u8向量维度与类型(NONE / INT8 / FP16)v1 碎片为 NONE
vecbytes向量。序列化时置于记录末尾(ADR-010)默认 384 B(int8/384d)
textutf8摘要 / 原文片段≤ 150 B,截断用 em_utf8_cut
metajson结构化元数据:dev_id / src / tags / scene / author≤ 64 B 建议
flagsu16DELETED / PINNED(不参与遗忘)/ CONFLICTED / SYNTHETIC—
为什么 vec 放在最后:现有 em_kb_search 是文本/标签匹配(无向量)。.emf 分 v1(纯文本)/ v2(带向量)两代,v1 固件读到 v2 记录时,按 rec_len 跳过末尾的向量段即可安全处理。schema 从第一天就预留向量字段,是向 v2 平滑演进的前提。

4.2 Fragment(碎片)

字段类型说明
frag_idu32碎片唯一 ID
nameutf8例 docs-core-v1 / expert-pack-v3
sceneu81=工业振动 / 2=野外巡检 / 3=私域知识库
count / bytesu32条数与占用
veru32版本号。同 name 高版本覆盖低版本
created_msi64生成时刻
quotau32接力配额(内部记账,见 03 号分册)

4.3 Pattern(模式,L2 层)

时序数据的提炼结果。靠去重收敛——新 Pattern 与已有条目余弦 > 0.95 则合并更新而非新增(ADR-007)。

字段类型说明
pattern_idu32—
vecbytes窗口特征向量(与 Memory 同维度,可同一索引)
labelutf8例 inner-race-fault-BPFI
hit_countu32被命中次数。参与优先级提升
last_hit_msi64最近命中时刻。参与遗忘
example_refu32指向取证区中一个代表性原始窗口(L0 转存)

4.4 Timeseries(时序,L0 / L1 / L2 三级)

层内容落哪里保留说明
L0原始波形PSRAM 环形缓冲
(不落 flash,ADR-006)
10 s10 s @ 4 kHz × 3 轴 int16 = 240 KB。只有判定异常的窗口才转存到取证区
L1分钟级聚合flash 时序区(列式)30 天每流每分钟:min/max/mean/std/rms/峰峰值
L2Pattern(模式)flash 记忆区(与 Memory 同索引)长期靠去重收敛,这就是容量控制阀
不要按「80 分钟原始波形」做预算。按真实预算,L0 原始波形放不进 flash。ADR-006 已把 L0 定在 PSRAM,只有异常窗口才落盘。

4.5 策略层规则(em_runtime.c)

规则触发条件动作
去重新条目与已有条目余弦 > 0.95合并更新(保留较高 prio,hit_count 累加)而非新增
冲突消解同 tags 下出现语义相反条目保留 ts_ms 更新者,旧条目标记 CONFLICTED 并降 prio
时效过期now - ts_ms > ttl_h 且非 PINNED标记 DELETED,参与 compact
容量压力占用 > 85%按 prio 升序 + last_hit_ms 升序淘汰,至 75%
拒答话术top-1 分数 < 阈值返回「不知道」,不编造。断网时的话术模板见 04 号分册 P5

5. .emf 碎片文件格式

5.1 文件头(32 B 固定)

偏移  长度  字段            说明
0     8     magic           "EMKBIMG1"(8 字节 ASCII,无结尾 \0)
8     1     version         1 = 纯文本;2 = 带向量
9     1     vec_dtype       0 = NONE;1 = INT8;2 = FP16
10    2     vec_dim         向量维度(version=1 时为 0)
12    2     header_len      固定 32
14    2     flags           位0=PINNED_ALL;位1=OVERWRITE_SAME_NAME
16    4     rec_count       记录条数
20    4     payload_len     payload 字节数
24    4     payload_crc32   CRC32(payload)
28    4     frag_id         碎片 ID(0 = 由设备分配)

5.2 记录(payload 内,4 字节对齐)

偏移   长度   字段
0      4      rec_len        整条记录字节数(含本字段,4 对齐)
4      4      id
8      4      frag_id
12     8      ts_ms
20     2      prio
22     2      ttl_h
24     2      text_len
26     2      meta_len
28     2      vec_len
30     2      flags
32     ...    text[text_len]
       ...    meta[meta_len]
       ...    vec[vec_len]     ← 向量置于末尾(ADR-010)
       ...    pad to 4
前后向兼容的实现方式:v1 读 v2 记录时,读 rec_len → 消费 text + meta → 按 rec_len 直接跳到下一条,安全跳过末尾的 vec。反之 v2 读 v1 时 vec_len = 0,视为无向量。

5.3 烧录与校验规则

  1. 4 KB 分块写入,每块带 CRC32;失败块可重传(断点续传)
  2. 先校验后落盘:整包 payload_crc32 校验通过才开始写
  3. 铁律:失败路径不得破坏已有库。导入在一个事务内完成;任一步失败 → abort → 已灌入的记忆必须完好如初
  4. 同 name 高 ver 覆盖低 ver;OVERWRITE_SAME_NAME 未置位时同名碎片导入应被拒绝并给出明确错误
  5. 错误包必须被拒绝:magic 错 / version 不支持 / CRC 不符 / rec_count 与实际不符 / 单条 text_len > 150

现有 em_crc32() 已实现,可直接复用。KB 镜像 OTA 走 POST /kbota,支持 ?dry=1 预检。

5.4 契约事实澄清(2026-10-28 实测回填,非新增要求)

① 分块大小:4 KB 是 HTTP 侧口径,串口侧必须 1 KB。 承载差异,不是退化。串口 CLI 单行上限 LINE_MAX = 2048,base64 膨胀 4/3: 1024 B → 1368 字符,加 JSON 包裹约 77 B = 1445 ≤ 2047(余量 602); 而 4096 B → 5464 + 77 = 5541 ≫ 2047,整行会被 cli_task 直接丢弃且零响应(连"块太大"都报不出来,现场表现为超时)。 两侧共用同一套 commit 校验(累计长度 + 整文件 CRC32),语义一致。

② 头域完整性由传输层负责,引擎只管 payload。 em_frag_import() 在拿到完整字节流后,对 32 B 文件头只做结构校验 (magic / version / header_len / rec_count 与实际条数一致 / 单条 text_len ≤ 150),不校验头自身的完整性—— payload_crc32 只覆盖 header 之后的 payload。 头字节损坏若未破坏上述结构约束,不会被引擎检出; 这份风险由传输层接住:frag.commit 验的是整文件(含头)CRC32, 不符即丢弃、imported = 0、不碰库。

板端实证(COM3,2026-10-28 重构后固件 v1.20261028-160744,不是推断): 造一个合法包,运输途中把头里 frag_id(offset 28)最低位翻转(8403 → 8402,payload 一个字节没动):

  • 翻转后 payload CRC 仍自洽——头内字段与实算值同为 0x6DAF6629, 即引擎侧所验的那一项必然放行(这是反证:引擎单独看不出这个包坏了);
  • 被 frag.commit 的整体 CRC32 拦下:rc = -9, 实收 0x300719CD vs begin 时承诺 0x68828C92;
  • imported = 0,库条数 1085 → 1085 不变,翻转出来的碎片域 8402 没有被凭空建出来。

若不拦的后果:那批记录会静默落进一个凭空冒出来的碎片域, 事后在 frag.list 上只看到「多了个不知道哪来的碎片」,无从追溯。 这条实证说明 frag.commit 的整文件 CRC 是承重的,不是冗余校验。

⚠ 引用纪律(务必遵守):上面两组 16 进制数是那一次、那个包的量。 同一天里三次复现就得到了三组互不相同的数值: 0x9AA0EE81(旧固件)→ 0x18B91930 → 0x6DAF6629(重构后固件), 结论始终稳定、数值每次不同。 它们是留痕的实测证据,不是固定期望值,禁止抄进任何断言: 判据是「两者是否相等」(payload CRC 字段 == 实算值 ⇒ 引擎必然放行; 整体 CRC != begin 承诺值 ⇒ 被拦),不是和常数比;写成常数会在换包后变成 「期望值过期导致的假红」,把一次正常复现误判成回归。

③ 实测错误分野(G7 判据口径,勿混用):

  • 改 magic → EM_E_INVAL(-1)「包非法」
  • version 改成不支持的 2 → EM_E_INVAL(-1)
  • 谎报 rec_count(CRC 仍自洽)→ EM_E_INVAL(-1)
  • 翻 payload 任一字节 → EM_E_CORRUPT(-9)「包校验失败」
  • 翻 32 B 头域内一字节 → 实测同样 EM_E_CORRUPT(-9)(头损坏传导到 payload 解析失败), 不是引擎在验头 CRC——是整文件 CRC 先被传输层接住才表现为 CORRUPT。 两层的分工必须分清,否则 G7「坏包一律拒绝」的判据会被误读成「引擎会验头」。

④ 单包容量上限(串口侧):会话缓冲 2 MB(PSRAM); 超过 2 MB 的 frag.begin 明确返回 EM_E_NOMEM(-2),不静默改小。 已实测单包灌入 12,000 条(907,992 B / 887 块)成功(Dev-B,该包 76.6 B/条)。

★ 2 MB 是「会话缓冲上限」,不是「可导入容量」的承诺。 分界存在,且落在 60 与 64 B/条之间——谁先到顶取决于记录尺寸 s。

三个量、两条公式:

  • s = 落库每条字节数。EM_REC_STORE_MAX=644 且 4 字节对齐 ⇒ s 只能取 {44, 48, …, 644},连续值不可达。
  • 线上每条 ≈ s − 12 字节。实测锚点:12,000 条 / 线上 1,003,960 B = 83.663 B/条,落库 95.66 B/条,差 11.997 ≈ 12。
  • C = 板记录区字节数(见下)。
量公式含义
N_boardC / s板能装的条数(用落库尺寸算)
N_sess2,097,120 / (s − 12)2 MB 会话上限内线上能装的条数(用线上尺寸算;2,097,152 − 32 B 头)

两者不约掉 s(一个除 s、一个除 s−12),所以必然存在分界。

Dev-A 的 C = 2,590,800 B = rec 区 635 页 × 4080 B/页。两个因子的证据强度不同,必须分开说:

因子值证据强度
rec 区页数635板端实证:em.stat 报 page_total=992;灌满撞 EM_E_FULL 时恰落在 635/992;与分区表几何逐级扣除(保留/WAL/时序/ANN)所得一致
页内可用字节4080引擎常量推算(EM_PAGE_SIZE 4096 − 16 B 页头),从未被直接读取

前提:实际烧录的分区表 == 仓库 partitions_emgw.csv(Dev-A 8MB / 992 页 / rec 区 635 页)。分区表若换,C 重算。

⚠ em.stat 的 bytes 不可当作 C 引用。 它是写指针的页字节位置,不是容量——源码为证:em_core.c:127-130 occupied = (rec_cur_page − rec_first) × EM_PAGE_SIZE + rec_cur_off; out->bytes = occupied。 反常信号:清库后一页零记录却报 bytes=16。任何用 em.stat.bytes 直接当容量、或用 bytes/count 反推记录尺寸的做法,都会得到错数。 ⇒ 更不要用 em.stat.bytes 去裁决细小的字节差:它是个口径本身就含糊的量(还夹着页尾弃置), 拿它分辨 S=44 上两法 72 B 的预测差(2,600,808 vs 2,600,880)等于零信噪比——那一档要裁决就数条数,不要称字节。

⚠ 读表前必读:本表 P(进入页数)是两列,含义不同,引用时必须写明是哪一列。 P_full = 灌到理论最大条数时的进入页数;P@accepted = 灌到实际提交水位(EM_FRAG_BATCH=8 整批回滚后的条数)时的进入页数。 板端 em.stat 的 rec_pages_used 读到的是 P@accepted,不是 P_full。 两列是否相等取决于用哪种算法:S=644 上读码推定(闭式)给 P@accepted = 634、读码推定(逐页模拟)给 635 (两者都不是实测;见 ★ 与下方 note 块)—— 未实测裁决前并列列出,禁止只挑一个写。

⚠ 抬头说明(读这张表前第二条必读): ① 本表 N_board / P 两列的两种取值全部是「读码推定」,没有任何一个是实测—— 「读码推定(逐页模拟)」模拟的是对 em_mem.c 的理解,「读码推定(闭式)」是代数近似,二者都不是板子行为的观测值(都不是实测); ② 两法差异只来自一件事:是否计入「页尾弃置」(见下方 note 块)——这是记录劈两段导致相位漂移后的二阶效应; 一阶命题「记录级零头不浪费、slack ≡ 0」两法一致(这也是第三次撤回的正确结论,未被推翻); ③ 权威值取「读码推定(逐页模拟)」(不是实测,只是建模更细),闭式值以「(闭式 …)」并列留痕;S=48/60/64/112 四档两法一致,S=44 / S=644 两档 ★ 处为分歧点; ④ 本表的实测锚点有两行:S=60 与 S=112(S=60 = #59,2026-10-28,Dev-A / EME-71FAC8,日志 _out59.txt;S=112 见 ⑤,三条限定缺一不可):
count=43,176 / em.stat.bytes=2,600,720 / rec_pages_used=635 / frag.list.bytes=2,590,560 / 尾页 cur_off=3,856。
该行同时锚定了四个量:U=4080(反解)、H=16、T=0、W=0
(2026-10-28 team-lead 裁定:【留痕·原稿笔误】原稿曾把本档记录大小 S = 60 误写进 T 的位置, 已裁定订正为 T=0——60 是 S,不是 T; T 的符号位已被 R = 16P + W + (P−1)T 中的「每页追加开销」占用,不要给它换新名字)。
其余四档(44 / 48 / 64 / 644)仍是「读码推定(逐页模拟 / 闭式)」,不是实测—— 它们没有板端数据支撑,谁标实测谁负责。
由实测锚点反解出的公式是 U = 4080 = (2,600,720 − 16×635) / 635,其中 635 与 2,600,720 是实测、16 是源码常量, 4080 因此是"反解"不是"直接实测"——对外一律写"板端实测反解",不许简写成"实测 4080"。

⚠ 落地前算术复核(spec-cap-table,2026-10-28):上面这段里两处存疑,先按原文留痕、不擅自改字,待 team-lead 裁定。 【2026-10-28 裁定结果:② 已裁定(原稿误把本档记录大小 S = 60 写进 T 的位置 ⇒ 订正为 T=0,见下; T 与 S 不是同一个量);① 仍存疑未裁定,保留原文。】 ① 该反解式差 240 B:(2,600,720 − 16×635) / 635 = 2,590,560 / 635 = 4,079.62… ≠ 4080。 缺的 240 B 恰是本档尾批被整批回滚的 4 条(4 × 60,见下方「★ 批量粒度」段)——补回即 (2,600,720 − 16×635 + 240) / 635 = 2,590,800 / 635 = 4,080 ✓。 ⇒ 严格写法是 U = (bytes − 16×P + 尾批残留) / P,而尾批残留(240 B)本身不是观测量、是批粒度推论; 这恰好加强了原结论:4080 只可能是"反解",不可能"直接实测",但原式右侧的数是算不到 4080 的。 ② T 的符号冲突 —— 已于 2026-10-28 由 team-lead 裁定:本文件下方 note 块把 T 定义为 R = 16P + W + (P−1)T 中的每页追加开销,并由同一组实测反解得 T = 0(见「† / ✓ 弃置标记」段); 【留痕·原稿笔误】原稿曾把本档记录大小 S = 60 写进 T 的位置,与上式直接矛盾 (那个 60 是 S,不是 T)⇒ 裁定为笔误,已改为 T = 0; T 的符号位继续由 R = 16P + W + (P−1)T 占用,不换名(给它换新名字会让两个 T 并存,比笔误更难查)。 裁定后本表可以引用 T,但只能按「每页追加开销」这一个定义用。

承接(原 ④ 的其余内容,未被上面覆盖,保留留痕): 反解链 cur_off = 2,600,720 − 634×4096 = 3,856 = 16 + 3,840 ⇒ 尾页 64 条、前 634 页各 68 条(634 × 4080 = 2,586,720) ⇒ U = 4080、H = 16 由实测反解确认;又因 R = 10,160 = 16×635 恰等无余量 ⇒ W(S=60) = 0。 由 U = 4080 与实测 rec_pages_used = 635 ⇒ C = 635 × 4080 = 2,590,800 B,口径由「引擎常量推算」升为「实测反解」。

⑤ 第二个实测锚点 S=112(三条限定缺一不可,漏一条就是假绿): 【实测(旧固件 v1.20261028-104711 · em.bulk 路径 · C:\emgw-build\_cap_devA_final.txt:2/13)】 count = 23,132 / bytes = 2,600,944 / rec 页 = 635/992(清库前基线 :2 与灌库后 :13 两次读数一致); 反解 2,600,944 − 16×635 = 2,590,784 = 23,132 × 112 ⇒ 落库 112 B/条, 且 23,132 = ⌊2,590,800/112⌋ 是紧界(余 16 B,装不下第 23,133 条),末轮日志 rc=-3 库已满(EM_E_FULL) 亦为证。
限定一:跑在旧固件 v1.20261028-104711(_cap_devA_final.txt:1),不是当前 19:18:32 固件; 限定二:走 em.bulk 路径(em_capacity.py:104-107 传 {"n","batch","prefix","frag_id","prio","start"}),不是 frag.begin/chunk/commit; 限定三:因此不受 EM_FRAG_BATCH=8 约束——23,132 % 8 = 4,它不是 8 的倍数(见下方「★ 粒度」段的路径分野)。
标签固定写法(逐字,三个限定缺一不可):「实测(旧固件 v1.20261028-104711 · em.bulk 路径 · _cap_devA_final.txt:2/13)」。 谁把它写成"当前固件实测",谁负责(写成"当前固件实测"就是假绿——当前固件的 frag.* 路径上没有这个数)。
它证明了什么:它是 C = 2,590,800 的独立佐证——23,132 = ⌊2,590,800/112⌋ 是紧界(余 16 B,装不下第 23,133 条), 与 #59(S=60 余 240 B)并列 ⇒ C = 2,590,800 现被两个不同 S、两条不同导入路径独立佐证(两者余量都在 C 之内、且都装不下下一条)⇒ 比只有一个锚点强得多; 同档 N_board 列的读码推定值 23,132(读码推定,不是实测)与实测逐字相等。
它不证明什么(别越界引用):① 两算法在 S=112 上本就一致 ⇒ 该锚点只验证 C 与 ⌊C/S⌋,不裁决两算法(裁决仍靠 S=44 / S=644); ② accepted = 23,128(8 截断)那一列在 em.bulk 路径上不成立,实测是 23,132 ⇒ 本表 accepted 列是 frag.* 路径口径; ③ 它也不验证 S=112 的 bytes 预测 2,600,496——那是 8 截断后的 frag.* 预测,实测 2,600,944 与之差 448 B = 4×112,是路径差不是误差。 ⑥ accepted 的 8 截断是「路径专属」,不是普适结论:accepted_max(S) = 8 × ⌊⌊C/S⌋/8⌋(EM_FRAG_BATCH=8,em_frag.c:69,整批回滚)
★ 行号订正(2026-10-28,frag-dev-2 找出、team-lead 亲自 grep 核实):粒度 8 的出处是 em_frag.c:69 #define EM_FRAG_BATCH 8u——不是 :67(:67-68 只是它上方的注释块,:26 是注释提及、:210 是使用点)。
★ 更硬的依据(比只引 #define 有说服力,「整批回滚」这四个字就出自这两行) 【读码推定 · em_frag.c:265-266】: 265: accepted += batch_n; /* 越过提交点才并账:只有落库的才算 accepted */ / 266: bytes += batch_bytes; ⇒ 越过提交点才并账 ⇒ 撞 EM_E_FULL 时整批回滚 ⇒ accepted 必然是 8 的倍数,末页不足一批的空间被整批吃掉。 ⇒ 末批残留最多浪费 7 条(S=60 时 420 B;S=644 时 4,508 B)。 实测佐证:S=60 尾页仅剩 240 B(= 4 条),而下一批要 8×60 = 480 B ⇒ 整批回滚,那 4 条的空间被吃掉 ⇒ 43,180 → 43,176。 反例(不许跨路径套用):S=112 走 em.bulk 实测 23,132,而 23,132 % 8 = 4 ⇒ 拿该式预测会差 4 条。 ⇒ 用 ⌊C/S⌋ 还是用它,先看导入走哪条路;只有 frag.* 路径才做 8 截断。

S (B/条)N_boardP_fullacceptedP@acceptedN_sess谁先到顶
4458,843★(闭式 58,881)63558,840★(闭式 58,880)63565,535板容(2 MB 过配)· text 全空,不可抽样回查
4853,97563553,96863558,253板容(2 MB 过配)
6043,18063543,17663543,690板容 —— 最后一条"过配"取值
6440,48163540,48063540,329会话上限 —— 第一条"不过配"取值
11223,13263523,12863520,971会话上限(2 MB 不过配)
6444,021★(闭式 4,022)6354,016635★(闭式 634)3,318会话上限(2 MB 不过配)

连续解 s = 62.97(解 2,590,800/s = 2,097,120/(s−12)), 但 s ≡ 0 (mod 4) ⇒ 62.97 不可达,实际翻转点在 60 → 64 之间。 自洽锚点:s=112 时 N_board = 23,132,与容量实测 em.stat count = 23,132 完全吻合 【实测(旧固件 v1.20261028-104711 · em.bulk 路径 · C:\emgw-build\_cap_devA_final.txt:2/13)——三条限定见抬头说明 ⑤,一个字都不能漏】。 不加这三条限定就写"实测",读者会以为它是当前固件 frag.* 路径的数 —— 那就是假绿。 且它吻合的是 N_board 列(⌊C/S⌋ = 23,132),不是 accepted 列(23,128,8 截断只属于 frag.* 路径)。

◆ 六档表「两条路径并存」脚注:23,128 与 23,132 并存不是矛盾
本表 S=112 这一行同时出现两个数,这是两条导入路径,不是误差、不是笔误、不是版本冲突:

  • accepted 列的 23,128 —— 这是 frag.* 分块灌库路径的值: 8 × ⌊⌊C/S⌋/8⌋ = 8 × ⌊23,132/8⌋ = 8 × 2,891 = 23,128。 【读码推定】粒度 8 来自 em_frag.c:69 的 #define EM_FRAG_BATCH 8u; 整批回滚见 em_frag.c:265-266(accepted 越过提交点才并账)。
  • 表下那句「s=112 实测 count = 23,132」的 23,132 —— 这是 em.bulk 路径的值:⌊C/S⌋ = ⌊2,590,800/112⌋ = 23,132。 【实测】旧固件 v1.20261028-104711 · em.bulk 路径 · C:\emgw-build\_cap_devA_final.txt:2(就绪基线)/ :13(灌库后),同一块板 EME-71FAC8。

⇒ 23,128 与 23,132 并存不是矛盾,是两条路径:差 4 条 = 448 B, 全部来自「frag.* 有 8 条整批回滚、em.bulk 没有」这一条机理差异。 不许把 23,132 当成 23,128 的"实测证伪", 也不许反过来拿 23,128 去"修正"实测的 23,132。
◆ 识别指纹(一眼分辨这是哪条路径的档):23,132 % 8 = 4
23,132 % 8 = 4 ≠ 0 ⇒ 它不是 8 的倍数 ⇒ 它不可能是 frag.* 路径的 accepted (frag.* 的 accepted 必然是 8 的倍数)⇒ 这一档是 em.bulk,指纹成立。
反向对照:S=60 档实测 43,176,43,176 % 8 = 0,且 frag 公式给 43,176、 bulk 公式给 ⌊C/S⌋ = 43,180 ≠ 43,176 ⇒ #59 这一档确定是 frag.*,不是 em.bulk 【实测】_out59.txt,固件 v1.20261028-191832。
⇒ 判据一句话:count % 8 ≠ 0 ⇒ em.bulk 档;count % 8 = 0 且 count ≠ ⌊C/S⌋ ⇒ frag.* 档。
⚠ 指纹的失效边界:若 ⌊C/S⌋ 本身是 8 的倍数,两条路径给出同一个数, 指纹不可分辨 ⇒ 此时只能按"用的是哪条命令"判路径,不许靠数判。

◆ ⑨ 判据的盲区(显式登记,不许伪装成已覆盖)
⚠ 整块是「推算 / 读码推定」,没有一个是实测:它推导的是判据本身的能力边界; 板端那两个锚点上 Δ = 0(R = 10,160 = 16×635 精确相等), 没有任何实测证据表明存在 1..40 B 量级偏差 ⇒ 盲区是算式推出的能力缺口,不是板子上的观测现象。
⑨ 的判据是 |U8 − U6| ≥ 1e-4,其中 U8 − U6 ≡ (R − 16·P) / [P·(P−1)] (恒等式,C:\emgw-build\_cfverify.py 的 check_identity() 逐格核对)。 记 Δ ≡ R − 16·P:

  • ⑨ 的报警门限:|Δ| ≥ 1e-4 × P × (P−1) = 1e-4 × 635 × 634 = 40.259 B ⇒ ⑨ 的盲区 = |Δ| ∈ 1..40 B(此区间内 ⑨ 静默)。 【推算】阈值换算式,非板端实测。
  • ⑩ 零盲区:⑩ 的判据是整数精确比较 R % 16 == 0 且 R // 16 == P ⇔ R == 16·P ⇒ ⑩ 静默 ⟺ Δ == 0,任何 Δ ≠ 0 都报警。 【读码推定】判据定义见 em_measure_cap.py:1133 (行号会漂移,以 grep -n "r % 16 == 0 and r // 16 == p1" em_measure_cap.py 为准)。
  • ⇒ 不存在"两条都漏"的档:⑨ 漏掉的 |Δ| ∈ 1..40 B 由 ⑩ 全部接住 ⇒ ⑨ 有盲区,⑩ 没有,二者互补构成完整覆盖。 【推算】

逐点实算(P = 635,纯离线复算,非板端实测):

Δ (B)R⑨ U8−U6⑨⑩ 判据⑩
010,1600静默R%16=0,R//16=635 == P静默
+110,161+2.484e-06静默R%16=1 ≠ 0报警
+1610,176+3.974e-05静默R%16=0,R//16=636 ≠ P报警
+3210,192+7.949e-05静默R%16=0,R//16=637 ≠ P报警
+4010,200+9.936e-05静默R%16=8 ≠ 0报警
+4110,201+1.018e-04报警R%16=9 ≠ 0报警
−1610,144−3.974e-05静默R%16=0,R//16=634 ≠ P报警

⚠ 留痕 —— 已被证伪的旧表述(不许复活):
旧表述一:曾写「注入 R += 16 ⇒ ⑨ 不报警、⑩ 也不报警」。 后半句错:Δ=+16 时 R//16 = 636 ≠ P = 635 ⇒ ⑩ 报警 (Δ=−16 时 R//16 = 634,同样报警)。正确表述是上面第三条。
旧表述二(team-lead 2026-10-28 自查,留痕不擦):曾写「⑩ 只在 Δ ≢ 0 (mod 16) 时报警」—— 错,漏看了 R // 16 == P 这一半(属"没读全判据就下结论"): ⑩ 的两条判据必须同时成立,Δ=+16 时 R%16=0 虽成立、但 R//16=636 ≠ 635 ⇒ ⑩ 报警 (Δ=+32 时 R//16=637,同样报警) ⇒ ⑩ 对任意 Δ ≠ 0 都报警,根本没有盲区;正确口径是「⑨ 有盲区,⑩ 没有,二者互补构成完整覆盖」。
本块由纯离线复算得出,不是板端实测;复核脚本 C:\emgw-build\_cfverify.py 的 check_blind_spot()(行号会漂移,以 grep -n "^def check_blind_spot" _cfverify.py 为准), 把上面每条都钉成断言 —— 谁把阈值收紧到能覆盖 16 B(如 1.24e-6), 「⑨ 静默」那几条立刻变红。

◆ 复核脚本的两种模式与覆盖边界(team-lead 2026-10-28 裁定「甲」方案)
C:\emgw-build\_cfverify.py 有快模式与全量模式两种跑法 (开关 --check-anchors-only;行号会漂移,以 grep -n "check-anchors-only" _cfverify.py 为准):
· 快模式覆盖:2 个实板锚点(check_anchors())+ 恒等式(check_identity()) 再加三类纯算术自检——⑨ 盲区断言(check_blind_spot())、 阈值语义(check_threshold_semantics())、写指针回滚(check_abort_rollback());
· 快模式不含空库锚点(P = 1 边界,check_real_board())——那一条只在全量模式跑。
⚠ 名字防混(必读):check_anchors()(2 个实板锚点:#59 S=60 frag 档、 旧跑 S=112 bulk 档)与 check_real_board()(空库锚点:em.stat 空库 P=1 / last=0 那三格)都叫"锚点"但不是一个东西——前者是容量锚点,后者是 P=1 边界锚点; 快模式只含前者。
★ 实测规模与耗时【实测 · 本机 subprocess 计时,非板端实测,frag-dev-2 2026-10-28】: 全量(默认)200 项断言,绿 200 / 红 0,exit 0;快模式(甲版)83 项断言,绿 83 / 红 0,exit 0; 快模式端到端墙钟 5 次:0.137 / 0.144 / 0.149 / 0.166 / 0.147 s(中位 0.147 s), 其中纯算术部分只 +0.0002 s,其余几乎全是 Python 解释器启动开销。
⇒ 触发条件(照这个选跑哪种,别一律跑全量):改 EM_FRAG_BATCH 或改 ⑨ 阈值后, 跑快模式即可(83 项里已含受影响的那三条);只有动到「空库 / P = 1 边界」或新增实板锚点时,才需要跑全量。
为什么必须补那三类(批甲的理由,别当随手改):裁决的触发清单里有「改 EM_FRAG_BATCH 后必须重跑」, 而 check_abort_rollback() 里"没回滚"的读数 240 B = 4 条 × 60 是半批量级, BATCH 一变它跟着变 ⇒ 甲案之前快模式不含它,改了 BATCH 只跑快模式会看着全绿、实际漏判—— 立了规矩但工具跟不上,比没规矩更糟。
★ 甲案收益的实证(不是纸上推演)【实测】:改前「快模式 + 阈值打回 0.5」是静默绿; 改后 rc=1 变红 ⇒ 快模式确实能抓到"改 BATCH / 改阈值"这类回归。
⚠ 永久口径(不是临时落后标记,别删):本段记录的是已裁定的目标覆盖; 实际覆盖以 python _cfverify.py --check-anchors-only 的标题行为准 (标题行由代码自己打印,代码改了它跟着变;本段文字不会跟着变)⇒ 不要以本段文字代替实跑。核对调用项用 grep -n "check_blind_spot(t)" _cfverify.py (行号会漂移,以 grep 为准)。

★ 落库口径(算"装不装得下"用这个):S = 12 + L,其中 L 是线上记录长度,12 = EM_REC_HDR_BYTES。
S=60 的实例校验(三条全部成立,已逐项复核): 线上 Σ L = 43,176 × 48 = 2,072,448 B; 落库 Σ S = 43,176 × 60 = 2,590,560 B(= frag.list.bytes); 写指针 = 落库 + 16 × 页数 = 2,590,560 + 10,160 = 2,600,720 B(= em.stat.bytes)。
校验:2,590,560 − 2,072,448 = 518,112 = 43,176 × 12 ✓;2,600,720 − 2,590,560 = 10,160 = 635 × 16 ✓。
三个口径不许混用:算容量用落库,算分块 / 线上传输用线上,em.stat.bytes 是写指针位置、不是剩余容量(见上方红框)。

★ accepted 的粒度由导入路径决定:frag.* 是 8 条,em.bulk 不是。 frag.* 按 EM_FRAG_BATCH=8 整批提交,放不下就整批回滚(em_frag.c:69 的 #define; 「越过提交点才并账 ⇒ 整批回滚」见 em_frag.c:265-266)⇒ accepted_frag(S) = 8 × ⌊⌊C/S⌋ / 8⌋(C = rec_pages × 4080)。
S=60:⌊2,590,800/60⌋ = 43,180,8 取整 ⇒ 43,176;frag.* 实测正是 43,176 ✓(#59)。
⚠ 别跨路径套用(有实测反例):em.bulk 路径 S=112 实测 23,132,而 23,132 % 8 = 4 ⇒ 拿上式去预测 em.bulk 会差 4 条。em.bulk 侧与「accepted = ⌊C/S⌋」一致(23,132 = ⌊2,590,800/112⌋); 但仅凭这一个数据点无法把它的粒度钉死为 1——总数 23,132 与末轮写入数 3,132 都只排除了 8(3,132 % 8 = 4), 粒度 1 / 2 / 4 都不被这个数排除 ⇒ 本文只写「em.bulk 粒度 ≠ 8」,不写「= 1」,直到有人补跑一档能分辨 1/2/4 的测量。
推论(仅 frag.* 路径成立):由 accepted 反推 C 的不确定度粒度是 8×S,不是 1×S (⌊C/S⌋ ∈ [accepted, accepted+7] ⇒ C ∈ [accepted×S, accepted×S + 8×S)); 也解释了为什么 #59 停在 99.99%(余 240 B = 4 条,不够下一批的 480 B)。
⇒ 这条同时是上面"反解式差 240 B"的来源:2,590,800 − 2,590,560 = 240 就是被整批回滚吃掉的 4 条—— 它不在任何观测量里,是从批粒度推出来的,所以带它的式子(含 C = 635 × 4080)一律是"反解"口径。

★ P 列的两种算法、算草与当前分歧(2026-10-28 第二轮复核)

量定义:N_board = 该 S 下能落库的最大条数; accepted = ⌊N_board/8⌋×8(EM_FRAG_BATCH=8 整批回滚); P = 进入页数;R = 16 × P@accepted(每页 16 B 页头的累计开销); bytes 预测 = accepted × S + R; N_sess = ⌊2,097,120/(S−12)⌋(线上每条 = 落库每条 − 12 B,EM_REC_HDR_BYTES=12,结构性差值非拟合)。

算法甲 · 读码推定(闭式)(不是实测;第一轮用的):N_board = ⌊C/S⌋,P = ⌈accepted·S/4080⌉,4080 = EM_PAGE_SIZE 4096 − 16 B 页头。 隐含前提:每页 payload 恒 4080。取整方向各自成立(条数只能整条 ⇒ 向下;最后一页哪怕只落进一个记录头也算进入 ⇒ 向上)。

算法乙 · 读码推定(逐页模拟)(不是实测;本轮新增,★★ 与上表 ★ 同源):照抄 mem_append_raw() 的三条分支逐条搬运—— ① avail < 12 ⇒ 跳页(em_mem.c:109); ② S ≤ avail ⇒ 整条写下; ③ 否则 劈两段:先填满本页尾,余下部分写到下一页 off = 16 + (S − avail)(em_mem.c:155-164)。 ③ 会把相位推到下一条 ⇒ 每一页内容起始的 off 不再总是 16 ⇒ 跑一段时间后会出现 avail ∈ {4, 8}(< 12 但放不下记录头)⇒ 那 4/8 个字节被丢弃,该页实得 4076/4072 而非 4080。 因此「每页 payload 恒 4080」在一阶成立(不存在整条级别的零头浪费),在二阶不成立(存在 ≤ 11 B/页的页尾弃置)。 表中与本节出现的「模拟/逐页模拟」一律读作 读码推定(逐页模拟)、「闭式」一律读作 读码推定(闭式): 两者均未上板、未编译,是对代码的理解而非观测(都不是实测);在不致歧义处方简写为"模拟/闭式"。 —— 本轮据此下调了第一轮那句过强的表述(「页内零头不会被浪费、每页 payload 恒 4080」),留痕不改数据; 一阶结论(slack ≡ 0、不存在记录级零头浪费)仍然成立且未被推翻,被推翻的是它的二阶版本。

SN_board
逐页模拟(闭式)
acceptedaccepted×S(B)页尾弃置(B)P@accepted
逐页模拟(闭式)
R = 16×P + Wbytes 预测
= accepted×S + R
4458,843(58,881)58,8402,588,9601,688 †635(635)11,8482,600,808
4853,975(53,975)53,9682,590,4640635(635)10,1602,600,624
6043,180(43,180)43,1762,590,5600 ✓635(635)10,1602,600,720
6440,481(40,481)40,4802,590,7200635(635)10,1602,600,880
11223,132(23,132)23,1282,590,3360635(635)10,1602,600,496
6444,021(4,022)4,0162,586,304844 †635(634)11,0042,597,308

† / ✓ 弃置标记:出处标签与自检适用性(frag-dev 2026-10-28 补,team-lead 派单)

† = 读码推定(逐页模拟),不是实测。本表 S=44 的 W = 1,688 B、S=644 的 W = 844 B 均未上板——#59b 尚未跑,这两档禁止标"实测",也禁止把这两档的 accepted / bytes 当实测引用。

✓ = S=60 的 W = 0 已由 #59 板端实测确认 【实测反解 · C:\emgw-build\_out59.txt:31/35-39】count=43,176 / bytes=2,600,720 / rec_pages_used=635 ⇒ R = 10,160 = 16×635 恰等、无余量 ⇒ W = 0(同时 T = 0)。 这是 W = 0 这一档上唯一被实测确认的锚点——S=112 的 R 虽也是实测的 10,160, 但它的 W = 0 是「读码推定」(gcd(4080,112) = 16),不是实测,见上方「恒等式」段与抬头说明 ⑤。

⚠ 自检适用性(逐档,勿跨档套用):有弃置的两档恒等式是 R = 16×P + W ⇒ R/16 == P 在 S=44 与 S=644 不适用: S=44:11,848 / 16 = 740.5 ≠ 635;S=644:11,004 / 16 = 687.75 ≠ 635。 其余四档 W = 0 ⇒ R/16 == P 适用(S=60 已实测验证)。 ⇒ 有弃置的两档一律用「条数」裁决,不要用「字节」裁决:S=44 上甲/乙的 bytes 预测只差 72 B,而 accepted 差 40 条(5 个批量)——称字节等于零信噪比。

★ 分歧落点与量级(务必看清再引用): 六档里只有 S=44 与 S=644 会产生页尾弃置。 失效条件是解析式,不必靠穷举背:弃置发生 ⇔ gcd(4080, S) ∈ {4, 8} (推导:设 r = 4080 mod S,第 n 页末余量 a_n,劈分后下一页 a_{n+1} = (a_n + r) mod S ⇒ a_n 的可达值恰为 gcd(4080, S) 的全体倍数 ⇒ 4 或 8 可达 ⇔ gcd | 8;又因 S ≡ 0 (mod 4) 且 4080 ≡ 0 (mod 16) ⇒ g ≥ 4 ⇒ 判据化为 g ∈ {4, 8}。 151 个可达 S 全部命中,弃置者恰好 59 个;本表六档里 48/60/64/112 的 gcd 分别为 48/60/16/16 ⇒ 无弃置,S=60 差值为 0 ⇒ #59 的预测不受影响)。 影响量级:S=44 条数 −38 条(0.065%,58,881→58,843、accepted 58,880→58,840),P 不变; S=644 条数 −1 条(4,022→4,021,accepted 仍为 4,016),P@accepted 由闭式 634 变成 635。 连带修正(2026-10-28 晚):有弃置时 R = 16×P + W 而非 16×P ⇒ S=44 的 R = 11,848、bytes 2,600,808;S=644 的 R = 11,004、bytes 2,597,308。 (闭式对应:S=44 R = 10,160 / bytes 2,600,880;S=644 P=634 ⇒ R = 10,144 / bytes 2,596,448。) ⇒ 注意一个反直觉点:S=44 上两法的 bytes 预测只差 72 B(2,600,808 vs 2,600,880),而 accepted 差 40 条 —— 这一档要用"数条数"裁决,不要用"称字节"裁决。 运行前置(2026-10-28 状态,照抄前先确认是否已解除): 测量脚本 em_measure_cap.py 目前对 gcd(4080, S) ∈ {4, 8} 的 S 加了前置断言、整跑拒绝(rc=4)—— 而 S=44 的 gcd 恰为 4 ⇒ 当前这一档跑不起来。已提出改为"照跑 + 明确禁用 R/P 自检"(约 3 行), 状态更新(2026-10-28 20:36):守卫已按 team-lead 三条硬条件改造完成(em_measure_cap.py): ① 新增显式开关 --allow-waste-s,不给开关照旧拒绝(rc=4); ② 给开关后照跑,并打醒目警告横幅到 stdout 与结果文件两处,写明"本轮不产出任何 R/P 自检结论(⑥⑧⑨ 一律不判、不给数),唯一裁决量是条数"; ③ ⑥⑧⑨ 已用 rp_selftest_allowed(S) 显式门控;条数核对仍走已存在的 probe_frag_list()(frag.list 从介质重新点一遍,无条件调用)。 ⇒ S=44 现在可以跑,但必须显式带 --allow-waste-s,且只认条数结论。 (另:该档另有 accepted_max = 8×⌊⌊C/S⌋/8⌋ 的批截断,见抬头说明 ⑥。) (方法论一条,留这免得下次重犯:该守卫当初实现成"拒绝整个运行",理由是"不产出误导性的红"; 而 #59b 选 S=44 的目的恰恰是绕开 R/P 自检不可用的档位、纯靠条数裁决 ⇒ "拒绝整个运行"把被测对象一起挡掉了。守卫粒度必须比它保护的东西更细,否则会把要测的东西一起屏蔽。) 推论:所谓「S=644 上 P_full 与 P@accepted 不等(635 vs 634)」是闭式近似的产物,逐页模拟下两者同为 635。 两算法谁对,不由文档裁定:判据跑一次即可(见下「自检式」),#59 未跑前并列列出。

自检式(2026-10-28 晚修正,上一版写错了;再修:由「区间夹」改为精确恒等式): ★ 恒等式:R ≡ bytes − count×S 必须精确等于 16×P + W(S), 其中 P = P@accepted、bytes 取板端 em.stat.bytes、 W(S) = 页尾弃置累计数(本表第 5 列),其取值由判据 弃置发生 ⇔ gcd(4080, S) ∈ {4, 8} 决定: gcd(4080, S) ∈ {4, 8} ⇒ W(S) > 0;否则 ⇒ W(S) = 0。
这是等式,不是区间夹——两侧必须逐字节相等,差 1 B 即为模型失效; 弃置那几字节不进记录也不进页头,只能落在 R 里 ⇒ 有弃置时 R 必然大于 16×P,且多出来的部分恰等于 W(S)。
两档对照(恒等式的两个检验点):
· S=60:gcd(4080, 60) = 60 ∉ {4, 8} ⇒ W = 0 ⇒ R 必须恰好 10,160; 实测 2,600,720 − 43,176×60 = 10,160 ✓(#59,_out59.txt:31/35-39)。
· S=112:gcd(4080, 112) = 16 ∉ {4, 8} ⇒ W = 0 ⇒ R 必须恰好 10,160; 实测 2,600,944 − 23,132×112 = 10,160 ✓。
⚠⚠ S=112 的 W = 0 是「读码推定」(由 gcd 判据推得,#59b 未跑),不是实测,不许顺手标成实测; S=60 的 W = 0 才是实测确认(下方 ✓ 标记)。两档虽同为 W=0,标签不同,引用时必须分开写。
⛔ 区间夹(R ∈ [16P, 16P + 16 + 上界) 那类写法)—— 已于 2026-10-28 由 team-lead 裁定:该上界给不出出处 ⇒ 一律删除,数字亦不得留在文件里: ① 本文件当前没有这种写法(2026-10-28 全文件 grep:零命中)⇒ 无需删除; ② 该上界的出处经 team-lead 本人复核:推不出来(既非源码常量、也非任何观测量、也非某个式子的输出)⇒ 按「出处可指律」:给不出出处就删,不留"降到次位"这个选项,也不留数字(留着等于默认它可以被引用); ③ 替代它的是上面的精确恒等式:R ≡ bytes − count×S 必须精确等于 16×P + W(S)—— 点值断言比区间夹强得多:S 选错一个 lane,R 立刻偏移 100 量级,一眼可见; 而一个宽数百 B 的区间会让 W 的任何推算值都"落在区间内",等于不检验。 实测正例(2026-10-28,#59):S=60 实测 R = 10,160 ⇒ R/16 = 635 = P@accepted ✓(且等于板端 rec_pages_used = 635)—— 这是该式在 W = 0 档上的实测通过实例; 反例仍是 S=44(R = 11,848 ⇒ R/16 = 740.5 ≠ 635)——正例反例成对,正好说明"该式只在 W=0 档成立"不是纸上推演。 (发现人 frag-dev;本文档作者已用模拟器独立验证:六档的写指针位置与 accepted×S + 16P + W 逐格相等,见下校验行。) 两种正确写法,二选一但必须写明用的是哪种:

  • 写带 W 的通用式:(R − W) / 16 == P@accepted,且 P@accepted 必须等于板端 em.stat.rec_pages_used;代价是 W 本身来自读码推定(不是实测),等于在自检里引入了第二个模型;
  • 把 R/16 == P@accepted 的适用范围限定在 gcd(4080, S) ∉ {4, 8} 的 S 上(共 92 个,含本表 48/60/64/112,也含 #59 要测的 S=60 ⇒ #59 不受影响)——建议用这条:它不把推定值写进判据。

校验行(可直接复算): S=44:58,840×44 + 16×635 + 1,688 = 2,588,960 + 10,160 + 1,688 = 2,600,808; S=644:4,016×644 + 16×635 + 844 = 2,586,304 + 10,160 + 844 = 2,597,308; S=60:43,176×60 + 16×635 + 0 = 2,600,720。三者与模拟器逐步搬运出的写指针位置完全相等。

禁止硬编码:不许写 == 635,也不许把某一档的 P / R / bytes 抄成常数——S=644 在两算法下 P 就不同(634/635),S=44 的 R 还会随 W 变;硬编码会在换算法当天变成假红。

S=44 这档的可用性限制(板端实证):工具报 S=44 反解出 text_len=0:本包 text 全空—— 因为 S = 12 + rec_len、rec_len 最小 32 B 全是头域,留不出 text。 结论:S=44 能测容量,但抽样查回(按 text 定位)失效。本档保留在表中仅供容量下界参考,任何"可回查"用例不得使用 S=44。 但这档的交叉验证仍有一条可用路径,且只有这一条: ❌ em.query 按 text 抽样查回 —— 失效(所有记录 text 均为空串); ✅ frag.list —— 仍然有效:它走 emlite_frag_list()(fw_ascii 树:签名行 em_frag.c:292,开花括号 293)扫 flash 页、 逐条 CRC 校验、从记录体 offset 8 取 frag_id,完全不依赖 text,输出 count / bytes ⇒ 是独立于 accepted 的第二路计数。⇒ S=44 上的核对请用 frag.list,不要用 em.query。 这条路已被实测坐实(#59,2026-10-28):frag.list 报 frag_id=9601、count = 43,176、bytes = 2,590,560, 与 accepted 完全一致,且 bytes = accepted × 60 精确相等(em_frag.c:357-358 累加的是含 12 B 私有头的落库尺寸)。
★ frag_id=9601 的归因(实测值不变,归因于 2026-10-28 由 team-lead 裁定改写): 它由 frag.begin 的 opt.frag_id 显式传入并生效(em_frag.c:219-232 第一优先级)—— 本档那次灌库由 em_measure_cap.py 发起,它给 frag.begin 传的是 9600 + b(b = 批序号,第 2 批即 9601), 9600 只是该脚本自己的编号约定,不是固件常量;不传时的固件自动分配规则是 frag_max_id() + 1(em_frag.c:224-231)。
实证(#60,2026-10-28,COM3,实测):传 --frag-id 9777 ⇒ 落库 9777(显式值生效); 库内已有 9777 时不传 ⇒ 落库 9778(= frag_max_id() + 1)。
⛔ 「9600 + session」已被证伪,本文件不得再出现这种说法——9600 在固件里零命中; 把它当"固件的自动分配规则"转述出去,是本档已经犯过一次并被撤回的假绿(_run_fid_split.py,作者本人撤回留痕)。 ⚠ 同名陷阱:同一份 em.stat 里的 frag_bytes = 0 与 frag.list 的 bytes 不是同一个量, 禁止拿 frag_bytes 当"碎片占了多少字节"。 (另注:frag.list 本身的口径边界见本条 ⑤——它不做墓碑回溯扣减,发生过删除的库其 count/bytes 可能偏高。)

N_sess 的来源(诚实版,勿夸大为独立印证):表中 N_sess 由 em_emf.py:587 按 (2,097,152 − 32)/(S − 12) 下取整算出; 本轮核过 fw_ascii/main/include/emlite_bridge.h(注意路径是 main/include/):第 229 行确有容量推算,但写的是 18,600,不是 20,971—— 18,600 / 18,724 = 2,097,152 / 112,用的是落库 112 B 这一侧(口径错); 而 20,971 = (2,097,152 − 32)/100 是同一条公式把口径改成线上 rec_len = 100 之后的更正值(由 frag-dev 提出、并已由其本人撤回"独立"属性)。 ⇒ 两个数出自同一条公式,只是一前一后改了口径 ⇒ 同一条路径,不是独立印证。当前真正站得住的、与线上/落库换算链路相互独立的佐证只有一条板端锚点: S=112 实测 em.stat count = 23,132,恰等于 ⌊C/S⌋ = 23,132 (注意该锚点本身来自 em.bulk 脚手架与 .emf 通道两条路径,脚手架 ≠ 产品通道,勿混引)。 ⇒ 在出现结构上独立的第二来源之前,本表不许用"交叉印证 / 独立印证"描述 N_sess。 ⚠ 上文那个 S=112 板端锚点不能充当这"第二来源:它锚的是 C 与 ⌊C/S⌋(N_board 列), 与 N_sess 不在同一条公式链上,也不验证 2 MB 会话上限(它走 em.bulk、且是旧固件)—— 拿它去印证 N_sess 就是跨量借锚点,属假绿;此处引用它只能用于 C 的口径; 先前流传的一句「S=112 → 20,971 与 main/include/emlite_bridge.h 的独立推算一致」, 经全树 grep 该文件内并无此值、该数值在全树亦零命中 ⇒ 该印证暂无出处,待核(2026-10-28 由提出方本人撤回,详见下方「口径自查留痕」)。

⑥ 点值式(U=4080)的定性 —— 自洽检查,不是独立印证: ⑥ 是从同一次观测解出的点值:设每页可用为 U,则 R = bytes − accepted×S = 16 + (P−1)×(4096 − U) ⇒ U = 4096 − (R−16)/(P−1)(代入 S=60 预测值得 U = 4080)。 用 P 做自洽检查(R/16 必须等于板端 rec_pages_used),不成立则模型失效。 这不是独立印证:区间式与点值式共用 bytes 与 accepted,⑥ 唯一的额外信息是 P, 因此它能抓算术错与数据不自洽,抓不到模型错——若"劈分吃满、页头 16"这个前提本身错(例如页尾弃置被忽略),两式会一起错且不报警。 ⑥ 也依赖 W = 0 这个前提:把正确关系 R = 16P + W 代入 U = 4096 − (R−16)/(P−1),得 U = 4080 − W/(P−1) ⇒ 有弃置时它会把页尾丢弃误算成"每页 payload 变小": S=44 会解出 U = 4080 − 1,688/634 ≈ 4077.34,S=644 会解出 ≈ 4078.67——一个数被压低,却不会有任何报警。 这正是"自洽检查抓不到模型错"的活样本:⑥ 只有一个观测量入口(bytes),页头开销与页尾弃置在它眼里是同一个东西。 另注意措辞边界:⑥ 直接测的是每页非记录开销 O = 4096 − U = 16 B/页,U = 4080 是由 4096 − O 推出来的,不是对着介质读出来的数。

来源标注(照抄前必读): N_board / accepted / P / R / bytes 预测五列, 其绝对值依赖 C 的两个因子:rec_pages = 635(板端实证)与 4080 B/页(引擎常量推算、板端未验); N_sess 依赖会话常量(frag.begin 的 EM_E_NOMEM 已实测锚定)。 算法乙为读码推定(不是实测:逐行复刻 mem_append_raw() 分支,未上板、未编译),与算法甲孰优由一次灌库测量裁决。 把 4080 由「推算」升为「板端实测」、并同时裁决两算法,靠编号 #59 的灌库测量(固定尺寸包灌满 → 读 em.stat 的 bytes / rec_pages_used),尚未执行。 在此之前,本表所有数值一律标推算/读码推定(不是实测),禁止当作实测引用。

⚠ 本条已撤回三次错误,留痕(不要照抄旧版本):

第一次(作废):曾写「分界点 ≈ 90.7 B/条」。错因:把「Dev-A 板容 23,132 条」当成尺寸无关常量去比「2 MB 装多少条」,两列用了不同的 s。

第二次(作废):曾写「2 MB 不过配,对所有记录尺寸成立」。错因:把"线上尺寸"与"落库尺寸"当成同一个量推导,于是 s 被约掉。两者差 12 B,约不掉。正确结论是上表的区间结论。

第三次(作废):2026-10-28,团队负责人提出含 slack 的修正式 R = 16 + (P−1)×(16+slack) (slack = 4080 − floor(4080/s)×s),假设记录不跨页、页内零头被浪费; 经 frag-dev 引证 components/edgemem/emlite/em_mem.c:155-164(记录跨页劈两段写,零头被跨页记录吃掉 ⇒ slack ≡ 0) 后被撤回。正确式为 R = 16 × P。 第三次错误性质与前两次不同:前两次是算术/量纲错,这次是未读代码即凭想当然的物理模型发修正式。

工程教训:参数无关的错与参数相关的错

第三次那个含 slack 的修正式之所以必须留痕,不是因为它错,而是因为它错得隐蔽。它建立在一个想当然的物理模型上: 「记录不跨页 ⇒ 每页只能装 floor(4080/s) 条 ⇒ 页尾余零 slack = 4080 − floor(4080/s)×s 被浪费 ⇒ 每页实摊 16 + slack」。 引擎实际会把放不下的记录劈成两段跨页写,零头被下一条跨页记录吃掉,slack ≡ 0,修正式退化为 R = 16 × P。

关键事实:这个错在 s = 60 上完全隐形。 4080 mod 60 = 0 ⇒ slack = 0,错式与正确式在我们实际在用的一组参数上给出同一个答案 (两边都是 R = 10,160、P = 635),无论怎么对拍都对不出来; 它只在 s = 112 时才暴露(4080 mod 112 = 48 ≠ 0:在 P = 635 前提下错式给 R = 16 + 634×64 = 40,592,实际 10,160,差 4 倍; 若连 P 也按错模型重算——每页 36 条、ceil(23,128/36) = 643 页——则更远,为 41,104。

这类错最危险的不是它错,而是它在你正在用的参数上恰好对。 它若没被抓出来,会一直潜伏到某天有人拿它去判 S = 112 的一次测量, 把一个本来正确的测量判废——错误一旦被写成公式沉淀下来,杀伤半径就不再限于当初那个场景。

纪律 · 退化点证伪律(可执行): 若某公式在参数点 A 上恰好退化成正确式(本例中 4080 mod 60 = 0 ⇒ slack 项消失 ⇒ 错式与正确式在 S=60 上给出完全相同的答案), 则在 A 上通过不构成对该公式的验证。必须另选一个「不退化」的参数点 B 再做一次;只有 A、B 同时通过才算验证过。 选 B 的判据就是"使退化条件不成立"——不是随便挑两个 S 各跑一遍就算数, B 必须由「让退化式失效」这一条选出来,写文档时要连同选 B 的理由一起写(否则这条纪律会被敷衍成走过场)。 本例:S=60 是退化点,S=112 是不退化点(4080 mod 112 = 48 ≠ 0,错式给 40,592、正确式给 10,160,差 4 倍)——这正是本次错误能在 112 上被抓出来、却在 60 上完全隐形的原因。

★ 同一条纪律,本轮就又抓到了一个:闭式 P = ⌈accepted·S/4080⌉。

这个式的隐性前提是「每页 payload 恒 4080」。它在 S = 48/60/64/112 上恰好成立(这四档的相位周期里不出现页尾弃置), 却在 S = 44 与 S = 644 上失效——那里 avail 会落到 4/8 B 而被丢弃(详上方 note 块)。 它在 S=60(我们正在用量、也是 #59 唯一要测的量)上完全隐形,换到 44 / 644 才露出来。

教训的二阶版本:一个公式"在你测的那个点上对",既可能因为它对,也可能因为那个点恰好让它退化。 ⇒ 文档里任何公式必须标注它的失效条件(本式:S ∈ {44, 52, 56, …, 644} 共 59 个可达取值会触发页尾弃置), 读者才知道去哪些点上信它、去哪些点上别信;只标注成立点的公式,等于把一半信息藏起来。

⚠ 口径自查留痕(2026-10-28,团队负责人自查,留痕不删):

① 同一处被纠正两次,且第二次发生在"自己已经算对之后"(禁止美化为笔误): 曾对外写「43,176 × 60 = 2,590,560,/4080 = 635.0 正好整除」。 真相是 2,590,560 / 4080 = 634.9412,余 3,840,不整除; 真正整除的是未经批量回滚的 43,180 × 60 = 2,590,800 = 635 × 4080。 特别记录:提出方在发出该口径之前已自行用 Python 验算过这一行,输出即为 634.9411764705883,看到后仍照写"整除"。 算对了却不认账,比不会算严重得多——不要写成"笔误",第二次纠正的记录要跟原文留在一起。 (结论 P = 635 仍成立,靠的是向上取整,不是整除。)

② 一个不存在的印证被用来给一张表背书: 曾写「N_sess 得到 emlite_bridge.h 独立推算的交叉印证(20,971 / 43,690)」。 经核,该文件内没有这两个值,20,971 字面值在全树零命中;唯一的算式是 em_emf.py:587 的同一套 (2,097,152 − 32)/(S − 12) ⇒ 同一条路径,不是独立印证。该说法系转述他人报告而未核实即当成独立证据外传, 而它恰恰被用来支撑"线上/落库换算链路是通的"这一结论。状态:该印证暂无出处,待核。

工程教训:阶次陷阱(2026-10-28,本轮真正的产出)

验证一个模型时,必须分清你验的是它的一阶结论还是高阶结构。 大量参数点上的一阶吻合,对高阶结构零证据力——因为高阶误差往往只在特定相位 / 累积条件下才显形。 判断一个公式够不够用,先问"我要的结论落在第几阶"。

为什么退化点证伪律装不下这件事:那一条管的是「错式在某个参数点上恰好退化成正确式」。 而本页这次不是退化——S=60 上闭式与逐页模拟本来就一致,它们在任何参数上一阶都一致。 失效发生在二阶:一阶命题「记录级零头不浪费」在全部 151 个可达 S 上全对; 二阶命题「每页 payload 恒 4080」在其中 59 个 S 上错。 ⇒ 在一阶上验过 151 个点,一点都不能说明二阶是对的。换 1000 个参数点也一样。

完整教训链(两次栽在同一个地方:拿结构假设当公式用,而不问它在第几阶成立):

  • 第一步:假设"页内零头浪费、每页只装 floor(4080/S) 条"——一阶就错,被 frag-dev 引 em_mem.c:155-164(劈分跨页写)否证(第三次撤回,见上)。
  • 第二步:过度修正成"既然会劈分,那每页 payload 就恒 4080"——一阶对,二阶错;错在没看到劈分余数会把相位推走, 进而使 avail 落到 4/8 B 而被丢弃。这一步由两次彼此独立的逐页复现(负责人与 spec-cap-table 各自照三条分支重写,1,688 B / 844 B 两处分毫不差)共同确认。

落地要求:凡从"某个结构事实"推出来的公式,写进文档时必须同时标注它成立到第几阶、二阶失效条件是什么 (本式的失效条件:S ∈ [44,644] 步长 4 的 151 个可达值中有 59 个触发页尾弃置,含本表的 S=44 与 S=644)。 只标成立点不标失效条件的公式,等于把一半信息藏起来。

引用纪律(三条): ①「能开 2 MB 会话」不是「能灌进 2 MB」的证据,中间隔着一整个板容量; ② 凡出现条数,必须同时标出当时的 s——s=44 与 s=644 差 14.6 倍,脱离 s 的条数是无效数字; ③ C 的页数因子已板端实证、页内可用字节因子仍为推算;权威值需一次「清库 → 固定尺寸包灌满 → 清库」的独占时段实测(C ≈ accepted × s,s 固定)。在此之前 C 标推算,且必须注明是哪一半在推算; ④ 量 C 的不确定度粒度是 8 × s 而非连续量——分块导入按 EM_FRAG_BATCH=8 成批提交且整批回滚,故 accepted 必为 8 的倍数,末批残留最多浪费 8 × s; ⑤ 表述截止线时不得写「s ≥ 64 就量不出 C」——那句话将来会被一句「换 s=48 就量得出」直接证伪。正确写法是绑死几何:「在 Dev-A 8MB / 992 页几何下,单会话可灌上限为 s ≤ 60,C 的可测域是 s ∈ {44, 48, …, 60}」。

⑤ frag.list 的口径边界:只统计记录区存活记录, 不做墓碑回溯扣减。发生过 em_mem_del 的库,其 count / bytes 可能偏高,不能当净条目账本用——要对账请另走删除计数。 (当前三场景实测场景无删除,判据干净;此边界在引入删除后必须重新核对。)

6. edgehippom 分区内部存储布局

edgehippom 分区(Dev-B 11.875 MB / Dev-A 3.875 MB)
┌────────────────────────────────────────────────────────┐
│ Superblock A  4 KB   seq + 各区指针 + CRC32             │  双份 A/B 交替写
│ Superblock B  4 KB                                      │  启动时取 seq 大且 CRC 有效者
├────────────────────────────────────────────────────────┤
│ Region Table  4 KB   区域类型 / 偏移 / 长度 / 水位      │  支持「分区内动态比例」(W1-16)
├────────────────────────────────────────────────────────┤
│ WAL           512 KB 环形,tx_id + 页号 + 旧值 + CRC32  │  提交/回滚依据
├────────────────────────────────────────────────────────┤
│ 记忆区                                                  │
│   ├─ 记录区(append-only 页链,4 KB 页)                │  主体
│   └─ ANN 索引区(IVF 簇表 + graph 邻接)                │  与记录同事务提交
├────────────────────────────────────────────────────────┤
│ 时序区                                                  │
│   ├─ L1 列式聚合(每流每分钟一行)                      │  预算 1.375 MB,待实测回收
│   └─ 取证区(异常原始窗口,L0 转存)                    │  定长环形,满则覆盖最旧
├────────────────────────────────────────────────────────┤
│ 空闲区(compact 可用)                                  │
└────────────────────────────────────────────────────────┘

6.1 与现有实现的衔接

现有去向说明
em_store.c 的 append-only 记录日志改造为记录区底座已有 em_kb_add/del/get/clear/compact/count/bytes_used/next_id/foreach
em_kb_search()保留为 v1 文本检索;v2 走 em_query无向量能力,是 .emf 分代的原因
em_kb_partition_{read,write,erase}收敛进 em_flash_ioPC 仿真实现替换即可上 PC 单测
em_crc32 / em_utf8_cut / store.map直接复用—
kb_static 分区并入 edgehippom,由 Region Table 管理改分区表后原内容作废,改前先 eme kb export --backup

6.2 store.map 同源输出(产品定规)

store.map 必须同源输出以下全部信息,不得分散到多个命令:flash 分区占用 / KB 占用 / NVS / RAM / 账本 / 多模态快照。这是 Studio 与 eme 的唯一容量视图。

7. 命令集(行协议)

协议:单行 JSON {"id":n,"cmd":"name","args":{}}。串口 / USB CDC / BLE 共用同一套命令分发。

7.1 MVP 保留(现有已实现)

命令用途
sys.ping sys.info sys.caps sys.bootloader sys.form sys.set_form sys.led sys.reboot sys.factory_reset设备信息与控制。sys.info 的 build_time 是验「是否上新板」的唯一依据。sys.form / sys.set_form 返回与设置形态标识(gamma-card / usb-dongle);旧名 sys.role / sys.set_role 保留为兼容别名(ADR-018)
store.map容量同源视图(6.2 节)
em.provision em.stats配置与统计
kb.imgKB 镜像导入状态
data.query dev.id dev.autoid数据查询与设备标识
link.scan link.connect link.status link.syncEdgeMesh 链路(03 号分册)
hub.discover hub.join hub.leave hub.set hub.state hub.statusEdgeMesh 中转(Hub)能力——协议层角色,与板级形态无关(ADR-018)
mesh.query mesh.ledger语义改为碎片接力(ADR-011)
ota.status ota.reboot ota.set_tokenOTA。token emgw-ota-demo
wifi.scan wifi.connect wifi.disconnect wifi.forget wifi.state wifi.statusWi-Fi
ble.init ble.adv ble.scan ble.conn ble.status ble.stopBLE

7.2 MVP 新增

命令args返回对应 HAL
mem.put{text, meta, prio, ttl_h, vec_b64?}{id}em_mem_put
mem.get{id}{rec}em_mem_get
mem.del{id}{ok}em_mem_del
mem.query{vec_b64?, k, filter, flags, min_score_q8?}{hits:[{id,frag_id,ts_ms,score,matched_by,text,src}], reject}。n=0 时必带 reject(ADR-014)em_query
mem.stat—{count,bytes,frag_rate,wal}em_stat
ts.inject{stream, ts_us, sample_b64}{ok}em_ts_append
ts.script{model, seed, dur_s}{ok}合成数据源(第 9 章)
ts.query{stream, t0, t1}{rows}em_ts_window 【ADR-019】区间左闭右开 [t0, t1):含 t0 所属分钟、不含 t1 所属分钟;逐分钟查询请写 t1 = 下一分钟起点
ts.agg{stream, t0, t1, op}{value}em_ts_agg 【ADR-019】与 ts.query 口径逐字一致的左闭右开 [t0, t1);min_score 类语义不适用,窗口为空返回 EM_OK + n = 0(非错误)
ts.level{from, to}{ok}em_ts_downsample
frag.import{chunk, off, total, crc}{ok, off}em_frag_import
frag.export{frag_id}{chunk, off, total}em_frag_export
frag.list—{frags:[...]}em_frag_list
frag.verify{frag_id}{ok, bad_recs}TC-F-*
frag.del{frag_id}{ok}—
edgehippom.compact—{freed_bytes, ms}维护窗口 ≤ 30 s

7.3 MVP 删除(随模块裁剪)

cam.*(11 个)、mic.*(7 个)、gps.*(2 个)、env.*(5 个)、photo.*、snap.*、ow.scan、sensor.*。

裁剪后 main/ 从 14 个 .c 减到约 11 个(另加 em_runtime.c / em_ts.c / em_usb.c)。实测裁剪仅省 ≈120 KB flash(esp32-camera 组件 83.7 KB + I2S 驱动 31.1 KB + 三个自有模块 8.4 KB),真正的大头是 Wi-Fi / BLE / mbedTLS,砍不掉。裁剪的真实收益是并发与内存峰值压力的下降,不是 flash。

JSON 缓冲含中文用 ≥768 B。多任务共串口须单锁整行原子写(cmd_console_write + esp_log_set_vprintf)。

8. HTTP API

全部端点必须支持 CORS 与 OPTIONS 预检 —— 这是 Studio 能直连板子的地基。
Studio 是浏览器页面,直接 fetch("http://192.168.1.46/api/...") 会被同源策略拦死。板端必须返回 Access-Control-Allow-Origin: *、Access-Control-Allow-Methods: GET,POST,OPTIONS、Access-Control-Allow-Headers: Content-Type, X-EM-Token,并对 OPTIONS 直接返回 204。
页面必须经 http / localhost 提供——https 页面请求 http 板子会触发 mixed content 拦截。

8.1 现有端点(保留)

端点方法说明
/GET控制台首页(h_index,HTML 约 24 KB)
/api/statusGET设备状态
/api/configGET/POST配置读写
/api/rebootPOST重启
/api/ota/status /api/ota/tokenGET/POSTOTA 状态与 token(emgw-ota-demo)
/otaPOST固件 OTA(只写 app 分区,不碰 edgehippom)
/kbotaPOST.emf 镜像 OTA(?dry=1 预检)
/kb/exportGET导出知识库
/api/kb/imgPOSTKB 镜像导入
/api/mesh/{query,bid,request,result,award}POST语义改为碎片接力(ADR-011,见 03 号分册)
/snapshot.jpgGETMVP 移除(摄像头裁剪)
/api/mic/{level,record}GET/POSTMVP 移除

8.2 新增端点

端点方法请求体说明
/api/mem/queryPOST{vec_b64?, k, filter, flags, min_score_q8?}混合查询。返回 {hits:[{id,frag_id,ts_ms,score,matched_by,text,src}], reject}
/api/mem/putPOST{text, meta, prio, ttl_h, vec_b64?}单条写入
/api/mem/delPOST{id}—
/api/mem/statGET—条数 / 字节 / 碎片率 / WAL
/api/frag/importPOST二进制 .emf(4 KB 分块 + CRC32 + 断点续传)铁律:失败路径不得破坏已有库
/api/frag/listGET—碎片清单
/api/frag/exportGET?frag_id=导出碎片
/api/frag/verifyPOST{frag_id}自检(对应打进包里的 20 条验收用例)
/api/frag/delPOST{frag_id}—
/api/ts/queryGET?stream=&t0=&t1=时序窗口
/api/ts/aggGET?stream=&t0=&t1=&op=聚合
/api/ts/injectPOST{stream, ts_us, sample}注入(含合成源触发)
/api/store/mapGET—同源容量视图(6.2 节)
/api/edgehippom/statGET—引擎层指标:malloc 计数、abort P50/P99、索引参数
/api/mesh/syncPOST见 03 号分册碎片同步

8.3 已知性能坑(务必处理)

Nagle + 延迟 ACK 会让 10 fps 掉到 0.33 fps。必须做两件事:① 响应合并成一次 send;② httpd 的 open_fn 中设置 TCP_NODELAY。

9. 时序 + ANN 闭环实现规格

9.1 闭环五步与每步产物

步环节产物算力来源落点
1采集多速率原始样本板内(合成源)L0 PSRAM 环
2窗口特征化时域 + 频域特征(RMS / 峭度 / 包络谱峰值)板上L1 分钟聚合
3特征向量化特征 → 向量板上(确定性变换,非模型)→ 检索
4ANN 检索 + 混合过滤top-k 及分数板上—
5回写新 Pattern 入库(或合并到已有)板上L2 记忆区
第 3 步是确定性变换,不是模型推理。特征向量化用的是固定的统计量拼接与归一化(无参数学习),因此不违反 ADR 原则②(板上不做 embedding)。语义类向量(S3 场景的文本 embedding)才必须离线算好灌入。

9.2 合成数据源规格(ADR-005)

MVP 不接任何传感器。时序数据走板内确定性合成源:CWRU 风格参数化模型——基频 + 谐波 + BPFO/BPFI/BSF 冲击串 + 噪声,seed 可控、逐字节可复现。

参数取值说明
采样率4 kHz(振动 3 轴)另有 1 Hz 温度、10 Hz 电流
基频 / 谐波可配模拟转频及其倍频
故障特征频率BPFO / BPFI / BSF对应外圈 / 内圈 / 滚动体故障
噪声高斯,σ 可配—
seedu32同 seed 必须逐字节可复现,否则测试不可信
注入接口ts.script {model, seed, dur_s}—
合成源必须放在板内而不是 PC 侧。这样写入吞吐、窗口特征化耗时、L0 环形覆盖仍是真实的,只有「数据从哪来」是假的——WP4 绝大部分验证价值保住了。
代价写死:真实采集对照(W4-07)移入 P2。在此之前,对外不得宣称 S1 的 85% 召回率已获真实数据验证(风险 R11)。

9.3 闭环验收(G4)

  1. 注入一段已知故障类型的合成数据 → 检索命中对应 Pattern(top-1 分数 > 阈值)
  2. 人工/自动确认 → 回写新指纹(或合并到已有,余弦 > 0.95)
  3. 再次注入同类数据 → 必须能命中刚刚写入的指纹
  4. 新写入的指纹在 frag.export 中可见,可被接力给另一块板并命中

10. 构建 / 烧录 / 调试规范

10.1 环境(必须显式设置,Git Bash 不支持)

# PowerShell —— 每次开新终端都要设
$env:IDF_PATH = "C:\Espressif\v6.1\esp-idf"
$env:IDF_PYTHON_ENV_PATH = "C:\Espressif\...\idf6.1_py3.12_env"
$env:IDF_PYTHON_CHECK_CONSTRAINTS = "0"
# 并把工具链加入 PATH

# 构建唯一入口
.\build_board.ps1 -Board xiao|devkit|all [-Flash] [-Port COMx]
# 日常增量
./inc_build.sh
# 板位与形态的对应(由 boards/sdkconfig.<board_id> 固化,见 §1.2):
#   xiao  → CONFIG_EMGW_FORM_GAMMA_CARD  (运行时 form = gamma-card)
#   devkit→ CONFIG_EMGW_FORM_USB_DONGLE  (运行时 form = usb-dongle)

10.2 版本与验证

10.3 升级路径优先级

序方式适用说明
1OTA(POST /ota)日常固件升级token emgw-ota-demo。双槽 + rollback + 启动自证。只写 app 分区,不碰 edgehippom
2KB-OTA(POST /kbota)知识库更新?dry=1 预检;改前必 --backup
3USB MSC离线灌库 / β 形态主路径拖 .emf 进虚拟 U 盘
4USB CDC(eme / Studio)在线灌库与调试行协议
5串口刷写(esptool / esptool-js)首刷与救砖唯一救砖通道,不可移除

10.4 已知坑速查(按现象)

现象先查什么
周期自发重启栈溢出:NimBLE host ≥ 8192;hub_task 6144 内不得放大缓冲;mic_mgr 类 char[4600] 改堆分配
tlsf assert / double freeesp_partition_next() 末尾自 release,调用方不可再 release
WS2812 不亮 / 颜色错IDF v6.1 的 rmt_transmit() encoder 不可为 NULL(用 rmt_copy_encoder);config 必须 memset 不可 {0};WS2812 是 GRB 序 10 MHz
HTTP 响应慢(10 fps → 0.33 fps)Nagle + 延迟 ACK:合并一次 send + open_fn 设 TCP_NODELAY
串口日志串行 / 半行多任务共串口须单锁整行原子写
中文乱码JSON 缓冲 ≥ 768 B
nvs_stats_t 字段报错IDF v6 字段名为 used_entries / free_entries
JS 参数名遮蔽模块变量例 protocol.js 中 connect(baud, port) → 改名 extPort;fire-and-forget async 须自带 try/catch
引擎未就绪,日志却只有一句常量取的是 err(常量)不是 detail(真因)——err or detail 的短路写法会把唯一有诊断价值的字段吞掉。详见下方 ★

★ 判据失败必须带出可归因的字段(本仓既有正确做法的归纳,不是新立的规矩)
em.stat 在引擎未就绪时返回【读码推定 · fw_ascii/main/cli_json.c:2362-2366】: {"ok":0, "err":"emlite 未就绪", "detail": emlite_last_error()}
· :2364 err = 常量字符串;· :2365 detail = emlite_last_error() = 真正的失败原因
⇒ 取 err or detail 的短路写法会静默丢掉 :2365 这个唯一有诊断价值的字段—— 日志里只剩一句常量,零可归因信息。

⚠ 两个命令的 err 文案不同,引用必须钉命令名: em.stat = 「emlite 未就绪」(:2364);em.query = 「emlite 引擎未就绪」(:1544); 其余命令走信封层 resp_err(id, "emlite 引擎未就绪")。 只写"emlite 未就绪"四个字会被后人张冠李戴——这与 check_anchors() / check_real_board() 两个"锚点"同名不同物,是同一类毛病(两个名字差几个字,结论就串了)。 核对用 grep -n "未就绪" fw_ascii/main/cli_json.c(行号会漂移,以 grep 为准)。

⚠ 顺带一条"取层"坑:em.stat 的失败写在 data 里(ok:0), 外层信封走 resp_ok_raw(:2383)⇒ 信封层 ok 恒为 true, 拿 r["ok"] 判 em.stat 是永远假绿;其余命令走 resp_err、失败在信封层。 两者取层不同,别混用。

本仓既有正确做法(3 处):em_powercut_l3.py:1681/1795 用 detail 优先、 :1377 两者都留;_fid_empty_probe.py:262 / _fid_hdr_probe.py:307 同时打印两者。 ⇒ 我们是把不一致的 2 处对齐到这既有的 3 处,不是发明新要求。
曾不一致的 2 处:emb.py 的 wait_ready、与 _boot_ready_probe.py(G7 专用启动就绪探针) —— 均已修(2026-10-28,team-lead 自修):一律改 detail 优先, 后者另加 flush 与"每轮就绪上限"参数。 行号会漂移,以 grep -n 'get("detail")' emb.py _boot_ready_probe.py 为准。