关于 seeded-determinism-and-golden-vectors
用于让生成结果由显式键值决定、并在不同运行和平台间保持可复现的开发规范,涵盖带种子生成、生成器版本管理,以及由独立实现推导且仅由 CI 验证的黄金向量。
主要功能
- 01注入键值而非读取时钟
- 02通过明确的哈希和混合步骤派生种子
- 03使用自有且稳定的伪随机数生成器
- 04为生成器版本化并保留旧行为
- 05在黄金向量表中固定指纹
- 06仅在本地更新向量,由 CI 验证
适用场景
- 01构建可重复的每日内容或选择结果
- 02在版本发布间保持生成结果稳定
- 03使用固定向量验证带种子算法
- 04审查有意进行的生成器变更
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill seeded-determinism-and-golden-vectors安装后在 Claude Code 中输入/seeded-determinism-and-golden-vectors确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
examples/golden_vectors_test.dartseeded_generator.dartreferences/golden-vectors.mdseed-derivation.mdscripts/check-determinism-bans.sh详细介绍
带种子的确定性与黄金向量
某些输出必须是键的纯函数:每位用户都会与朋友比较的每日抽选结果、程序生成的布局、他人可以验证的可分享结果。无论使用什么设备、身处哪个时区,或运行明年发布的平台版本,都必须得到完全相同的结果,而且无需服务器仲裁。这比“测试通过”要求更高,需要专门的规范,以及一份冻结的判定基准来确保这一点。
testing-strategy 负责纯逻辑的测试方法(使用独立判定基准进行带种子的模糊测试、注入 Clock)。此技能负责确保输出可复现的要素,以及固定这些要素的已提交向量表。
黄金向量不是黄金图像。向量是纯生成器的数据指纹,由 dart test 在无需 widget binding 的情况下断言;渲染 UI 的 PNG 参考图属于 widget-golden-and-a11y-testing,而重新批准这些图则是 run-goldens-rebaseline 中的独立流程。两者只有“黄金”一词和一条共同规则:门禁绝不能重新生成它要检查的内容。
不容妥协的规则
-
注入键,绝不读取键。 生成器将键作为参数接收——日期字符串、ID、序号。任何从它可调用的代码都不能调用
DateTime.now()。墙上时钟时间只通过组合根注入的Clock进入应用一次(该规则由value-objects-money-and-units负责)。原因:读取时钟的生成器无法测试,也无法建立黄金基准,而且无法根据错误报告复现其输出。 -
日历日期键是民用日期,不是瞬间。 “7 月 4 日”是仅包含日期的值——应按此存储和传递,例如 ISO
YYYY-MM-DD字符串或日序号,绝不能使用DateTime瞬间。事件时间戳仍是 UTC 瞬间;日期标识根本不是一个时间点。原因:使用瞬间会使每次读取都依赖时区,导致两台设备对哪些内容属于“今天的”意见不一。 -
统一日界定义,并在所有比较中使用。 如果内容基于 UTC 日期生成,而连续打卡、截止日期或“已看过”检查使用本地午夜,那么每天临近界限时都会有数小时的差异。明确选择本地时间或 UTC,将定义集中命名,并确保所有比较都使用这一统一定义。
-
熵只能有一个来源:你派生出的种子。 生成路径中任何位置都不得使用环境随机数
Random()/Random.secure()——哪怕是用于“无关紧要”的打破平局或洗牌也不行。每次抽取都必须显式调用传入的带种子生成器,且抽取顺序属于契约的一部分:调整两次抽取的顺序会改变此后所有输出。 -
使用指定的哈希和混合步骤派生种子。 对键字符串进行哈希(FNV-1a-64 体积小、规范明确且易于重新实现),异或一个按功能和模式区分的盐值,然后在抽取前将结果传入雪崩混合步骤(SplitMix64)。原因:连续的键只相差一个字节;如果没有混合步骤,这种相关性会让连续几天的输出看起来明显相似——这是用户最先注意到的错误。参见
references/seed-derivation.md。 -
自行实现 PRNG,不要依赖未指定的 PRNG。
dart:math的Random(seed)在同一运行时中可复现,但其算法属于实现细节,SDK 版本和平台之间没有文档规定的稳定性保证。用户会拿来相互比较的输出不能依赖它。SplitMix64 只有八行代码;自行实现,并依据已发布的向量进行测试,然后将其固定下来。 -
按功能和模式设置盐值。 两个使用相同日期作为种子的功能不能生成相同序列,否则输出之间会产生明显相关性。每个功能使用一个盐值常量,发布后永久固定。
-
重新生成派生内容;绝不存储。 持久化 key、generatorVersion 和 outcome(用户做了什么)。内容本身是
key → generator → content,可以永远重新构建。 原因:存储内容会造成双重事实来源,并使历史记录无法迁移。派生的 聚合数据 同样在读取时重新计算——persistence-drift负责这项规则。 -
已发布的生成器不可更改。 用户一旦看到某个 key 对应的输出,该输出就 是这个 key 的含义。改进应以新版本和切换 key发布,同时将旧版本保留在二进制文件中, 以便重现此前的所有内容。绝不要就地修改已发布的生成器:这会悄然改写过去, 而现在每条存储的 outcome 都指向已不复存在的内容。
-
用黄金向量固定契约。 提交一张包含
(key, params) → fingerprint行的表, 并精确断言 UI 显示的所有派生指标。存储 fingerprint(规范序列化的稳定哈希), 而非整个产物,这样表格更小,实际变更也会以一行差异呈现,让审查者一目了然。 整数和 fingerprint 使用==;仅对经过刻意校准的浮点数使用容差。 -
每个预期值都必须由独立的预言机计算。 如果测试夹具由被测实现生成,就等于将该实现的缺陷奉为准则——包括你即将引入的缺陷。向量行应从独立、简单但速度较慢的实现中推导,或手动计算。 (
testing-strategy负责模糊测试循环中的独立预言机原则;这里是其固定表格部分。) -
重新生成必须明确执行、经过审查,且绝不自动进行。 只有通过刻意执行的本地命令刷新向量,并由人工在拉取请求中查看其差异。CI 只验证,绝不认可——会重新生成自身预期值的流水线形同虚设,和在 CI 中运行
--update-goldens一样,什么也检查不了。 -
先测量,再标注——绝不把它宣传成证明。 如果生成的产物带有派生标签(难度、质量等级、尺寸类别),就应计算指标,并通过其上的固定阈值表确定标签。断言映射具有单调性且阈值未变;不要把校准描述为已证明,也绝不重新标注已发布的内容。
推导种子
/// FNV-1a-64 over the key's UTF-8 bytes. Small, specified, trivially portable.
int fnv1a64(String key) {
var hash = 0xcbf29ce484222325;
for (final byte in utf8.encode(key)) {
hash = (hash ^ byte) * 0x100000001b3; // 64-bit wrap-around is intended
}
return hash;
}
/// The one entropy source. SplitMix64 — eight lines, published test vectors,
/// and yours forever, unlike an SDK-internal generator.
class SeededRng {
SeededRng(this._state);
int _state;
int nextInt64() {
_state += 0x9e3779b97f4a7c15;
var z = _state;
z = (z ^ (z >>> 30)) * 0xbf58476d1ce4e5b9;
z = (z ^ (z >>> 27)) * 0x94d049bb133111eb;
return z ^ (z >>> 31);
}
/// Uniform in [0, max). Draw order is part of the contract — never reorder.
int nextInt(int max) => nextInt64().toUnsigned(63) % max;
}
/// The only place the calendar meets a generator. `isoDay` is a civil date
/// ("2026-07-04") supplied by the caller — the generator never reads a clock.
SeededRng seedFor(String isoDay, {required int featureSalt, int modeSalt = 0}) =>
SeededRng(fnv1a64(isoDay) ^ featureSalt ^ modeSalt);
⚠️ 如果应用也要构建为 Web 版本,64 位整数运算就不会如此工作——Dart 整数在这类目标中会编译为 JavaScript 双精度浮点数。要么将这段代码限制在原生目标上,要么使用 32 位半字实现哈希和 PRNG。请参阅 references/seed-derivation.md。
黄金向量表
// One row per pinned key. Computed ONCE from an independent oracle (rule 11) and
// refreshed only by `dart run tool/update_vectors.dart` (rule 12).
const dailyPickVectors = <DailyPickVector>[
DailyPickVector('2026-01-01', 0x9f3ac1e0, 12, 'year boundary'),
DailyPickVector('2026-07-04', 0x2b7708d4, 3, 'mid-year'),
DailyPickVector('2026-12-31', 0x84ce11a9, 7, 'year end'),
];
test('the generator reproduces every frozen vector', () {
for (final v in dailyPickVectors) {
final pick = generateDailyPick(v.isoDay);
expect(pick.fingerprint(), equals(v.fingerprint), reason: v.note);
expect(pick.items.length, equals(v.itemCount), reason: v.note);
}
});
覆盖可能掩盖缺陷的边界:支持的第一个和最后一个 key、一个闰日、一次月份/年份切换,以及至少一个历史 key,这样改写过去的回归就会明显失败。
反模式
- 在生成器或种子辅助函数中使用
DateTime.now()— 输出将不再可复现,也无法根据错误报告重新生成。 - 将
DateTime时刻用作日期标识 — 每次读取都会受到时区影响。 - 根据 UTC 日期生成内容,却使用本地午夜进行边界检查 — 一天中会有数小时出现每日偏差,而且这是最难从自己所在时区发现的 bug。
- 为“无伤大雅”的平局决胜使用环境
Random()— 一次调用,整个契约就不复存在。 - 在重构期间调整抽取顺序 — 交换之后的所有输出都会改变;抽取顺序是一项契约。
- 直接用原始哈希作为种子,不经过混合步骤 — 连续日期的结果会明显相似。
- 两个特性共用一个盐值 — 它们的输出会相关。
- 对用户可见的内容依赖
Random(seed)、identityHashCode或哈希集合的迭代顺序 — 没有文档保证其稳定性;抽取前应按明确的键排序。 - 存储生成的产物 — 持久化
(key, generatorVersion, outcome),并重新生成。 - 原地修改已发布的生成器 — 这会改写过去的键所代表的含义;应通过带版本的切换发布。
- 使用被测实现生成向量 — 这会把它的 bug 固化下来。
- 在向量表中存储完整产物 — 差异无法审查;应使用指纹。
- 在 CI 中自动重新生成向量 — 这样门禁就什么也验证不了。
- 将校准标签称为“已证明” — 指标是测量出来的;标签是冻结的阈值。
完成定义
- 生成器将键作为参数;其调用路径上的任何内容都不读取时钟或环境随机数。
- 日期键是民用日期;所有涉及该日期的比较都使用同一种日期定义。
- 种子经过混合步骤后,由仓库自有的 PRNG 使用,公式为
hash(key) ^ featureSalt ^ modeSalt。 - 盐值是冻结的常量,每个特性/模式各用一个。
- 内容根据
(key, generatorVersion)重新生成;只持久化键、版本和结果。 - 每个结果都存储
generatorVersion;生成器变更应作为新版本发布并设定切换点,且之前的版本仍能重现历史结果。 - 已提交的向量表固定了指纹以及任何用户可见的派生指标,并覆盖范围边界、闰日和一个历史键。
- 每个预期值都来自独立预言机,而非被测实现。
- 重新生成通过本地命令执行并经过审查;CI 只负责验证。
- 确定性测试断言相同键的两次运行逐字节一致,因此任何重新引入的环境随机性都会导致构建失败。
-
scripts/check-determinism-bans.sh在生成器的包/目录范围内通过。 - 任何派生标签都使用冻结的阈值表,并有单调性测试;已发布的内容绝不重新标注。
相关技能
- 请参阅
testing-strategy,了解带种子的模糊测试循环、独立预言机原则和注入Clock— 本技能的测试也采用这种形式。 - 请参阅
value-objects-money-and-units,了解规范存储和注入Clock的接缝。 - 请参阅
persistence-drift,了解存储键/版本/结果以及重新计算派生聚合值。 - 请参阅
project-structure-and-packages,了解如何将生成器放在不依赖 Flutter 的包中,以便通过机制强制执行这些禁令。 - 请参阅
widget-golden-and-a11y-testing,了解渲染后 UI 的参考图像;请参阅run-goldens-rebaseline,了解重新批准这些图像的流程 — 这与这些向量属于不同层级。 - 请参阅
ci-pipeline-and-gates,了解如何将向量检查和禁令搜索作为门禁运行。 - 请参阅
dart3-idioms-and-coding-standards,了解生成器返回的不可变值类型。
参考资料
- Dart API —
dart:mathRandom(带种子的构造):https://api.dart.dev/stable/dart-math/Random-class.html - Dart — Web 上的数字(
int作为 JS double、>>>、溢出):https://dart.dev/guides/language/numbers - Dart API —
identityHashCode:https://api.dart.dev/stable/dart-core/identityHashCode.html - FNV 哈希规范:https://datatracker.ietf.org/doc/html/draft-eastlake-fnv
- SplitMix64(Steele、Lea、Flood——“快速可拆分伪随机数生成器”):https://doi.org/10.1145/2660193.2660195
package:test:https://pub.dev/packages/test