mcpField
S
seeded-determinism-and-golden-vectors

seeded-determinism-and-golden-vectors

github.com/zakariaf开发63139 次安装5 周前更新
代码仓库

关于 seeded-determinism-and-golden-vectors

用于让生成结果由显式键值决定、并在不同运行和平台间保持可复现的开发规范,涵盖带种子生成、生成器版本管理,以及由独立实现推导且仅由 CI 验证的黄金向量。

主要功能

  1. 01注入键值而非读取时钟
  2. 02通过明确的哈希和混合步骤派生种子
  3. 03使用自有且稳定的伪随机数生成器
  4. 04为生成器版本化并保留旧行为
  5. 05在黄金向量表中固定指纹
  6. 06仅在本地更新向量,由 CI 验证

适用场景

  1. 01构建可重复的每日内容或选择结果
  2. 02在版本发布间保持生成结果稳定
  3. 03使用固定向量验证带种子算法
  4. 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.dart
seeded_generator.dart
references/
golden-vectors.md
seed-derivation.md
scripts/
check-determinism-bans.sh

详细介绍

带种子的确定性与黄金向量

某些输出必须是键的纯函数:每位用户都会与朋友比较的每日抽选结果、程序生成的布局、他人可以验证的可分享结果。无论使用什么设备、身处哪个时区,或运行明年发布的平台版本,都必须得到完全相同的结果,而且无需服务器仲裁。这比“测试通过”要求更高,需要专门的规范,以及一份冻结的判定基准来确保这一点。

testing-strategy 负责纯逻辑的测试方法(使用独立判定基准进行带种子的模糊测试、注入 Clock)。此技能负责确保输出可复现的要素,以及固定这些要素的已提交向量表。

黄金向量不是黄金图像。向量是纯生成器的数据指纹,由 dart test 在无需 widget binding 的情况下断言;渲染 UI 的 PNG 参考图属于 widget-golden-and-a11y-testing,而重新批准这些图则是 run-goldens-rebaseline 中的独立流程。两者只有“黄金”一词和一条共同规则:门禁绝不能重新生成它要检查的内容。

不容妥协的规则

  1. 注入键,绝不读取键。 生成器将键作为参数接收——日期字符串、ID、序号。任何从它可调用的代码都不能调用 DateTime.now()。墙上时钟时间只通过组合根注入的 Clock 进入应用一次(该规则由 value-objects-money-and-units 负责)。原因:读取时钟的生成器无法测试,也无法建立黄金基准,而且无法根据错误报告复现其输出。

  2. 日历日期键是民用日期,不是瞬间。 “7 月 4 日”是仅包含日期的值——应按此存储和传递,例如 ISO YYYY-MM-DD 字符串或日序号,绝不能使用 DateTime 瞬间。事件时间戳仍是 UTC 瞬间;日期标识根本不是一个时间点。原因:使用瞬间会使每次读取都依赖时区,导致两台设备对哪些内容属于“今天的”意见不一。

  3. 统一日界定义,并在所有比较中使用。 如果内容基于 UTC 日期生成,而连续打卡、截止日期或“已看过”检查使用本地午夜,那么每天临近界限时都会有数小时的差异。明确选择本地时间或 UTC,将定义集中命名,并确保所有比较都使用这一统一定义。

  4. 熵只能有一个来源:你派生出的种子。 生成路径中任何位置都不得使用环境随机数 Random()/Random.secure()——哪怕是用于“无关紧要”的打破平局或洗牌也不行。每次抽取都必须显式调用传入的带种子生成器,且抽取顺序属于契约的一部分:调整两次抽取的顺序会改变此后所有输出。

  5. 使用指定的哈希和混合步骤派生种子。 对键字符串进行哈希(FNV-1a-64 体积小、规范明确且易于重新实现),异或一个按功能和模式区分的盐值,然后在抽取前将结果传入雪崩混合步骤(SplitMix64)。原因:连续的键只相差一个字节;如果没有混合步骤,这种相关性会让连续几天的输出看起来明显相似——这是用户最先注意到的错误。参见 references/seed-derivation.md。

  6. 自行实现 PRNG,不要依赖未指定的 PRNG。 dart:math 的 Random(seed) 在同一运行时中可复现,但其算法属于实现细节,SDK 版本和平台之间没有文档规定的稳定性保证。用户会拿来相互比较的输出不能依赖它。SplitMix64 只有八行代码;自行实现,并依据已发布的向量进行测试,然后将其固定下来。

  7. 按功能和模式设置盐值。 两个使用相同日期作为种子的功能不能生成相同序列,否则输出之间会产生明显相关性。每个功能使用一个盐值常量,发布后永久固定。

  8. 重新生成派生内容;绝不存储。 持久化 key、generatorVersion 和 outcome(用户做了什么)。内容本身是 key → generator → content,可以永远重新构建。 原因:存储内容会造成双重事实来源,并使历史记录无法迁移。派生的 聚合数据 同样在读取时重新计算——persistence-drift 负责这项规则。

  9. 已发布的生成器不可更改。 用户一旦看到某个 key 对应的输出,该输出就 是这个 key 的含义。改进应以新版本和切换 key发布,同时将旧版本保留在二进制文件中, 以便重现此前的所有内容。绝不要就地修改已发布的生成器:这会悄然改写过去, 而现在每条存储的 outcome 都指向已不复存在的内容。

  10. 用黄金向量固定契约。 提交一张包含 (key, params) → fingerprint 行的表, 并精确断言 UI 显示的所有派生指标。存储 fingerprint(规范序列化的稳定哈希), 而非整个产物,这样表格更小,实际变更也会以一行差异呈现,让审查者一目了然。 整数和 fingerprint 使用 ==;仅对经过刻意校准的浮点数使用容差。

  11. 每个预期值都必须由独立的预言机计算。 如果测试夹具由被测实现生成,就等于将该实现的缺陷奉为准则——包括你即将引入的缺陷。向量行应从独立、简单但速度较慢的实现中推导,或手动计算。 (testing-strategy 负责模糊测试循环中的独立预言机原则;这里是其固定表格部分。)

  12. 重新生成必须明确执行、经过审查,且绝不自动进行。 只有通过刻意执行的本地命令刷新向量,并由人工在拉取请求中查看其差异。CI 只验证,绝不认可——会重新生成自身预期值的流水线形同虚设,和在 CI 中运行 --update-goldens 一样,什么也检查不了。

  13. 先测量,再标注——绝不把它宣传成证明。 如果生成的产物带有派生标签(难度、质量等级、尺寸类别),就应计算指标,并通过其上的固定阈值表确定标签。断言映射具有单调性且阈值未变;不要把校准描述为已证明,也绝不重新标注已发布的内容。

推导种子

/// 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,了解生成器返回的不可变值类型。

参考资料

评论

支持 Markdown