跳转至

Datus 对 Apache Ossie 的扩展(厂商规范)

  • 状态:草案,Datus 已在用(Dosi 与 Datus 的模型生成 Agent)。
  • 规范版本:datus-ext/1(主版本系列)
  • 当前版本:1.10 —— 每个小版本加了什么、以及版本号如何决定, 见 §2.1。任何引擎都能报告自己实现的版本: dosi info、GET /v1/capabilities,或 dosi_engine.DATUS_EXT_VERSION。
  • 范围:Apache Ossie(原 OSI)核心规范 0.2.0.dev0 还无法表达的那些语义, 统统装在 Ossie 官方认可的 custom_extensions 字段内部, 这样文档仍然是完全合法的 Ossie,而每个非 Datus 的消费方都会忽略它们。

想要带前后对比示例的任务式讲解,见 Datus 扩展指南。这一页是规范性契约。

除了模型语义之外,Dosi 的连接配置也讲 Datus: 配置沿用 agent.yml 的 datasources: 词汇表,一份完整的 agent.yml 可以直接作为 --connections 传入 —— 见 connectors.md。

这是一个私有的厂商扩展,不必经 Ossie 工作组接纳即可使用。 当某个扩展所泛化的空缺本该属于核心规范时(时间粒度、累计窗口), 它的长期归宿是一份 Ossie RFC;本文档是 Datus 今天据以发布的过渡载体。

1. 为什么这对 Ossie 是安全的

Ossie 的 schema 很严格(到处都是 additionalProperties: false), 所以我们不能直接加 relationship.join_type 这样的裸字段。 但规范在每个主要对象上都提供了一等公民级别的逃生舱:

custom_extensions:
  - vendor_name: <string>
    data: <JSON string>

custom_extensions 存在于 SemanticModel、Dataset、Field、Relationship 和 Metric 之上(见 crates/dosi-model/src/spec.rs)。上游的 ossie 校验器 接受它,所以一份携带 Datus 扩展的文档原样通过 validate-osi 这道关。 不认识 vendor_name: DATUS 的消费方直接跳过, 模型的含义仍精确等于纯 Ossie 所说的含义。Datus 扩展只在 Ossie 未作定义之处 细化引擎行为;它们绝不改变一个指标或维度是什么。

2. 信封

一个 Datus 扩展就是一条 custom_extensions 条目:

  • vendor_name:规范写法是大写常量 "DATUS"(与 Ossie 规范的大写枚举风格一致, 比如 ANSI_SQL)。匹配时不区分大小写;历史上的小写 datus 继续可用。
  • data:一个 JSON 字符串(按 Ossie 规范,data 是字符串而不是对象), 解码后是单个 JSON 对象 —— 即载荷。每个对象上最多一条 DATUS 条目。 有两个信封键是保留的:v 和 requires。

消费规则(Dosi):

  • 不存在 → 采用下文写明的 Ossie 默认行为。扩展纯粹是增量的, 一个没有任何扩展的模型,行为与今天完全一致。
  • 未知的载荷键会被忽略,而不是被拒绝:这样更新的 Datus Agent 可以输出老引擎不读的键,而不至于把它弄坏(前向兼容)。 例外是下面的 requires。
  • 未知的 vendor_name(除 DATUS 之外的一切,比较时不区分大小写)会被忽略。
  • 格式不对的 DATUS 载荷(不是 JSON,或某个已知键的类型不对)是一个 结构化错误(invalid_datus_extension),而不是悄悄取默认值: 一份由 Datus 编写的文档要按 Datus 的契约来要求。编译仍会带着默认值继续, 这样一趟就能报出模型里的所有问题。

v —— 声明的版本

v 的形式是 MAJOR.MINOR。字符串形式是规范写法("v": "1.1"); 为方便起见也接受 JSON 数字("v": 1.1,以及裸整数 "v": 1 表示 1.0)。

JSON 数字只能表达 0–9 的小版本号。 1.10 和 1.1 是同一个 f64, 所以数字无法表达小版本 10;带两位及以上小数的数字会被直接拒绝, 并提示你改用字符串形式。一旦当前小版本到达 10,请一律把 "v" 写成字符串。

v 是可选的,省略它永远是安全的 —— 一个未声明版本的载荷, 行为与版本闸门存在之前完全一致,也不会产生任何诊断信息。 版本是按 custom_extensions 条目声明的,而不是按文档: 模型级的 v 不会成为它下面数据集、字段、关系或指标的默认值。

给定引擎自身的版本,标注版本能给你带来什么:

情形 引擎的反应 错误码
v 不存在或为 null 静默 —— 行为与闸门出现之前一致 —
v 格式不对(坏字符串、JSON 类型不对、带 ≥2 位小数的数字) 错误 invalid_datus_extension
v 低于 1.0,或主版本比引擎的新 错误 unsupported_datus_ext_version
小版本比引擎的新 告警并点名被丢弃的键;引擎认识的一切仍然生效 datus_ext_version_ahead
某个键比你声明的 v 更新 告警;但该键仍会被采纳 datus_ext_key_newer_than_declared
小版本比引擎的旧,且所用键都在其范围内 静默 —

未来的主版本是唯一一种一刀切的拒绝。主版本号提升意味着至少有一个键改变了含义, 而早于这次改变的引擎无从知道是哪一个,硬着头皮读那个载荷会产生悄无声息的错误数字。 小版本按构造就是增量的,所以它们最多只是告警。

requires —— 不允许被丢弃的键

data: '{"v": "1.4", "requires": ["semi_additive"], "semi_additive": {"reduce": "last"}}'

requires 是一个键名数组,生产方借此声明这些键不得被悄悄忽略。 没有实现其中某个键的消费方会抛出 datus_ext_key_required 并点名该键, 而不是丢弃它。

它之所以存在,是因为消费方无法判断自己从没听说过的键有多危险。 对 join_type 这样的键,"未知键被忽略"是正确的默认: 忽略它只是退回到一个有文档的行为。但对 semi_additive 这样的键就是错误的默认: 忽略它意味着把期末余额加总起来,返回一个干脆就是错的数字。 生产方知道哪个是哪个,requires 就是它表达这一点的方式。

对每一个忽略影响为 silent 的键(见 §2.1)都要输出 requires, 其余一概不要。让老引擎降级运行,好过直接拒绝为它服务。 dosi info / GET /v1/capabilities 会报告每个键的影响级别。

Datus 模式下,非空的 silent 扩展若未列入 requires,会产生 datus_ext_requires_missing 告警,即使没有声明 v 也会检查。模型仍可编译; 将提示中的键补进 requires,才能防止旧引擎忽略它。

2.1 版本策略

本引擎读取的每个键都登记了引入它的版本,以及消费方忽略它的代价。 这份登记表位于 crates/dosi-compiler/src/ext.rs,是唯一事实来源: 版本闸门、基础模式的告警文案、dosi info 和 GET /v1/capabilities 都在运行时读取它,所以它们谁也不可能与之漂移。§7 的变更记录是照着这份登记表手写的。

忽略影响级别,按危险程度递增排列 —— 注意大声失败排在比悄悄返回不同数字更安全的位置:

影响 含义
inert 引擎从不消费的展示类元数据。结果完全相同。
degraded 不会出错误数字 —— 请求会大声失败(一个结构化错误,或某个查询名不再能解析)。
documented 数字会变,但变得可预期、方向有文档可依(LEFT 对 INNER,NULL 对 0)。
silent 数字是错的,而调用方无从察觉。必须列进 requires。

已发布的键里有两个是 silent:1.4 加入的 measure(D-MEASURE)和 1.6 加入的 time(D-FORMAT),见 §7 的变更日志。其余每个键都是 degraded 或 documented, 所以对两者都不使用的模型来说,基础模式依然自洽。用了其中之一的模型必须把它列进 requires,正是这一条阻止了没有实现该键的 datus 模式引擎(旧版 Dosi、 其他厂商读这个信封的实现)悄悄算出另一个数字:它会在版本闸门处以 datus_ext_key_required 失败。基础模式是另一回事 —— 它根本不读载荷,requires 也不读,只会告警 ignored_vendor_extension;带 silent 键的模型不应在基础模式下运行。

什么时候提升版本号:

  • MINOR —— 纯增量:一个新键,或者某个已有键的一个新可接受取值 (老载荷不可能用过它)。老载荷原样继续工作;新载荷跑在老引擎上会得到 datus_ext_version_ahead,只损失那个新键。 一个影响为 silent 的新键仍然只是小版本提升, 但生产方必须把它列进 requires。
  • MAJOR —— 只有当某个已有键改变了含义、被重命名或被撤销时。 此后每个声明了更旧 v 的载荷都会按改变了的键被逐一拒绝, 并给出更新 YAML 的指引。
  • 两者都不 —— 不改变任何键契约的引擎内部工作。 一个背后没有任何键的版本会让登记表的不变式变得空洞。

3. 扩展目录

D-JOIN —— 关系的连接类型

位置:一个 Relationship 的 custom_extensions。 载荷键:join_type,枚举 "left" | "inner"。 默认(不存在时):"left"。

控制一个扇出分支在沿这个关系(多→一)把"一"端 JOIN 进来、 以到达某个维度或某个不在基上的度量时,行为如何:

  • "left"(默认):保留多端没有匹配的行 —— 孤儿事实行会存活下来,落进任何取自缺失那一侧的维度的 NULL 分组里。 对账/审计语义。
  • "inner":丢弃未匹配的多端行。归因语义 —— 一个度量只在它所归因的实体确实存在时才被统计。

这让 INNER 还是 LEFT 成为关系的一个显式属性, 而不是生成器里写死的默认值(见 docs/semantics.md S-JOIN)。

relationships:
  - name: sales_to_regions
    from: sales
    to: regions
    from_columns: [region_id]
    to_columns: [region_id]
    custom_extensions:
      - vendor_name: DATUS
        data: '{"v": "1.0", "join_type": "inner"}'

D-CONFORM —— 事实表之间的一致性关系

位置:Relationship 的 custom_extensions。 载荷键:cardinality,枚举 "many_to_one" | "many_to_many"。 缺省(不存在时):"many_to_one" —— 普通的外键边,连接行走会沿着它走。 忽略时的影响:silent —— 请列进 requires。

两张事实表经常共享维度 —— 日期、平台 —— 却谁也不是谁的多端:一条登录记录并不 属于某条付费记录。严格 OSI 只能通过共享的维表把它们关联起来,于是「按日期算付费 率(付费人数 / 活跃人数)」这样的跨事实指标没有任何日期可以分组 (unconformed_dimension)。many_to_many 直接声明这个配对:每一对 from_columns / to_columns 就是同一个维度在两侧的两种拼写。

relationships:
  - name: login_payment_conformed
    from: netbar_login
    to: netbar_payment
    from_columns: [stat_date, platform_id]
    to_columns:   [pay_date,  platform_id]
    custom_extensions:
      - vendor_name: DATUS
        data: '{"v": "1.8", "requires": ["cardinality"], "cardinality": "many_to_many"}'

一致性边永远不会逐行 join —— 那会把每一个 SUM / COUNT 都扇出。每张事实表 各自聚合,把自己那侧的配对列投影成查询的输出名,然后各分支按这个名字合并,和 S-FAN-3 合并任意两个分支的方式完全一样:

{"metrics": ["pay_rate"], "group_by": ["netbar_login.stat_date"]}
WITH m0 AS (SELECT netbar_payment.pay_date AS stat_date, COUNT(DISTINCT netbar_payment.player_id) AS payers
            FROM main.netbar_payment GROUP BY netbar_payment.pay_date),
     m1 AS (SELECT netbar_login.stat_date AS stat_date, COUNT(DISTINCT netbar_login.player_id) AS users
            FROM main.netbar_login GROUP BY netbar_login.stat_date)
SELECT COALESCE(m0.stat_date, m1.stat_date) AS stat_date, CAST(m0.payers AS DOUBLE) / m1.users AS pay_rate
FROM m0 FULL OUTER JOIN m1 ON m0.stat_date = m1.stat_date OR m0.stat_date IS NULL AND m1.stat_date IS NULL

配对带来的能力(fixture 见 fixtures/datus_conform/):

  • 两种拼写都能用在 group_by、where 和 time_range.dimension 里,对任一侧的 指标都成立 —— 只在登录表上的指标也能按 netbar_payment.pay_date 分组;输出列名 沿用查询里的那种拼写。
  • metric_time 在两侧的主时间维度配对时按分支解析:每个分支按自己的日期列 过滤和分组。
  • 粒度上卷发生在各分支内部(stat_date:month):每张事实表截断自己的列, 合并键是截断后的值。存储粒度不同的事实表也可以配对,较粗一侧的 D-GRAIN 下限生效。
  • 可传递:payment ↔ login 加 login ↔ register,就把 payment 和 register 也配上 了 —— 三张事实表,一个日期维度。
  • 只有配对列是共享的。跨事实指标按未配对的列分组是 unconformed_dimension; 按它过滤是 no_join_path,并提示这个过滤条件没法只落在一侧(改成 D-DERIVE 的 filter 成员)。一致性边不能出现在关系路径里(invalid_relationship_path)—— 对面没有任何一行可以走到。
  • S-FAN-6 照旧:比率里面的计数在只有一侧有数据的日期读 NULL;只有纯计数指标 补 0。
  • 配对的两侧可以存成不同格式 —— 一侧是原生 DATE,另一侧是 D-FORMAT 的 %Y%m%d 字符串。配对说的是两列是同一个维度,不是同一种编码,所以规划期 搬过去的每一个边界值都会按目标列自己的编码折算:单个时间范围、每个 tag 的 CASE 闸门,以及这些闸门 OR 起来的扫描谓词。唯一搬不过去的是 where_sql 里的 比较 —— D-FORMAT 本来就拒绝编码列出现在那里(字面量不会是它的编码),点名配对 另一侧的原生列同样被拒绝,而不是悄悄改写(unsupported_filter,消息里两种拼写 都点出来)。改用 time_range 指定这个维度。
  • 窗口指标也可以按任一拼写分组,前提是它的时间轴是单列。落在一张事实表上的 窗口,用另一张事实表的名字问出来,仍然是这张事实表自己的序列、按自己的列排序, 输出名保持查询写的那个。窗口指标自身的成员跨两张配对事实表时没有单列可排序, 依旧是 not_implemented。

声明一次、检查一次:配对列必须在 dimension.is_time 上一致,两侧都声明了 datatype 时类型也要一致;一个数据集在一个一致性类里至多贡献一列;many_to_many 边上不允许 join_type(invalid_datus_extension)。模型校验仍然会对这条边发 relationship_target_not_unique 告警 —— 在严格 OSI 下这是对的,也是忽略该键的 消费者能得到的唯一信号。

为什么是 silent:丢掉这个键的引擎看到的是一条普通的 many→one 边,会把两张事实表 逐行 join。COUNT DISTINCT 类的 compose 会返回看起来合理的数字(当天没付费的 登录全部落进 NULL 桶);跨 join 的 SUM 只在维度落在对面事实表上时才被 fan_out_risk 拒绝,其余情况就重复计数。把 cardinality 列进 requires,缺少它的 datus 模式 引擎就会在版本闸门处失败。基础模式(§5)就是这种降级本身。

设计见 ../design/d-conformed-edges.md;用例见 ../design/d-conform-e2e-cases.md,在细节层次上对齐见 ../design/d-lod.md(4.5 与 9.3 的 D 系列)。

D-FILL —— 合并时缺失分组的指标空值填充

位置:一个 Metric 的 custom_extensions。 载荷键:fill_nulls_with,一个 JSON 数字。 默认(不存在时):引擎默认 —— 一个纯 COUNT 指标填 0 (docs/semantics.md S-FAN-6);其余每个指标保持 NULL。

显式的 fill_nulls_with 优先于 S-FAN-6 的计数默认值, 并且对任何种类的指标都适用(SUM、比率、表达式), 在合并计划和单分支计划里都会把该指标的输出 COALESCE 成给定的数字。

在一次多分支合并之后,只出现在部分分支里的分组, 对那些在缺失分支中计算的指标会投影出 NULL。 fill_nulls_with: <n> 会把这个指标的值 COALESCE 成 <n>(通常是 0), 这样一个没有任何贡献行的分组就会报出一个具体数字而不是空白。

作用范围的保护(与 S-FAN-6 一致):填充作用于指标的输出, 绝不作用于嵌在比率/表达式里的某个度量 —— 一个计数作为分母时不能变成字面量 0 除数。对比率来说,请填充那个比率, 引擎仍会以空值安全的方式计算它。

在窗口阶段之下,计数默认值落在更低一层 —— 落在合并出来的那个度量列本身上, 因为阶段以及它之上的一切读的都是列,而不是带分支限定的表达式。 两种路径答案一致:往查询里加一个窗口指标,绝不会把某个计数从 0 变成 NULL。

metrics:
  - name: sales_total
    expression:
      dialects:
        - dialect: ANSI_SQL
          expression: SUM(sales.amount)
    custom_extensions:
      - vendor_name: DATUS
        data: '{"v": "1.0", "fill_nulls_with": 0}'

D-TIME —— 主(聚合)时间维度

位置:一个 Dataset 或一个 Metric 的 custom_extensions。 载荷键:time_dimension,一个字符串名称引用 —— 在数据集上, 是它自己某个 is_time 字段的名字;在指标上,是 "field" 或 "dataset.field"。 默认(不存在时):一个恰好只有一个 is_time 字段的数据集, 会把它作为隐含的主时间(这条推断不读取任何扩展,所以在基础模式下同样适用); 零个或多个 is_time 字段 → 没有主时间。

声明哪一列是一张表/一个指标的业务时间轴 —— 也就是当查询要求某个时间范围或粒度却没有点名列时,引擎会使用的那一列。 每个指标的解析顺序(S-TIME-5):

  1. 该指标自己的 time_dimension(优先级最高);
  2. 否则取该指标所涉数据集中唯一的那个主时间(数据集显式的 time_dimension, 否则是单 is_time 推断)。

指标级的裸 "field" 先在该指标自己的数据集里解析 (像 etl_dt 这样的时间字段名会在多个数据集里重复出现), 只有当它们都没有这个字段时,才放宽到整个模型。

查询通过保留名 metric_time 来消费它 (--group-by metric_time:month、--time-dimension metric_time), 也通过时间范围的回退来消费:一个没有点名维度、group-by 里也没有时间项的范围 会去过滤每个指标的主时间 —— 按聚合分支分别进行,用该分支自己的那一列。 一个无法解析出主时间的指标会产生 no_primary_time_dimension。 同一底表上主时间不同的几个指标不会报错:每条时间轴在这张底表上各开一个分支, 再按分组键合并,所以每个指标返回的都是它单独查询时的结果。

被引用的字段必须存在,且 is_time 实际为 true(显式声明或由核心 datatype 默认规则得出),否则 invalid_datus_extension。

datasets:
  - name: orders
    custom_extensions:
      - vendor_name: DATUS
        data: '{"v": "1.1", "time_dimension": "order_date"}'
    fields:
      - name: order_date
        dimension: { is_time: true }
      - name: ship_date
        dimension: { is_time: true }
metrics:
  - name: shipped_revenue
    expression:
      dialects: [{ dialect: ANSI_SQL, expression: SUM(orders.amount) }]
    custom_extensions:
      - vendor_name: DATUS
        data: '{"v": "1.1", "time_dimension": "ship_date"}'

D-GRAIN —— 原生时间粒度

位置:一个 Field 的 custom_extensions(该字段必须是 dimension.is_time: true,可以显式声明,也可以由核心 datatype 默认规则得出)。 载荷键:time_granularity,枚举 "day" | "week" | "month" | "quarter" | "year"。 默认(不存在时):从 time.format 推断,或由核心 datatype: Date 得出日粒度; 其余情况为未知,可以请求任意查询粒度。

声明该列存储时所用的粒度(比如一份按月快照的 etl_dt)。 请求一个严格更细的粒度(在一个 month 列上请求 :day, 无论是通过显式 group-by 项还是通过 metric_time)会得到一个结构化的 grain_too_fine 错误,而不是悄无声息的错误数据。 截断到原生粒度或更粗的粒度则不受影响。 这是上游 RFC 里 dimension.time.granularity 的过渡载体。

fields:
  - name: etl_dt
    dimension: { is_time: true }
    custom_extensions:
      - vendor_name: DATUS
        data: '{"v": "1.1", "time_granularity": "month"}'

D-FORMAT:时间存储格式

什么时候需要配置

时间字段用字符串或整数保存日期时,例如 '20260907' 或 202609,需要用 D-FORMAT 声明编码。原生 DATE/TIMESTAMP 字段通常无需配置;省略 time.format 时,引擎默认按原生日期或时间处理。

time.format 描述存储编码,不控制输出显示格式。配置位于字段的 DATUS custom_extensions 中。字段的 is_time 必须实际为 true,可以显式声明, 也可以由核心 datatype 默认规则得出。 声明描述的是字段 expression 的结果,它可以是计算表达式,不一定是源表中的列。

快速上手

下面在数据集的 fields 中声明一个字符串日期和一个整数月份。省略 storage 时, datatype: String 推断为 "string",Integer 推断为 "int";未声明 datatype 时仍默认为 "string"。将 "time" 列入 requires,可以让不支持该扩展的旧版 Datus 模式引擎 拒绝模型,避免忽略配置后继续计算。

fields:
  - name: dt
    expression:
      dialects: [{dialect: ANSI_SQL, expression: dt}]
    datatype: String
    dimension: {is_time: true}
    custom_extensions:
      - vendor_name: DATUS
        data: '{"v": "1.6", "requires": ["time"], "time": {"format": "%Y%m%d"}}'
  - name: etl_month
    expression:
      dialects: [{dialect: ANSI_SQL, expression: etl_month}]
    datatype: Integer
    dimension: {is_time: true}
    custom_extensions:
      - vendor_name: DATUS
        data: '{"v": "1.6", "requires": ["time"], "time": {"format": "%Y%m", "storage": "int"}}'

假设数据集名为 events,查询的时间范围片段如下。无论存储编码是什么,边界都写 YYYY-MM-DD,包含起点、不包含终点:

time_range:
  dimension: events.dt
  start: "2026-01-01"
  end: "2026-04-01"

过滤条件怎么写

D-FORMAT 编码字段使用 time_range 过滤。 边界始终写 YYYY-MM-DD, 引擎会转换为字段声明的存储编码。

where_sql 中只要引用 D-FORMAT 编码字段,就会返回 unsupported_filter, 即使常量格式正确也不允许。这个限制同样适用于 dataset 的计算字段、函数或 CAST 内部的引用、IS NULL、IN、BETWEEN 和字段间比较。请从 where_sql 中移除 该字段,改用 time_range。如果条件无法用 time_range 表达,例如选择离散日期 或 NULL 值,当前就不能通过 where_sql 对这些字段执行该筛选。

原生时间类型声明(date、datetime、timestamp、timestamp_tz)仍可用于 where_sql,未声明 D-FORMAT 编码的字段也不受此限制。

这项限制针对查询的 where_sql。D-DERIVE 的 filter 和指标表达式内的过滤仍需 按存储编码书写常量,不会自动转换 ISO 日期。例如,%Y%m%d 字符串字段应使用 '20260401',不能使用 '2026-04-01'。可通过 dosi list dimensions 查看字段的 time_format 和 time_storage。

配置项与支持的格式

time 对象接受 format、storage 和 granularity。 除了下面的原生类型名称,format 还支持 %Y%m%d%H%M%S 的 连续、有序、定宽组合。%Y 为四位年份;%m、%d、%H、%M、%S 分别为两位月份、日期、小时(00–23)、分钟和秒,不足两位补零。 可以在任一级结束,但不能跳级、重复或调整顺序。

时间部分之间及前后允许任意固定文本。例如 %Y-%m、%Y年%m月%d日、 %Y.%m.%dT%H:%M、%Y%m%d%H%M%S 都有效。字面的百分号写成 %%。 不支持未知指令、可变宽度修饰符、小数秒和时区指令。 例如 %Y%d、%d/%m/%Y、%-m、%Y%m%d%z 会被拒绝。

新增组合沿用扩展版本 1.6,不升级版本号。尚未支持此功能的旧引擎会拒绝不认识的格式:

custom_extensions:
  - vendor_name: DATUS
    data: '{"v":"1.6","requires":["time"],"time":{"format":"%Y年%m月%d日"}}'

每条数据都必须符合声明的布局,包括固定文本和补零规则。直接比较字段要求列的排序规则 能够保证这些定宽值按时间先后排列;必要时使用合适的二进制或字符编码排序。 引擎不会检查或修改列的排序规则。

原有六种编码保持兼容:

format 存储值 推导出的粒度 允许的 storage
%Y%m%d 20260907 day string, int
%Y-%m-%d 2026-09-07 day string
%Y/%m/%d 2026/09/07 day string
%Y%m 202609 month string, int
%Y 2026 year string, int
%Y-%m-%d %H:%M:%S 2026-09-07 12:30:00 不推导 string

原生时间类型声明不会解析或重新编码字段:

format 含义 推导出的粒度
date 原生日期 day
datetime, timestamp, timestamp_tz 原生日期与时间 不推导

新模型的原生日期时间字段建议直接声明 datatype: Date、DateTime 或 DateTimeTz, 无需再声明 time.format。旧写法继续支持:date 必须对应 Date, datetime 和 timestamp 对应 DateTime,timestamp_tz 对应 DateTimeTz。 原生日期时间类型不能搭配 %Y%m%d 等存储编码。Time 暂无对应的 D-FORMAT 编码。

原生类型声明不接受 storage。编码字段显式声明的 storage 必须与 datatype 一致: String 对应 "string",Integer 对应 "int";其他已声明类型不能使用编码模式。 省略 storage 时根据类型推断;未声明类型时仍默认为 "string"。 "int" 只允许没有任何固定文本的连续时间部分,例如 %Y%m%d%H 或 %Y%m%d%H%M%S,固定数字文本也不允许。最长编码为 14 位,紧凑时间戳应使用 BIGINT、DECIMAL(n,0) 等足够宽的整数值列。物理类型为 DECIMAL(n,0) 的整数日期键 可以声明逻辑类型 Integer,但逻辑类型 Decimal 不作为整数编码处理。 不能单独声明 storage 而不声明 format。以上检查依据字段表达式的结果类型, 不检查源列类型。基础模式会忽略 Datus 块并发出警告,核心类型的时间角色默认规则仍然生效。

granularity 支持 day、week、month、quarter 和 year。 格式能够推导粒度时,可以省略。time.granularity 和 time_granularity 两种写法 都受支持,均未弃用;同时声明时必须一致。声明的粒度不能细于编码所能表达的粒度。 time 对象可以只声明粒度,但不能是空对象。 无论分隔文本是什么,年、月、日编码都分别推导出 year、month、day; 包含小时、分钟或秒的编码不推导粒度。存储到时分秒并不代表支持日内分组, time_range 边界也仍然只接受日期。解析输出时,省略的月份和日期补为 1,省略的 时分秒补为 0。例如 %Y-%m 对应当月第一天,%Y%m%d%H 对应该小时的起点。

分组粒度与时间边界

%Y%m 字段可以按月、季度或年分组,不能按日或周分组,因为存储值没有这些细节。 同理,%Y 支持按年分组。请求更细的粒度会返回 grain_too_fine。

时间范围边界必须与存储周期对齐。对 %Y%m 字段设置 end: "2026-03-15" 会返回 grain_too_fine:如果排除三月,应使用 "2026-03-01";如果包含三月,应使用 "2026-04-01"。错误信息会给出这两个边界,引擎不会自行决定是否纳入不完整的月份。 按年编码的字段则要求边界落在年初。这些检查针对查询传入的边界;窗口计算需要扩大 扫描范围时,由引擎处理。

选出的时间维度会被解析为日期或时间值,不指定粒度时也一样。不要依赖结果保留原始 字符串的样式。D-FORMAT 当前不提供输出显示格式配置。

校验范围与已知限制

声明必须符合实际数据。Dosi 会检查支持的格式和配置组合;字段声明了核心 Field.datatype 时,还会检查所声明的 storage 和原生格式与它是否一致。但 Dosi 不会检查仓库中的列类型或数据值,因此 dosi validate 无法确认字段表达式的实际 输出是否符合 %Y%m%d。

格式或存储类型写错,可能导致仓库报错、分组值变成 NULL,或过滤范围错误却不报错。 例如,实际存储 '2026-09-07',却声明 %Y%m%d,生成的过滤常量也会使用错误格式。 配置时应同时检查表达式的输出类型和实际数据样例。

ClickHouse 使用 parseDateTime,其 DateTime 结果范围约为 1970-01-01 至 2106-02-07。范围以外的日期,包括 SCD2 常用的结束日期 '99991231',不能可靠地 用于分组或解析后的输出。ClickHouse 24.8.14 实测:parseDateTime 对 1960、2107 和 9999 年的日期返回 CANNOT_PARSE_DATETIME。 该版本没有 parseDateTime64。BestEffort 系列也不能直接替换: parseDateTimeBestEffort 会将越界值截到边界,parseDateTime64BestEffort 则会把 9999-12-31 改成 2299-12-31。 直接比较编码值的 time_range 过滤不调用此解析函数,但这不代表超出范围的分组也安全。

basic 模式会忽略 DATUS 扩展并给出警告,requires 也不生效。 依赖日期编码处理的字段应使用 Datus 模式。 当前不支持 epoch_seconds、epoch_millis 等时间戳编码。

生成 SQL 的原理

分组时,引擎将编码字段解析为日期或时间值;请求更粗粒度时,再截断到对应周期。 按存储粒度分组或不指定粒度时,GROUP BY 可以直接使用存储列,在 SELECT 表达式中解析。

time_range 则将边界转换为存储编码。上述示例生成的过滤条件形式如下:

WHERE dt >= '20260101' AND dt < '20260401'
WHERE etl_month >= 202601 AND etl_month < 202604

过滤列不被解析函数包裹,有利于仓库进行分区裁剪。实际裁剪效果取决于仓库、字段表达式 和分区定义。

D-DATASET —— 指标的归属数据集

位置:一个 Metric 的 custom_extensions。 载荷键:dataset,一个字符串,点名模型里的某个数据集。 默认(不存在时):归属只从指标 SQL 本身推导。

COUNT(*) 没有点名任何列,所以在一个多数据集模型里、 又没有别的聚合把某个数据集钉住时,Ossie 让它的归属处于未定义状态, 引擎会报错(count_star_needs_dataset)。这个提示恰好解决那种歧义。 它绝不会覆盖从 SQL 推导出的归属 —— 一个其聚合点名了列的指标, 无论有没有这个提示都保留它们各自的数据集(扩展绝不重新定义 SQL 所说的东西)。 一个不存在的数据集名会是 invalid_datus_extension。

metrics:
  - name: chat_message_count
    expression:
      dialects: [{ dialect: ANSI_SQL, expression: "COUNT(*)" }]
    custom_extensions:
      - vendor_name: DATUS
        data: '{"v": "1.1", "dataset": "chat_record"}'

D-WINDOW —— 派生的窗口指标(同环比/滚动/累计)

位置:一个 Metric 的 custom_extensions。 载荷键:window,一个 JSON 对象 —— 完整 schema 见 window-extension.md。 默认(不存在时):该指标就是它那个朴素的基础聚合 —— 不做任何派生。

在指标自己的 expression(必须能推断为单个朴素聚合)之上声明一种窗口派生: pop / rolling / cumulative 三种语法糖, 或者它们展开成的通用 offset / frame 形态。时间轴不在这里声明 —— D-WINDOW 与 D-TIME 组合使用(指标的 time_dimension > 数据集的主时间; 缺失时间轴会在查询时以 no_primary_time_dimension 失败)。 偏移会下降成一个日历正确的移位键自连接,带自动扫描扩展 + 输出裁剪; 窗框会下降成 agg(v) OVER (… ROWS …),其中 reset 会加上一个 DATE_TRUNC 分区键,并有自己的扩展 + 窗口后裁剪。余下的查询限制 (需要时间轴的那些指标共用同一条轴;不重置的累计指标、或分区全局的指标, 在 time_range 之下不能与会扩大扫描范围的指标共处一条查询)都是结构化的 not_implemented 错误;比所查询粒度更细的重置是 window_reset_too_fine。 D-FILL 绝不作用于派生输出(NULL 的含义是"没有可比的上一期")。 指标表达式里的裸窗口 SQL 仍然被拒绝(window_in_metric)—— 这个扩展是唯一的窗口通路。

metrics:
  - name: revenue_mom_growth
    expression:
      dialects: [{ dialect: ANSI_SQL, expression: "SUM(orders.amount)" }]
    custom_extensions:
      - vendor_name: DATUS
        data: '{"v": 1, "window": {"type": "pop", "offset": "1 month"}}'

D-DERIVE —— 派生指标(filter / compose / lod)

位置:一个 Metric 的 custom_extensions。 载荷键:derive,一个带 type 判别式的 JSON 对象 —— 外加 window 键的 base 字段(M2,见下)。 默认(不存在时):该指标是自包含的,没有任何结构元数据。 支持的形式:M1 发布 filter + compose; M2 补上 window.base 和成员含窗口指标的 compose;1.10 补上 lod(D-LOD, 和 D-LOD 的另一半写在一起)。reagg 与跨数据集的 filter 谓词仍是保留形态,会以 not_implemented 拒绝,并在提示里点名接替它的家族或所属里程碑。

声明一个指标如何从同一模型的其它指标派生 —— 这是 Ossie 表达式承载不了的结构信息。 降级分两类。在展平层(flatten tier:filter,以及成员为叶子指标或 filter 指标的 compose)上,该指标的 expression 仍然是一个合法且语义等价的 Ossie 聚合 —— 基础模式照样计算它、得到相同的数字,引擎也会在编译期强制这份等价性 (derive_expression_mismatch)。在阶段层(stage tier:window.base, 以及成员含窗口指标的 compose)上,没有任何扁平表达式能表达这份语义, 于是 expression 只是最接近的那个近似,基础模式给出不一样的结果是合理的 (并带上扩展被忽略的告警)。无论哪一档,都请把这个表达式当作工具维护的产物, 而不是可以手改的东西。

  • filter —— {"type":"filter","base":<metric>,"where":<predicate>}: base 的一个按维度过滤的变体。谓词会折进聚合的参数里 (CASE WHEN pred THEN arg END;COUNT 类基础指标折成 THEN 1), 而绝不是折成 WHERE —— 不匹配的时间分桶依然存在(取值为 NULL), 正是这一点让该变体在 ROWS 窗框下也安全。M1 限制谓词只能作用于基础指标自己的数据集 (跨数据集谓词在 M4 之前是 not_implemented),且 base 必须是一个非派生的 朴素单聚合。引擎会把子集关系(subset_of)记进指标元数据。
  • compose —— {"type":"compose","expr":<arithmetic>,"fill":<n>}: 在指标名之上做标量算术("revenue - total_refunds")。 标识符严格解析为指标名 —— 带限定符或未知的名字都是错误并附候选项, 绝不会悄悄当成列来读。聚合调用会被拒绝(组合绝不重新聚合)。 fill 在分支对齐之后、算术之前替换某个成员的 NULL (用于修正跨事实表时"缺失分桶毒化整个求和"的问题)。 引擎会识别线性组合(Σ cᵢ·metricᵢ),记录成员、系数和叶子度量的分解, 并在编译期固定下一致维度集(在唯一连接路径规则下,每个成员分支都能到达的维度) —— dosi list 会暴露它,而在该集合之外分组的查询会得到 unconformed_dimension, 并点名是哪个成员够不到这个维度。成员之间没有共同维度时, 编译期会给出 compose_no_conformed_dimensions 告警。 归因元数据只有在 SUM/COUNT 层级度量之上的线性组合中才标记为 exact (一个被过滤的 COUNT(DISTINCT) 与它的补集会重叠 —— 永远不可能 exact)。
  • lod —— {"type":"lod","expr":<粒度表达式>}:相对视图的粒度关键字, avg(include(base, dims…)) 与 exclude(base, dims…),以及组合在它们之上的 compose。 与另外两个家族不同, 这个指标的 expression 不是等价降级形式 —— 它就是声明所描述的那个嵌套聚合, 任何引擎都执行不了它,所以基础模式在它上面 fail closed,而不是去算它的某一层。 完整说明和字段那半边写在一起:D-LOD。

M2 新增:

  • window.base —— window 键(D-WINDOW)接受 "base": <metric>: 窗口作用在被点名的那个指标的序列上,而不是本指标自己的表达式上。 base 可以是叶子指标、filter 指标、比率(1.9)、compose (度量落在一个还是多个数据集都可以),或者建在另一个窗口输出上的 compose (1.9)。跨事实 base 的序列要到分支合并之后才成为一个值,所以阶段落在合并之上, 而不是落在单个聚合之上。 被拒绝的情形:直接点名一个窗口指标 —— 内层窗口没有属于自己的阶段可落脚, 包一层 compose 就有了。 元数据记录 derive_family: "window"、derive_base 链,以及继承来的 subset_of。 作用在 filter base 之上的窗口会继承 CASE-WHEN 的分桶保留特性: 没有匹配行的分桶依然存在,所以 ROWS 窗框在日历上保持正确, 累计求和则跳过这些 NULL。

能把比率或 compose 当 base,「按成交率给市场排名」「这个率比上月高多少」 「我在不在平均率之上」才写得出来 —— 这些都不是单聚合能表达的。 要注意窗口平均的是什么:全分区的 avg 建在比率 base 上, 得到的是各行率的等权平均,而不是把分子分母先合并再相除的合并率。 两者都说得通,而且数不一样;窗口给的是「各个率的平均」那一个。 - 成员含窗口指标的 compose —— 当 compose 的 expr 引用了窗口指标时, 算术作用在窗口输出之上("累计客单价" = revenue_cum / order_count_cum: 分子分母各自累计,然后相除)。成员可以把窗口指标和叶子指标混用; 所有成员共享同一个窗口阶段,因此它们必须共享同一条时间轴 (跨事实表的窗口成员会被拒绝)。这类指标的归因永远是 approximate, 也没有叶子度量分解 —— 窗口输出没法从物化聚合里取。 - compose 的成员可以是 filter 指标 —— 例如 "new_order_count / order_count"; 它在展平层上组合,和叶子指标完全一样。

成员本身是 compose 时,会在编译期内联展开:编译产物是平的, 和把内层算术直接抄出来写一遍完全一样。这就是一条公式只写一次、 到处复用的办法 —— 而且外层的 expression 是拿内层的声明展开后来核对的, 所以改了内层、消费者那份副本没跟着改,会报 derive_expression_mismatch, 不会变成一个没人怀疑的数。

嵌套之前有四件事要知道:

  • expression 写完整的展开式。 把整条展平后的算术写出来, 包括嵌套带来的括号,以及内层是裸 ratio 形状时编译出来的 CAST(… AS DOUBLE)。 它是工具维护的产物 —— 从声明重新生成,不要手改。
  • fill 会下推到叶子。 每个叶子引用取它那条路径上最内层声明的 fill, 所以外层的 fill: 0 依然能修好内层某个没声明 fill 的成员里缺掉的分支。 这个决定是按引用算的:同一个叶子从两个成员到达、两边填法不同,两种填法都保留。
  • 展平层上 derive_members 是展平后的叶子列表,系数逐层折叠 (quadrupled = doubled × 2、doubled = revenue × 2,报出来的是 revenue 系数 4)。 一致维度沿 DAG 求交,所以 unconformed_dimension 点名的是真正缺维度的叶子, 不是中间那层 compose。dosi lineage 画的仍然是你写下的那条成员边。
  • 分层档上,compose 成员仍然是成员。 树里任何位置出现窗口都会把这个指标推到 分层档,而在那里内联是错的:每个成员都是窗口阶段输出的一条独立序列, 把中间那层 compose 溶解成叶子,会丢掉阶段根本不投影的那段算术 (cum_aov / revenue 会退化成「两个窗口成员对一个叶子」,除法没了)。 所以分层档上 derive_members 是直接成员,规划器把每个嵌套 compose 算成一个隐藏的阶段输出,代入在下降时由内向外进行。 档次判断读的是整棵树,所以窗口长在中间那层 compose 上时, 它上面的每一层也一并进分层档。
  • 嵌套有一个上限:单次展开最多 256 个成员引用 —— 被内联的成员在每一处出现都会被代入一份,所以引用两次就会让展开量逐层翻倍。

指标引用之间成环会是 metric_reference_cycle,并给出完整路径。 元数据在每个宿主上都会暴露(dosi list、REST、MCP、Python): derive_family、derive_base、derive_expr、subset_of、derive_members、 leaf_measures、attribution、conformed_dimensions,以及 required_dimensions —— 查询必须分组的维度,沿整条依赖链收集 (window-extension.md#frozen-degrees)。

metrics:
  - name: net_revenue
    expression:
      dialects: [{ dialect: ANSI_SQL, expression: "COALESCE(SUM(orders.amount), 0) - COALESCE(SUM(refunds.ref_amount), 0)" }]
    custom_extensions:
      - vendor_name: DATUS
        data: '{"v": "1.4", "derive": {"type": "compose", "expr": "revenue - total_refunds", "fill": 0}}'
  # 复用 net_revenue;`expression` 是展开式,所以 net_revenue 的声明一改,
  # 这个指标会当场报错。
  - name: net_revenue_per_order
    expression:
      dialects: [{ dialect: ANSI_SQL, expression: "(COALESCE(SUM(orders.amount), 0) - COALESCE(SUM(refunds.ref_amount), 0)) / COUNT(orders.order_id)" }]
    custom_extensions:
      - vendor_name: DATUS
        data: '{"v": "1.4", "derive": {"type": "compose", "expr": "net_revenue / order_count"}}'

D-MEASURE —— 显式度量名

位置:一个 Metric 的 custom_extensions。 载荷键:measure,形如 {"name": <identifier>}。 默认(不存在时):由净化器生成的 stem(S-MEASURE-1)。

显式命名该指标唯一的那个合成度量。度量名原本是把聚合参数净化成纯 ASCII 得到的, 于是两个只在非 ASCII 内容上有差别的聚合(比如 '新品' / '老品' 这样的 CASE 字面量)会塌成同一个 stem,触发 measure_name_collision —— 模型根本编译不过。 显式名字是一个标签:去重仍然以聚合签名为键(一个没有声明的文本孪生体会共享这个 已声明的名字),这个名字也会进入同一套冲突检查(一个签名上有两个显式名字, 或一个名字落在两个签名上,同样会大声冲突),并且它只适用于单聚合指标。 由于度量名会出现在结果列和元数据里,忽略这个键等于悄悄改名 —— 所以请把它列进 requires,让更旧的引擎直接拒绝 (这是 requires 发布以来第一个 silent 影响级别的键)。

metrics:
  - name: 新品收入
    expression:
      dialects: [{ dialect: ANSI_SQL, expression: "SUM(CASE WHEN orders.category = '新品' THEN orders.amount END)" }]
    custom_extensions:
      - vendor_name: DATUS
        data: '{"v": "1.4", "requires": ["measure"], "measure": {"name": "orders_new_product_amount_sum"}}'

D-PARAM —— 查询期参数

位置:一个 Metric 的 custom_extensions。 载荷键:params,一个由 {name, type, default, allowed | min/max, description} 组成的列表。 默认(不存在时):没有参数 —— 指标就是它所声明的那个。

指标的窗口宽度、偏移期数、排名分桶数、导航位置,或者其 filter 谓词里的一个 字面量,可以变成一个类型化的查询期参数:一个 moving_avg(n) 取代 r7_revenue / r30_revenue / r90_revenue,一个 ltv(n) 取代八个 ltvN 列。 参数只填类型化的洞 —— 绝不填 SQL 文本 —— 所以模型对全部合法绑定一次校验 即可,绑定也永远注入不了任何东西。

metrics:
  - name: moving_avg
    expression:
      dialects: [{ dialect: ANSI_SQL, expression: "SUM(sales.amount)" }]
    custom_extensions:
      - vendor_name: DATUS
        data: |
          {"v": "1.5",
           "params": [{"name": "n", "type": "int", "default": 3, "min": 1, "max": 12,
                       "description": "trailing window width in buckets of the queried grain"}],
           "window": {"type": "rolling", "function": "avg", "periods": {"param": "n"}}}
  - name: paid_within_n
    expression:
      dialects: [{ dialect: ANSI_SQL, expression: "SUM(CASE WHEN base.datediff_pay < 7 THEN base.sum_pay_amount END)" }]
    custom_extensions:
      - vendor_name: DATUS
        data: |
          {"v": "1.5",
           "params": [{"name": "n", "type": "int", "default": 7, "allowed": [1, 3, 7, 14, 30, 60, 90, 180]}],
           "derive": {"type": "filter", "base": "pay_total", "where": "datediff_pay < :n"}}

声明。 每个参数有 name([a-z][a-z0-9_]*,指标内唯一)、type (int | float | string)、必填的 default,以及一个取值域 —— allowed(显式的取值列表)或 min/max(闭区间,两端都可省略);二者互斥。 取值域就是治理边界:它规定这个指标允许成为哪些口径。string 参数必须声明 allowed(一个无界的 string 参数等于任意维值),也不接受 min/max; 没有任何取值域的 int/float 参数能编译,但会带上 datus_param_unbounded 警告。default 必须落在取值域内。

槽位。 参数只能从下面这些位置被引用:

槽位 位置 写法 参数类型
offset.count window.offset.count(对象形;"1 month" 糖不接受参数) {"param": "n"} int,取值域 ≥ 1
frame.preceding window.frame.preceding {"param": "n"} int,取值域 ≥ 0
rolling.periods type: rolling 下的 window.periods {"param": "n"} int,取值域 ≥ 1
rank.buckets window.rank.buckets(ntile) {"param": "k"} int,取值域 ≥ 1
value.n window.value.n(nth_value) {"param": "k"} int,取值域 ≥ 1
filter.literal D-DERIVE filter 指标 where 里的一个字面量 :n(bind 语法) int / float / string

窗口槽位都是整数计数,所以只接受 int 参数,且其取值域必须声明一个不低于 槽位自身下限的下界;filter.literal 洞接受三种类型中的任一种(region = :r、 amount > :t)。一个参数可以填多个槽位,一个谓词也可以含多个参数 (datediff_pay < :n AND platid = :plat)。只有参数没有列的谓词(:n < 7) 会被拒绝 —— filter 必须仍然在测试某一列。:name 出现在其他任何地方 —— 指标的 expression、查询期的 where_sql —— 都是错误,绝不会被当成静默的 字面量。没有任何槽位引用的参数会带 datus_param_unused 警告编译。

编译后的模型按每个参数的默认值实例化,所以声明了参数的模型在有查询绑定 之前,规划得和没有参数的模型一模一样。对于 filter 槽位,作者写的 expression 必须就是那个默认值实例化(上面的 < 7):D-DERIVE 的等价门把两者绑在一起, 而一个绑定值会重新物化出与手写字面量完全相同的度量 —— n = 30 的 paid_within_n 与手写的 SUM(CASE WHEN datediff_pay < 30 …) 共用基础 CTE 里的同一列。

绑定。 查询按名字绑定,作用于每一个声明了该参数的被查指标 —— 直接声明, 或经由它的 derive 成员(compose 继承成员的参数,所以 ltv = paid_within_n / new_user_cnt 接受 n):

{"metrics": ["new_user_cnt", "ltv"], "group_by": [{"field": "base.register_date"}],
 "params": {"n": 30}}                          // 一个绑定
{"metrics": ["new_user_cnt", "ltv"], "group_by": [{"field": "base.register_date"}],
 "params": {"n": [1, 3, 7, 14, 30, 60, 90, 180]}}   // 列表:每个值一列

省略的参数取默认值;没有任何被查指标声明的绑定是 unknown_metric_param (candidates 列出已声明的名字);类型或取值域之外的值 —— 包括给 int 传 7.5 或 "7",或者在归因里传列表 —— 是 param_out_of_domain,并附带可直接 重发的 suggested_retry。唯一的隐式转换:float 参数接受 JSON 整数。列表把 指标展开成每个值一列(多个列表取笛卡尔积,先声明的参数在外层),每次查询 最多 64 列(param_expansion_too_large)。

输出。 默认绑定下列名就是指标名;非默认绑定下列名为 {metric}__{param}_{value}(moving_avg__n_6、ltv__n_30;float 1.5 → _1_5,字符串会被净化:o'neil → o_neil),多个参数按声明顺序拼接, 列表绑定下每一列都带后缀。这条规则的存在,是为了两张看板永远不可能在不同 窗口下显示同一个指标名 —— 而无论如何,每个响应都带着机器可读的形式: outputs,每个指标列一条,含它的 metric 与解析后的 params(含默认值), 出现在 CLI 的 JSON、REST 与 MCP 的响应、Python 的 dict、explain,以及 Arrow 的字段元数据(dosi.metric、dosi.params)中。

净化器只认 ASCII,所以它并不总能把一个字符串取值域里的值区分开:全中文的 allowed 列表会塌缩成同一个后缀,us-east 挨着 us_east 也一样。这类取值 改用不透明 token:h 加上该值的 FNV-1a 哈希的 8 位十六进制(算法已冻结, 如 by_channel__c_h0898d4b7),万一撞上就整域加宽到 16 位。token 只由「取值 本身 + 它声明的取值域」决定,所以查询绑了哪几个值、allowed 有没有重排、有没有 新增取值,都不会让它改变。一个取值只要它的词根在域内唯一、且本身不是保留的 h<hex> 形状,就保留原词根 —— 因此可读的 ASCII 取值域的命名与改动前完全一致。 哪些取值退化成了不透明 token、各自的 token 是什么,编译期由 datus_param_opaque_token 警告给出;outputs[].params 始终带着原值, dosi query --format text 还会在表格下面打一行图例。

排序键指的是一个指标(用它的模型名)或一个 group-by 项(用它的输出名, 或用查询里写的那个拼法 —— sales.sale_date:month 与 sale_date__month 都行), 永远不是引擎自己生成的列名。指标名在该指标只有一列时可以解析,带后缀也一样; 被列表绑定摊成多列的指标则是 unknown_order_key,因为没有任何拼法能从中挑出 一列。想按某一组绑定排序,就只绑那一个值 —— 报错里的重试片段就是这么写的。

归因每个参数只绑定一个值(列表会被拒绝并给出单值重试),并把解析后的 绑定回显在 comparison_metadata.params 里。

目录。 dosi list metrics、GET /v1/metrics 与 MCP list_metrics 带有每个指标的 params(声明加上它填的 slots);describe_metric 额外给出 param_schema,即 params 映射的 JSON Schema(allowed → enum、 min/max → minimum/maximum)—— Agent 先读它,再绑定。

兼容性。 在 --osi-basic 下,D-WINDOW / D-DERIVE 这些宿主会被忽略, 所以 params 随之惰性(一条 ignored_vendor_extension 警告),任何查询期 的 params 都是 unknown_metric_param。早于 1.5 的引擎会在结构上拒绝窗口 槽位里的 {"param": …}(那里的字段是整数),并在版本门处拒绝 "v": "1.5" 的信封 —— 对 filter 里的 :name 来说这才是要紧的那道保护,因为 1.4 引擎 否则会把 bind 标记原样渲染进 SQL。注意 MetricQuery 容忍未知字段:1.4 的 服务端会悄悄丢掉客户端的 params 并按默认值作答。依赖它之前,先看 GET /v1/capabilities(或 dosi info)里有没有 params。

D-DIM —— 哪些字段可以作为维度推荐

位置:Field 的 custom_extensions。 载荷键:is_dimension,布尔。 缺省(不写):由引擎推断该字段的角色(见下)。

Ossie 把每个非时间字段都当作普通维度,于是模型无法表达一列其实是度量 (unit_cost)、标识(c_customer_id)、自由文本或 PII。按这样的列分组 并不是结构错误 —— SQL 跑得通,数字也对,只是这个分析没有意义。 is_dimension 让模型在建模时把这件事说一次,而不是每个调用方在每次调用时 重新判断一遍。

它是列举信号,不是权限:

写法 效果
不写 走下面的推断
false 不推荐该字段:list_dimensions 标记 is_dimension: false,归因不把它列为候选。显式写进 group_by 仍然可用。
true 即使推断会排除它也照样推荐 —— 数值列要分桶分析时就这么写

因为它到不了规划器,忽略这个键不会改变任何数字,只会让调用方拿到一份更差的 候选清单(documented 影响级别)。

datasets:
  - name: store_sales
    fields:
      - name: ss_quantity            # 没有任何指标聚合它的度量列
        expression: { dialects: [{ dialect: ANSI_SQL, expression: ss_quantity }] }
        custom_extensions:
          - vendor_name: DATUS
            data: '{"v": "1.7", "is_dimension": false}'
      - name: i_current_price        # 数值,但要按价位段分析
        expression: { dialects: [{ dialect: ANSI_SQL, expression: i_current_price }] }
        custom_extensions:
          - vendor_name: DATUS
            data: '{"v": "1.7", "is_dimension": true}'

不写这个键时的推断。 引擎只看模型结构,绝不探测数仓(对高基数列做 COUNT(DISTINCT …) 不是引擎有权强加的代价)。以下字段不会被推荐:主键成员、 指标自身归属数据集上的单列唯一键、关系连接列对中出现的列、以及被某个指标度量过的列 (SUM/AVG/STDDEV*/VAR* 读到的那个值)。其余都推荐; list_dimensions 会用 source 说明命中的是哪条规则。

有三类聚合只是提到了某列,并没有度量它,因此该列仍然是维度:

  • COUNT —— COUNT(DISTINCT region)("活跃区域数")是正常指标,region 是正常维度。 对正在被列举的那个指标同样成立:同一个字段,从哪个指标问都是同一个答案。
  • MIN / MAX —— 它们对字符串和日期同样成立,MIN(activity_code) 是"随便取一个标识",不是度量。
  • 聚合里的条件 —— SUM(CASE WHEN is_instant THEN 1 ELSE 0 END) 是"按标志计数", 标志本身仍是维度;只有 THEN/ELSE 的取值才是被度量的。D-DERIVE 的 filter 折叠出的正是这个形状, 行为一致。

推断看不见这几类:既非声明的键、也不是关系列的业务键;没有任何指标读过的数值列; 自由文本;PII —— 这些正是值得显式声明的字段。

D-LOD —— 固定粒度字段

位置:Field 的 custom_extensions。 载荷键:lod,一个 {"expr": "fixed(base, dims…)"} 对象 —— 一条细节层次 表达式,和指标上的 derive: {type: "lod"} 是同一套语法。 base 指向一个「单一平凡聚合」指标;维度是本字段所属数据集的字段,一个都不写 就是表级 FIXED —— 也就是 Tableau 的 {MAX([date])},一个值广播到每一行。 缺省(不存在时):这就是一个普通的行级字段。

lod 字段的值不是存储的列,而是把数据集按 dims 分组之后算出来的 base —— 也就是 Tableau 的 {FIXED [dims]: base}。这让聚合的结果本身可以当成一根轴: 「恰好活跃 N 天的用户有多少」,而这个 N 本身就是每个用户的 COUNT(DISTINCT 日期)。查询侧不需要任何新语法 —— 这个字段和别的字段一样可以 分组、过滤、被聚合,汇总阶段由引擎生成 —— 视图和它的筛选都是汇总键的函数时走两阶段计划, 否则走下面的 broadcast。

fields:
  - name: active_day
    # 逐字写出聚合本身 —— 原因见下面「表达式不是占位符」
    expression:
      dialects: [{ dialect: ANSI_SQL, expression: "COUNT(DISTINCT event_date)" }]
    custom_extensions:
      - vendor_name: DATUS
        data: '{"v": "1.10", "requires": ["lod"],
                "lod": {"expr": "fixed(active_days, sessions.user_id)"}}'

一套语法,两个载体。 fixed 在这里和在指标的 derive 里是同一个词, 由同一个解析器读;不同的是各载体接受什么。字段只接受 fixed,且不能带外层 聚合 —— 它的值每个键一个,没有东西需要被收掉。相对视图的两个关键字属于指标, 因为它们的粒度是查询的函数。

形式 字段 指标
fixed(base, dims…) 可以 —— 值是行的一列 可以 —— 值挂接到视图行上
include(…) / exclude(…) 不行 —— 字段没有「视图」可言 可以
外层聚合 agg(…) 不行 include 必须有;exclude 和 fixed 禁止
// 2 渠道上出现过的用户里,1 月份恰好活跃 N 天的各有多少
{"metrics": ["user_num"],
 "group_by": ["sessions.active_day"],
 "where": "sessions.channel_id = 2",
 "time_range": {"start": "2025-01-01", "end": "2025-02-01"}}
SELECT f.active_day, COUNT(DISTINCT sessions.user_id) AS user_num
FROM main.lod_session AS sessions
JOIN (SELECT user_id, COUNT(DISTINCT event_date) AS active_day
      FROM main.lod_session
      WHERE event_date >= DATE '2025-01-01' AND event_date < DATE '2025-02-01'  -- time_range:上下文
      GROUP BY user_id) AS f
  ON sessions.user_id IS NOT DISTINCT FROM f.user_id
WHERE sessions.channel_id = 2                                                    -- where:维度筛选
  AND sessions.event_date >= DATE '2025-01-01' AND sessions.event_date < DATE '2025-02-01'
GROUP BY f.active_day

1 月这个窗口约束每个用户的天数;渠道条件在它之后挑行,所以 1 月里在两个渠道 都会话过的用户,在 2 渠道上也是「活跃 2 天」的用户 —— 这正是 Tableau 计算 FIXED 的次序(见筛选位置)。

「把钉死的值再聚合一次」只有一种写法:一个普通指标去聚合这个字段 (daily_users 按 event_date 钉死,AVG(sessions.daily_users) 就是日均活跃; 按 metric_time:month 分组就是月度序列)。没有单独的关键字。

筛选落在哪一层:Tableau 的运算次序

active_day 是它所依据的那些行的函数,所以谓词落在哪一阶决定的是数字对不对, 而不只是粗细。分层沿用 Tableau 的运算次序 —— 上下文筛选,然后 FIXED,然后筛选架:

  • time_range(以及 time_ranges)和 context_filter 是上下文层。 分析窗口 推进每一次汇总的输入,所以上面那个查询数的是每个用户在 1 月的天数。这个窗口在 Datus 的其他阶段 —— metric_time、D-WINDOW、归因 —— 都是同一个含义;也正是 Tableau 用户把日期筛选「添加到上下文」得到的效果。context_filter 对其他条件做 同样的事:context_filter: "sessions.channel_id = 2" 时只数 2 渠道上的天数。上下文 条件不能读 D-LOD 字段,因为它本身就是计算这个字段的那次汇总的输入。
  • where 子句是维度筛选。 它挑出视图要聚合的行,永远不进入固定汇总的输入: 上面那个查询先按全部渠道数出每个用户 1 月的天数,再只留 2 渠道的行。对关联 维表的过滤(channel.channel_name = 'pc')同样是维度筛选。只引用汇总自身键的条件 (sessions.user_id <> 'A')改为作用在汇总的输出上 —— 同样的行,走更省的两阶段 计划;其他存储列条件把分支切到 broadcast,在那里过滤明细行。
  • 针对 D-LOD 字段的谓词作用在汇总值上 —— 两阶之间,或者 broadcast 的 join 之后 —— 永远不会反过来进入任何一次汇总的输入。同期群分析靠的就是这一条: where: "sessions.first_day BETWEEN …" 按获取日期圈定人群,同时仍然让这批人 按全部历史汇总。跨键族也一样:在 active_day 分布上加 where: "sessions.daily_users >= 4",留下的是落在高流量日子的会话行,而 active_day 本身仍按全部日期计数。
  • 关联也按同样的分层。 固定汇总只关联它自己的表达式、键和上下文层引用到的 数据集 —— 这是 Tableau 的「关系」(逻辑层)语义:FIXED 只在它引用的表上计算。 视图为了某个维度、where 子句或别的指标关联进来的数据集,只作用于明细行。所以 视图多带一个别的数据集的维度,固定值不会变,关联是 INNER 也一样:sessions → promo_day 声明为 inner、只有一天是促销日时,按 promo_day.promo_name 分组, 视图只剩那一天的会话,而 active_day 仍按每个用户的全部历史计数。要让汇总只算 关联得上的行,就在上下文层引用关联另一端 (context_filter: "promo_day.promo_name = 'launch'"):这条关联连同它删掉的行 一起进入汇总。
  • 每个合取项各自落位,所以存储列条件和 D-LOD 条件用 AND 连起来不需要 任何照顾。一个表达式里同时出现两层 —— orders.event_date = orders.first_order_day (首日收入),或者跨两层的 OR / NOT —— 就在 broadcast 之后整体求值。

因此 where 子句没有办法表达条件的上下文口径(「只算 2 渠道上的活跃天数」); 按查询表达用 context_filter。属于数据集本身的条件,就像 Tableau 的数据源筛选, 今天用内联查询做数据源(source: "SELECT * FROM t WHERE channel_id = 2");数据集级 的筛选扩展见 issue #197。

Broadcast:在明细行旁边读汇总值

两阶段计划只读汇总表、不读别的,前提是视图选出来的每一样东西都是汇总的键或者 同一次汇总的 D-LOD 字段。不满足时 —— 维度不在键里、按 LOD 分组聚合一个存储列、 行级表达式读了这个值、一个查询里有两个键族 —— 引擎改走 broadcast:把事实表当 明细行读,每一行按键 join 上它所属实体的那一行汇总,每个键族一张派生表,全部 算在同一份过滤后的扫描上。多行明细对一行汇总,join 不会扇出。Tableau 的 FIXED 就是这么做的。

聚合按行计数时,汇总键也算「存储列」。汇总表每个键只有一行,所以对键做 COUNT(DISTINCT user_id)、MIN、MAX,在汇总上和在明细上答案一样,仍走两阶段; COUNT(user_id)、COUNT(*)、SUM、AVG 数的是事实行,改走 broadcast。按 active_day 分组时,活跃两天的用户有 8 条会话,不是 4 条。

// 按渠道看日均活跃 —— channel_id 不是按日期汇总的键
{"metrics": ["avg_daily_users"], "group_by": ["sessions.channel_id"]}
SELECT g.channel_id, AVG(f.daily_users) AS avg_daily_users
FROM (SELECT channel_id, event_date                    -- 每个 (channel_id, event_date) 一行
      FROM main.lod_session GROUP BY channel_id, event_date) AS g
JOIN (SELECT event_date, COUNT(DISTINCT user_id) AS daily_users
      FROM main.lod_session GROUP BY event_date) AS f
  ON g.event_date IS NOT DISTINCT FROM f.event_date
GROUP BY g.channel_id

两条值得知道的后果:

  • 权重:每个键在每个分组里只算一次。 钉住的值属于它的键,不属于携带它的那些 行,所以聚合它时每个键在每个分组里只算一次——事实表会被读成「每(视图维度 × 汇总 键)一行」,也就是上面那个 GROUP BY channel_id, event_date 的派生表。2 渠道出现 在三个日期上,带的值是 4、4、1 → 3.0,而不是它六条会话行给出的 3.5。Tableau 对同一个问题的算法与此相同。这不是加权平均:想让明细行参与加权,就去聚合一个 存储列。

只读 D-LOD 值和常量的行级字段也一样 —— CASE WHEN active_day > 1 THEN 1 ELSE 0 END 对一个用户只有一个值,不管他有几条会话行 —— 所以对它求 SUM,每个键在每个分组里 只算一次,与直接对 active_day 求和相同,也与 Tableau 在 FIXED 粒度上求值 「只含 FIXED 值的计算字段」相同。同时读了存储列的字段(event_date = first_order_day) 是行的属性,按行加权。网格的键是视图维度加上被聚合那些键族的键;查询只用来分组的 键族 —— 作为视图维度的 D-LOD 字段或由它算出的字段 —— 在网格内部 join 进来算出 这个维度,自己不贡献键。 - 一次查询只有一种粒度。 聚合钉住值的指标和聚合存储列的指标粒度不同——一个是 每键一次,一个是每行一次——同时要这两种的查询会被点名拒绝,而不是挑一种粒度 糊过去。两个指标聚合的钉住值来自不同键族时同样拒绝(比如按用户的 AVG(active_day) 和按日期的 AVG(daily_users)):网格按被聚合的键去重,两族共用 一个网格,每个值都会按另一族的键重复计,两个指标都和单独查时对不上。请分开查询。 - 键为 NULL 也能对上。 GROUP BY 会让 NULL 键自成一组,所以连接条件是空值 安全的(IS NOT DISTINCT FROM,或方言需要时的等价 OR 写法)——否则那些明细行 会无声地退出聚合,同一个问题还会因为 planner 选了哪种形态而给出两个答案。在 无法对 OR 写法做哈希连接的方言上这会慢一些;真在意就在上游把该列声明为 NOT NULL。 - 行级字段可以读 D-LOD 字段。 CASE WHEN event_date = first_order_day THEN 'new' ELSE 'returning' END 是合法的字段表达式:写出 D-LOD 字段的名字就是读 broadcast 过来的值,按这个标记分组就是新老下单收入,不需要任何新语法。这种字段不能做的 是把值再送回汇总 —— 当 dims 的键、当 base 的参数(嵌套 LOD)、当关系列、 当时间维度 —— 这些都在编译期拒绝。

一条查询走哪种形态按分支决定,--explain 里能看到(LodRollup 还是 LodBroadcast)。只要两阶段够用就用两阶段,所以 broadcast 出现之前能规划的查询, 生成的 SQL 逐字节不变。

汇总保留存储编码

汇总的键按存储原值投影 —— 不截断粒度,也不做 D-FORMAT 解析。 视图归外层管:外层对 %Y%m%d 的键解析并截断到请求的粒度,恰好一次 (DATE_TRUNC('MONTH', STRPTIME(f.event_ymd, '%Y%m%d')))。如果在里面也截断, 汇总本身会被悄悄变粗,字段的含义就变了。

表达式不是占位符

字段的 expression 携带真实的聚合,引擎会检查它和 base 一致(不一致就是 derive_expression_mismatch)。两个原因:这是忽略该扩展的消费者会读到的东西; 而一个聚合被当成行级列使用时会在数仓那边直接报错 —— 这严格优于占位符, 后者会静默地按错误的行集分组。基础模式的降级也走这条路(§5)。

相对视图:托管在指标上的 include 与 exclude

位置:Metric 的 custom_extensions。 载荷键:derive,家族 {"type": "lod", "expr": …}。 缺省(不存在时):这个指标就是它表达式写的那样。

fixed 字段对所有查询钉死同一个粒度,所以它能当字段:它的值是行的一个属性。 include 和 exclude 钉的粒度是相对查询自己的 group-by 的,所以它们不能是字段 —— 它们托管在指标上,那里「值取决于视图」本来就是契约的一部分。

- name: revenue_per_user
  # 逐字写出嵌套聚合 —— 原因见「表达式不是占位符」
  expression:
    dialects: [{ dialect: ANSI_SQL, expression: "AVG(SUM(orders.amount))" }]
  custom_extensions:
    - vendor_name: DATUS
      data: '{"v": "1.10", "derive": {"type": "lod",
              "expr": "avg(include(revenue, orders.user_id))"}}'

expr 写成 SQL 是为了让普通工具能 lint 它,但它读起来是一套很小的语法:

写法 内层分组 外层聚合
agg(include(base, dims…)) 视图维度加上 dims —— 比视图细 必须有:总得有东西把多出来的行收掉
exclude(base, dims…) 视图维度减去 dims —— 比视图粗 不允许:每个视图行本来就只对应一行
{"metrics": ["revenue_per_user"], "group_by": ["orders.channel_id"]}
SELECT f.channel_id, AVG(f.revenue) AS revenue_per_user
FROM (SELECT orders.channel_id, orders.user_id, SUM(orders.amount) AS revenue
      FROM main.lod_order GROUP BY orders.channel_id, orders.user_id) AS f
GROUP BY f.channel_id

你要哪一种是个真问题,而模型负责回答它。 「按渠道看人均下单」有两种都说得通的读法, 只要有用户在多个渠道下过单,两者就不一样:

写法 读法 按渠道
avg(include(revenue, orders.user_id)) 在这个渠道内的人均 pc 12.5,mobile 20.5
普通指标聚合 fixed 字段 AVG(orders.user_revenue) 每个用户的历史总额,在他下过单的每个渠道上各算一次 pc 17.5,mobile 23

两个都不算错。引擎从不替你挑:各是各的指标,各写一次。

过滤规则和字段那半边一样:查询过滤和 time_range 推进内层分组,所以这个粒度是在 查询问到的那批行上算的。「相对视图」带来两处与 fixed 汇总的差别:

  • 粒度落在里面。 按 metric_time:month 分组时,内层按月和用户分组再求平均, 而不是按天和用户。fixed 汇总恰好相反(见上文),因为那里的键是 存储列,视图归外层管。
  • 外层只读这一个分组,所以查询里的每个指标都必须是同一粒度的 LOD 指标。旁边放一个 普通指标会被点名拒绝,而不是在错误的粒度上给个数。

exclude 是挂接,不是替换。 include 分得更细再收回来,exclude 分得更粗 再把结果带到每一个视图行上:事实表聚合两次,两者按共享的维度 join(LEFT JOIN), 声明把视图维度全排除时就没有共享维度,那就是 CROSS JOIN —— 总计。每个视图行只对应 一行更粗的结果,所以挂接不会扇出。

- name: revenue_grand   # 排除全部视图维度:总计
  expression:
    dialects: [{ dialect: ANSI_SQL, expression: "SUM(SUM(orders.amount))" }]
  custom_extensions:
    - vendor_name: DATUS
      data: '{"v": "1.10", "derive": {"type": "lod", "expr": "exclude(revenue)"}}'
- name: revenue_share   # 它的用处:一个视图没有按之分组的分母
  expression:
    dialects: [{ dialect: ANSI_SQL, expression: "SUM(orders.amount) / SUM(SUM(orders.amount))" }]
  custom_extensions:
    - vendor_name: DATUS
      data: '{"v": "1.10", "derive": {"type": "compose", "expr": "revenue / revenue_grand"}}'
WITH m0 AS (SELECT orders.channel_id, SUM(orders.amount) AS revenue
            FROM main.lod_order GROUP BY orders.channel_id),
     m1 AS (SELECT SUM(orders.amount) AS revenue_grand FROM main.lod_order)
SELECT m0.channel_id, m0.revenue, m0.revenue / m1.revenue_grand AS revenue_share
FROM m0 CROSS JOIN m1

两点值得知道:

  • 组合在 LOD 指标之上的指标,自己也是 LOD 指标。 revenue_share 自己的表达式 也是嵌套聚合,因为它的分母的值本来就写不成扁平聚合 —— 所以基础模式在占比列上 fail closed,和在它的成员上一样。引擎是穿过 compose 去找那个声明的:找不到不会 报错,而是会把分母算在视图自己的粒度上,让每个占比都变成 1。
  • 更粗的值是重算出来的,不是再聚合出来的。 窗口函数 (SUM(SUM(x)) OVER (PARTITION BY …))对可加的 base 一趟就能算出同样的答案, 但对 COUNT(DISTINCT) 会多算。侧分支在更粗的粒度上跑 base 自己的聚合,对任何聚合 都是对的。
  • 完全没有 group-by 时没有东西可排除,此时指标就是它的 base,计划是一次扁平聚合。 这只对 exclude 成立:无键的 fixed 指标在没有 group-by 时仍然不受维度筛选影响,所以它照样单独成一个侧分支。

指标上的 fixed:视图不分组的那个值

位置:Metric 的 custom_extensions。 载荷键:derive,家族 {"type": "lod", "expr": "fixed(base[, dims…])"}。

和字段做的是同一条声明,托管在指标上,于是它能挂接到视图行上,而不是成为 视图的一列。不带维度时就是 Tableau 的表级 {SUM([Sales])} —— 单行,CROSS JOIN 到每一个视图行。带维度时,这些维度必须是视图分组的维度,侧分支通过空值安全的 LEFT JOIN 挂上去:每个视图行恰好遇到它的一行,所以挂接不会扇出。

- name: revenue_fixed_all       # Tableau 的 {SUM([amount])}
  expression:
    dialects: [{ dialect: ANSI_SQL, expression: "SUM(SUM(orders.amount))" }]
  custom_extensions:
    - vendor_name: DATUS
      data: '{"v": "1.10", "derive": {"type": "lod", "expr": "fixed(revenue)"}}'
- name: revenue_share_of_all
  expression:
    dialects: [{ dialect: ANSI_SQL, expression: "SUM(orders.amount) / SUM(SUM(orders.amount))" }]
  custom_extensions:
    - vendor_name: DATUS
      data: '{"v": "1.10", "derive": {"type": "compose", "expr": "revenue / revenue_fixed_all"}}'

fixed 与 exclude 不是同一件事的两种写法。 两者都可以无键、都把一个总计 挂到每个视图行上 —— 而一旦有筛选,答案就不同:fixed 在维度筛选之前 算好,exclude 则是视图自己的分组少一个维度。

{"metrics": ["revenue", "revenue_share_of_all", "revenue_share"],
 "group_by": ["orders.channel_id"],
 "where": "orders.user_id <> 'A'"}
WITH m0 AS (SELECT orders.channel_id, SUM(orders.amount) AS revenue
            FROM main.lod_order AS orders WHERE orders.user_id <> 'A'   -- 视图
            GROUP BY orders.channel_id),
     m1 AS (SELECT SUM(orders.amount) AS revenue_fixed_all
            FROM main.lod_order AS orders),                            -- fixed:没有 WHERE
     m2 AS (SELECT SUM(orders.amount) AS revenue_grand
            FROM main.lod_order AS orders WHERE orders.user_id <> 'A')  -- exclude:受筛选
SELECT m0.channel_id, m0.revenue,
       m0.revenue / m1.revenue_fixed_all AS revenue_share_of_all,
       m0.revenue / m2.revenue_grand     AS revenue_share
FROM m0 CROSS JOIN m1 CROSS JOIN m2

两个分母都不错。「占我们卖出去的全部的多少」和「占这次筛选选中的多少」是两个 不同的问题,Tableau 有两个关键字正是为此。一个指标是哪种意思,在模型里写一次。

带键的 fixed 读法相同 —— 侧分支按声明的维度分组、跳过筛选,于是筛选只作用在 分子上:

{"metrics": ["revenue", "revenue_fixed_by_channel"],   // fixed(revenue, orders.channel_id)
 "group_by": ["orders.channel_id"], "where": "orders.user_id <> 'A'"}
// 渠道 1  :revenue 25,fixed 25
// 渠道 2:revenue 35,fixed 82 —— 筛选只从分子里拿掉了 47

它的键必须是视图的键。 如果 fixed 指标的键不在查询的分组里,它一次会遇到 多个视图行;要收成视图粒度就得跨键再聚合这些 FIXED 值 —— 那是一张(视图维度 × 键)的网格,也就是普通指标聚合一个 fixed 字段时的形状。拒绝信息会给出 两条出路。没有 group-by 的查询也一样,它的视图里同样没有这些键。同理,fixed(…) 外面套一层聚合会被拒绝,而不是算出一个数。

跨事实对齐:一根轴,两张事实表

两张事实表可以各自算出同一个按实体的聚合 —— 一个用户会话了几天、下单了几天 —— 而业务上要把它们放在同一根轴上看。用一条端点是两侧 fixed 字段的 conformed 配对来声明这件事:

relationships:
  - name: sessions_orders_conformed
    from: sessions
    to: orders
    from_columns: [active_day, event_date]
    to_columns: [active_day, event_date]
    custom_extensions:
      - vendor_name: DATUS
        data: '{"v": "1.8", "requires": ["cardinality"], "cardinality": "many_to_many"}'
{"metrics": ["user_num_total"], "group_by": ["sessions.active_day"]}
WITH m0 AS (SELECT f.active_day, COUNT(DISTINCT f.user_id) AS sessions
            FROM (SELECT user_id, COUNT(DISTINCT event_date) AS active_day
                  FROM main.lod_session GROUP BY user_id) AS f
            GROUP BY f.active_day),
     m1 AS (SELECT f.active_day, COUNT(DISTINCT f.user_id) AS buyers
            FROM (SELECT user_id, COUNT(DISTINCT event_date) AS active_day
                  FROM main.lod_order GROUP BY user_id) AS f
            GROUP BY f.active_day)
SELECT COALESCE(m0.active_day, m1.active_day) AS active_day,
       COALESCE(m0.sessions, 0) + COALESCE(m1.buyers, 0) AS user_num_total
FROM m0 FULL OUTER JOIN m1 ON m0.active_day = m1.active_day
                           OR m0.active_day IS NULL AND m1.active_day IS NULL

每张事实表自己汇总,两条分支按共享的输出名合并。两张事实表之间从不 join —— 一条会话行和一条下单行没有可匹配的东西,join 起来会让两边的计数都翻倍。这正是这条边 是「配对」而不是「关系」的全部理由。

两套机制已有的行为原样适用:配对轴的任一侧拼写都能拿来分组;行级过滤和 time_range 按各自那一侧的时间列拼写推进各自那次汇总的输入;裸计数给缺的桶补 0,而 SUM 保持 NULL (S-FAN-6)。单事实指标也可以按另一侧的拼写分组 —— 分支读自己 那一侧,如果它的度量聚合的列汇总没有导出,就走 broadcast。

D-LOD 还不做什么

下面这些都是结构化的 not_implemented 拒绝,并点名什么能解除它(或者为什么 永远不解除),绝不会生成算错的 SQL:

查询 原因
指标上的 agg(fixed(…)),或者 fixed 指标的键不在视图的分组里 跨键再聚合 FIXED 值是字段载体的形状 —— 一张(视图维度 × 键)的网格;声明 fixed 字段,再用普通指标聚合它
两个指标聚合不同键族的钉住值 一个网格只能按一组键去重,每个值会按另一族的键重复计;请分开查询
归属于另一个数据集的指标,且两者之间没有 conformed 配对 没有东西能把两根轴对齐;声明这条配对
普通指标与 include 指标同查,或 fixed 字段与其中任一同查 外层只读一个分组;两个粒度、其中一个还相对视图,不做嵌套。(普通指标与 exclude 同查没问题 —— 挂接不动视图自己的分组。)
include 与 exclude 出现在同一个查询里 它们把分支的分组往相反的两个方向拉
exclude 与窗口指标同用、出现在 time_ranges 下,或旁边有归属另一数据集的指标 挂接只读一份视图粒度的聚合
按相对视图的指标分组或过滤 它的粒度是由 group-by 定义的,所以不能又是 group-by 的一部分;错误里会给出能这么用的 fixed 字段写法
D-LOD 字段与窗口指标同用,或者出现在 time_ranges 下 不同的阶段栈
include 指标与窗口指标同用,或者出现在 time_ranges 下 外层只读那份更细的汇总;没有时间列留给窗口或逐 tag 的闸
where 里有针对 D-LOD 字段的条件,同时查 include、exclude 或 fixed() 指标 这个条件筛的是汇总值,相对视图或挂接的粒度得围着它算 —— 和按这个字段分组是同一种嵌套
某个 group-by 维度的输出名与一个汇总键相同,但它不是那个键(acct.user_id 与按 sessions.user_id 汇总的键) duplicate_output_name:网格会把两者当成一个,按错误的值连接。给字段改名,或者直接按那个键分组
对读了细节层次的指标做归因,或者按 D-LOD 维度归因 归因把两个窗口放进同一条语句比较,而汇总值是每个窗口过滤上下文的函数——得每个窗口各算一次。指标预先就被拒为 unsupported / level_of_detail(dosi list metrics 里 attributable: false);维度则在发出任何语句之前以 dimension_skipped 告警跳过
dosi select 投影或过滤 D-LOD 字段,或者读了它的字段 明细平面没有汇总阶段;错误里给出对应的 dosi query 写法

无键的 fixed 字段不在这些限制里:lod 不带维度就是表级细节层次,广播到 每一行,供行级表达式读取。

还有几条是结构上就固定的:include / exclude 是相对视图的,所以托管在指标上 而不是字段上(见上)—— 在字段上写会告诉你它该待在哪儿。D-LOD 字段不得声明 dimension.is_time: true,读了它的字段也不行:汇总出来的值不是一根能下推时间 范围或粒度的轴。只是 datatype 为日期类型不会让它们成为时间轴 —— 类型为 Date 的首单日期没问题,只是永远不当时间维度用。嵌套 LOD —— dims 条目或 base 的 参数本身是、或者读了 D-LOD 字段,或者字段或 derive 的 base 本身是细节层次 指标,不论文档里声明的先后 —— 会被拒绝,关系列里读 D-LOD 也一样。window.base 指向细节层次指标同样拒绝(not_implemented):不支持在细节层次上开窗。

4. 优先级与默认值汇总

关注点 Ossie 核心 Datus 扩展 不存在时的引擎默认
关系的连接类型 未定义(假定 LEFT) D-JOIN join_type left
事实表之间的一致性配对 无法表达(每条关系都是 join) D-CONFORM cardinality many_to_one —— 这条边就是一个 join
缺失分组的指标值 未定义 D-FILL fill_nulls_with 计数→0(S-FAN-6),其余 NULL
主时间维度 未定义(只有 is_time) D-TIME time_dimension(指标 > 数据集) 数据集里唯一的 is_time 字段,否则无
原生时间粒度 无显式粒度;Date 表示日粒度 D-GRAIN time_granularity 从类型或编码推断,否则未知
时间列的存储格式 有逻辑 datatype,无编码布局 D-FORMAT time 假定为原生 DATE/TIMESTAMP
COUNT(*) 的归属数据集 在多数据集模型里未定义 D-DATASET dataset 由 SQL 推导,否则 count_star_needs_dataset
窗口派生 无法表达(窗口 SQL 被拒绝) D-WINDOW window 朴素的基础聚合
查询期参数 无法表达 D-PARAM params 无 —— 每个槽位就是它声明的那个字面量
哪些字段作为维度推荐 每个非时间字段都是维度 D-DIM is_dimension 从键、关系与被聚合的列推断
聚合结果当维度 无法表达(字段是行级的) D-LOD lod 无 —— 字段就是一个存储的列

5. 校验与引擎模式

  • Ossie 关卡(scripts/validate_osi.py):不受影响 —— custom_extensions 是合法 Ossie,所以所有 fixture 仍然能 PASS ossie。
  • Datus 自校验(引擎处于 --osi-datus 模式,即默认模式,且读到一条 DATUS 条目时):载荷必须是一个 JSON 对象;已知键必须匹配其声明的类型/枚举; 否则 invalid_datus_extension。未知键放行(前向兼容), 除非它被 requires 点名。信封的版本闸门(§2)在这里运行,每个载荷一次 —— 包括 SemanticModel 这个载体,它自己不定义任何键,但仍要遵守信封规则。
  • 基础模式(--osi-basic):引擎把 DATUS 当作任何未知厂商来对待 —— 扩展是惰性的(符合规范的原样透传),编译过程会为每个携带它的元素收集一条 ignored_vendor_extension 告警 —— SemanticModel、Dataset、Field、 Relationship、Metric 一视同仁 —— 并点名改为适用的那个默认值 (D-JOIN → LEFT,D-FILL → 除 S-FAN-6 外不做填充, D-TIME → 只做单 is_time 推断,D-GRAIN → 任意粒度, D-DATASET → 只做 SQL 推导的归属,D-WINDOW → 朴素的基础聚合, D-FORMAT → 该列被当作原生日期,对字符串或整型列而言这意味着一个数仓类型错误、 或者悄无声息的错误行,D-LOD → 字段被丢弃,D-CONFORM → 这条边成为普通的 many→one join,两张事实表被逐行连接、对重复敏感的聚合被扇出)。D-LOD 是唯一一个没法降级成某个 值的:没有那份声明,字段的表达式就是一个裸聚合,而聚合不是合法的行级属性, 所以字段会带着一条 aggregate_field_dropped 告警被丢掉,引用它的指标和查询 随后以 unknown_field / unknown_column 失败。另一条路是让 AVG(COUNT(DISTINCT …)) 打到数仓上。 载荷不会被解析,所以格式不对的载荷同样只是被忽略并告警 —— 而且在基础模式下根本不会产生任何版本诊断。同一个模型文件在两种模式下都合法; 区别只在语义。(单 is_time 的主时间推断不是扩展 —— 它在基础模式下同样适用,所以只要纯 Ossie 能确定一条唯一的时间轴, metric_time 就仍然可用。)
  • 实现边界:所有扩展读取逻辑 —— 以及模式分支本身 —— 都住在一个模块里, crates/dosi-compiler/src/ext.rs。将来的扩展也以同样的形态落在那里; 引擎的其余部分对扩展一无所知。

6. 非目标/边界

  • Datus 扩展绝不重新定义一个 Ossie 对象的身份或一个指标的 SQL —— 只在 Ossie 未定义的接缝处影响引擎行为。
  • 它们不是夹带厂商 SQL 的地方。指标表达式仍然放在 Ossie 的 expression.dialects 里;引擎依旧从那段 SQL 推断指标语义。
  • 那些本该属于 Ossie 核心的语义(粒度、时间轴表、窗口/累计指标) 未来可能由上游标准统一定义; 一旦在那里被采纳,对应的 Datus 扩展(D-GRAIN、D-WINDOW)就会退役, 改用核心字段。

7. 变更记录

每个已发布的小版本,以及它新增的键。这张表的机器可读形式是 capabilities().history —— dosi info --format json 或 GET /v1/capabilities。

版本 日期 新增的键 忽略时的影响
1.0 2026-07-11 join_type (D-JOIN)、fill_nulls_with (D-FILL) documented、documented
1.1 2026-08-02 time_dimension (D-TIME)、time_granularity (D-GRAIN)、dataset (D-DATASET) degraded、documented、degraded
1.3 2026-08-09 (无 —— window 的族内扩展) (继承 window 的 documented)
1.4 2026-08-12 derive(D-DERIVE M1)、measure(D-MEASURE) degraded、silent
1.5 2026-08-26 params(D-PARAM M1) degraded
1.6 2026-09-02 time(D-FORMAT) silent
1.7 2026-09-11 is_dimension(D-DIM) documented
1.8 2026-09-16 cardinality(D-CONFORM) silent
1.9 2026-09-16 (无 —— window 的族内增长) (沿用 window 的 documented)
1.10 2026-09-23 lod(D-LOD;以及 derive 的 lod 家族) degraded
  • 1.0(2026-07-11):初版 —— D-JOIN(关系的 join_type, 已实现:dosi-compiler 把它读进 RelationshipIr.join_kind, 规划器据此发出 INNER/LEFT)、D-FILL(指标的 fill_nulls_with, 2026-07-12 已实现:MetricIr.fill_nulls_with → 规划器对指标输出做 COALESCE; 覆盖 S-FAN-6 的计数默认值,对任何种类的指标都适用)。
  • 1.1(2026-08-02):时间与归属 —— D-TIME(数据集/指标的 time_dimension,已实现:DatasetIr.primary_time_dimension + MetricIr.time_dimension,保留的 metric_time 查询名, 按分支的时间范围回退)、D-GRAIN(字段的 time_granularity, 已实现:FieldIr.time_granularity,更细粒度的请求 → grain_too_fine)、 D-DATASET(指标的 dataset,已实现:COUNT(*) 的归属)。 基础模式的 ignored_vendor_extension 告警现在覆盖全部五种载体对象 (此前只有 Relationship 和 Metric)。
  • 1.1(2026-08-04,未提升版本号):信封版本闸门本身 —— v 现在会被读取并强制执行(§2),引入了 requires, 每个键都带上了声明的忽略影响,整份登记表通过 dosi info、 GET /v1/capabilities 和 dosi_engine.DATUS_EXT 对外发布。 没有新增或改变任何键,所以版本保持在 1.1。不带 v 的载荷 —— 也就是 Datus Agent 今天输出的那种 —— 不受影响。
  • 1.2(2026-08-06):窗口指标 —— D-WINDOW(指标的 window, 已实现:MetricIr.window → 规划器的 WindowStage 投影根; pop/rolling/cumulative 语法糖 + 通用的 offset/frame 形态, 带扫描扩展 + 窗口后裁剪的累计重置;忽略影响为 documented —— 指标退回到它朴素的基础聚合;规范见 window-extension.md)。 把 13 个 baisheng 的派生时间用例从 Deferred 提升为 Supported(55/2)。 最初只在 DuckDB 上执行;全方言启用(按方言的偏移移位下降、 全方言快照、真机语料验证、datus_window 语料场景)作为后续落地。 族内新增(同一小版本,在早于它们的引擎上会 fail-closed): 放宽了混合族/混合移位/混合的带起始重置, 以及有限窗框上的 require_full_window(2026-08-06)—— 见 window-extension.md#restrictions。
  • 1.3(2026-08-09):窗口族与函数 —— 在族内扩展 window 键, 没有新的登记表行(capabilities().history 仍然停在 1.2; 这个版本号标示的是载荷方言)。新增内容:rank 族 (在指标值之上的 row_number / rank / dense_rank / ntile / percent_rank / cume_dist)、value 族(first_value / last_value / nth_value)、 偏移的 direction: "forward"(LEAD —— 下一期参照)、窗框的 order / partition / units / second 修饰符 (按值排序的窗框和 RANGE 窗框、分区模式、双参数输入), 以及登记表层级 W2(stddev_pop/samp、var_pop/samp)
  • W3(covar_pop/samp、corr)。每一项新增在 1.2 引擎上都会 fail-closed: 未知的顶层族键会落进"恰好一个族"的错误,未知的族内键会撞上 deny_unknown_fields,未知的函数名会在枚举解析时失败 —— 而且声明 "v": "1.3" 本身还会触发版本闸门。规范见 window-extension.md(各族章节 + 函数登记表)。
  • 1.4(2026-08-12):派生指标与度量命名 —— D-DERIVE M1 (derive 键,已实现:filter + compose 两族、带成环检测的 resolve_metric_refs 阶段、表达式等价性闸门、编译期一致维度集, 以及在每个宿主面上暴露的 derive 元数据;reagg / 跨数据集的 filter 谓词 / window.base 属保留形态,会以 not_implemented 拒绝并点名各自的里程碑 —— 注意 window.base 此前会落进"未知键静默忽略"那一档,现在改为 fail closed), 以及 D-MEASURE(measure 键,已实现:显式度量命名, 按签名去重和冲突检查保持不变 —— 这是对非 ASCII stem 冲突的修复, 也是第一个 silent 影响级别的键:请把它列进 requires)。 M2(2026-08-19,族内 —— 没有新键,也不提升版本号):window.base 从保留形态转为可用(窗口作用在叶子指标/filter 指标之上),compose 接受 窗口成员(阶段层下降:算术作用在窗口输出之上)和 filter 成员(展平层)。 用到这些形态的 1.4 载荷在 M2 引擎上能编译,在 M1 引擎上则 fail closed (not_implemented 或直接拒绝)—— 绝不会被静默误读。 嵌套 compose(族内 —— 没有新键,也不提升版本号):compose 的成员可以 是另一个 compose,在编译期内联展开。老引擎遇到这个形态还是报原来那条 invalid_datus_extension,同样是 fail closed。指标仍然留在展平层, derive_members 变成传递展开后的叶子列表、系数逐层折叠; 树里任何位置有窗口,这个组合依旧是 not_implemented。
  • 1.6(2026-09-02):时间存储格式 —— D-FORMAT(字段的 time 块, 已实现:FieldIr.time_format、分组侧的 SqlBackend::parse_time、 过滤侧在 Rust 里完成的字面量折叠,粒度从布局推断)。time.granularity 与扁平的 time_granularity 成为地位相同的两种写法 —— 不废弃任何一种, 但两者不允许矛盾。这是第二个 silent 影响级别的键:请把它列进 requires, 这样缺少它的 datus 模式引擎会在版本闸门处失败(基础模式不读 requires,见 §2)。 丢弃它的消费者不会响亮地失败;它会假定这是一个原生日期列, 而对按月或按年存储的列,结果是一个空结果集且没有任何诊断。 配置方法与限制见 D-FORMAT。范围:字符串与整型布局, 外加原生时间类型;epoch_seconds / epoch_millis 暂缓, 因为 from_unixtime 会读会话时区,那套语义值得单独一轮。

现有键扩展(2026-09-08,版本仍为 1.6):time.format 支持连续、有序、定宽的时间部分及任意固定文本。 过滤仍将日期边界转换为存储编码常量;分组按位置提取时间部分,组成标准日期时间后 交给各数仓解析。原有格式保持兼容,旧版 Datus 引擎会拒绝新增格式,不会静默误解。 - 1.7(2026-09-11):字段角色 —— D-DIM(字段的 is_dimension, 已实现:FieldIr.is_dimension,由 list_dimensions 与归因的候选清单读取)。 模型终于能说清一列是度量、标识、自由文本还是 PII,从而不再被当作分组维度推荐。 它只是推荐信号:显式写出的 group_by 照常生效,编译出的 SQL 一个字节都不变, 因此影响级别是 documented,也不需要写进 requires。不写这个键时,引擎只依据 模型结构推断 —— 键、关系列、被数值聚合的列 —— 绝不探测数仓。见 D-DIM。 - 1.8(2026-09-16):事实表之间的一致性关系 —— D-CONFORM(Relationship 上的 cardinality 键,已实现:RelationshipIr.kind、连接图里的一致性类、 group-by/过滤/时间范围列与 metric_time 逐分支解析到各事实表自己的拼写、 按输出名合并)。many_to_many 边永远不 join;只有配对列是共享的。 这是第三个 silent 影响级别的键:请把它列进 requires —— 没有它,这条边就是 many→one join,对重复敏感的聚合会被扇出。设计见 ../design/d-conformed-edges.md; fixture 见 fixtures/datus_conform/。 - 1.9(2026-09-16):一张排名记分卡 —— 与 1.3 一样是 window 键的族内增长, 因此没有新的登记表行。同一个形态带出四项新增。

frame.scope: "partition" 把帧声明成整个分区, 而不是相对当前行的偏移,编译出 <函数>(…) OVER (PARTITION BY …): 既没有 ORDER BY,也没有帧子句。它就是排名旁边的第二个窗口函数 —— 「我是在多少行里排这个名次」—— 打分卡需要它,而现有的键组合不出来: count 配 preceding: "unbounded" 是累计行数,units: "range" 也只到当前行所在的并列组为止。scope 与 preceding、reset、 require_full_window、units、order 互斥,后面这些只描述有序帧, 写了就报错而不是忽略。

window.base 同时放宽:比率和 compose 现在都能当作窗口读取的序列, 于是「给某个率排名」「看某个率的环比」写得出来了。跨事实 compose 的序列要到分支 合并之后才成为一个值,所以窗口阶段落在合并之上 —— 各分支分别聚合,它们的 CTE 与阶段的基座并排。这类基座没有共享的时间轴(每张事实表各带各的), 所以需要时间轴的那些族会以一致性为由拒绝。

在 1.8 引擎上双重 fail closed:FrameSpec 和 OrderSpec 都带 deny_unknown_fields,而载荷里的 "v": "1.9" 也会触发版本闸。 见 window-extension.md。

嵌套窗口与 NULL 位置把这个形态补齐。order.nulls: "first" | "last" 钉住窗口排序里 NULL 落在哪一端 —— 不写的话各数仓按自己的默认来,而它们并不一致 (DuckDB 与 PostgreSQL 升序排最后,MySQL 一系与 StarRocks 排最前), 基座是比率之后这就从理论风险变成了常态。生成器只在与目标方言默认不一致时才写出该子句; 方言根本没有这个子句时(MySQL、TiDB),位置声明降为一个打头的 CASE WHEN … IS NULL 排序键 —— 所以渲染随方言变、含义不变。而 window.base 比「比率」更进一步:它接受「建在另一个窗口输出之上的 compose」,于是第二个窗口能读第一个窗口的输出。每一层编译成多一个 SQL 阶段; 层数上限为 3,第 2 层起必须显式声明 partition。要紧的护栏是跨层的而不是层内的: 输出的 trim 始终留在最外层之后,而任何一层的分区全局指标都不能与任何一层会扩大 扫描范围的指标共处一条查询。另外还放宽了:阶段层的 compose 可以有 compose 成员(它保持为成员而不被内联 —— 在那一层每个成员都是一条独立序列)。 - 1.10(2026-09-23):细节层次 —— D-LOD,一套语法、两个载体:Field 上的 lod 键,以及 Metric 上现有 derive 键的 lod 家族(已实现)。

写在字段上,fixed(base, dims…) 让一个「按实体聚合」的值可以被普通查询分组、 过滤、再聚合(FieldIr.lod → 计划里的 LodRollup 阶段;D-FORMAT 编码的汇总键 保留存储原值,只在外层解析一次)。视图需要的超出汇总本身时 —— 汇总键之外的维度、 读取该值的行级字段、多个键族 —— 走广播形态(LodBroadcast):明细行与每个键族 各一张派生表连接;对钉住值做聚合时,每个键在每组里只算一次。不写键就是整表 范围的 FIXED,广播到每一行。

写在指标上,include 按查询自身的 group-by 加上声明的维度分组内层,再用声明的 外层聚合收回来;粒度与 D-FORMAT 解析都在内层完成。exclude 把同一张事实表更粗 的一次聚合挂到视图的每一行上 —— 按共有的维度 LEFT JOIN,没有共有维度就 CROSS JOIN —— 是重新计算而不是开窗,所以 COUNT(DISTINCT) 基座也是对的。fixed 是字段那份声明挂在指标上,好让它挂到视图上:在维度筛选之前算出的整表或按键 总计,也就是 Tableau 的 {SUM([Sales])} 作分母时的含义,exclude() 表达不了 (见上文)。一致性配对的两端若是两张事实表各自的 fixed 字段,就把两者对齐成同一根轴:每张事实表各自汇总,分支再合并,两张事实表从不 连接。

筛选位置沿用 Tableau 的运算次序。where 是维度筛选 —— 只点名汇总键时作用在 固定汇总的输出上,否则作用在明细行上,从不进入汇总内部;time_range 是上下文 筛选,约束每一个汇总。只读 D-LOD 值的行级字段按键各聚合一次。

基础模式丢弃 D-LOD 字段(aggregate_field_dropped),对 D-LOD 指标则 fail closed:它的 expression 就是声明所描述的嵌套聚合(nested_aggregate), 而不是只算其中一层。设计、用例与 Tableau 对照见 ../design/d-lod.md;fixture 见 fixtures/lod/、fixtures/tableau_official_*/。

尚未实现(路线图)

排队中的这些 RFC 全部是增量的,所以每一个都以一次小版本提升落地, 带上自己的登记表行。顺序可能变化;每一条都独占一个小版本。

小版本 RFC 键 载体 影响 需要 requires?
≥1.11 rfc-semi-additive-metrics §4 semi_additive Metric silent —— 期末余额会被跨快照加总 是
≥1.11 rfc-minimal-declarations §3 D-DISTINCT-STATE distinct_state Field / Dataset silent —— 一个物化的 bitmap 列会被当作普通列聚合 是
≥1.7 rfc-derived-metrics M3-M4 derive 的 reagg 与跨数据集 filter(window.base 与 compose 的窗口/filter 成员已在 1.4/M2 以族内方式发布) Metric degraded/错误 —— 保留形态今天就以 not_implemented 拒绝,绝不静默计算 否
— window-extension.md 的 rank/share/streak 族、W2+ 函数 将来的 window 形态(族内新增,在旧引擎上 fail-closed) Metric degraded 否

已经发布的 silent 键有三个:1.4 的 measure(D-MEASURE)、1.6 的 time(D-FORMAT)和 1.8 的 cardinality(D-CONFORM)。如果你在一个会输出 silent 影响级别键的 Datus Agent 上编写模型,请留意它产生的 requires 列表: 正是它阻止了更旧的引擎悄悄返回错误数字,而这之所以行得通, 是因为 requires 本身在 1.1 就已发布 —— 早于第一个需要它的键。