abelien-harness 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +9 -3
  2. package/package.json +1 -1
  3. package/skills/abelien-3dgen/SKILL.md +4 -2
  4. package/skills/abelien-3dgen/providers/tripo.json +64 -1
  5. package/skills/abelien-3dgen/references/engineering-lowpoly-qualification.md +20 -0
  6. package/skills/abelien-3dgen/references/host-api.md +128 -0
  7. package/skills/abelien-3dgen/references/provider-profile.md +4 -0
  8. package/skills/abelien-appearance/SKILL.md +2 -0
  9. package/skills/abelien-appearance/references/local-component-edit.md +31 -0
  10. package/skills/abelien-clarify/SKILL.md +8 -3
  11. package/skills/abelien-clarify/references/examples.md +9 -0
  12. package/skills/abelien-clarify/scripts/check_result.py +53 -1
  13. package/skills/abelien-clarify/scripts/test_check_result.py +40 -0
  14. package/skills/abelien-guide/SKILL.md +2 -0
  15. package/skills/abelien-guide/references/shared-response-rules.md +2 -2
  16. package/skills/abelien-hardware/SKILL.md +5 -0
  17. package/skills/abelien-hardware/domain.json +2 -2
  18. package/skills/abelien-hardware/references/search-index-format.md +31 -2
  19. package/skills/abelien-hardware/references/search-index-release.md +9 -0
  20. package/skills/abelien-hardware/references/selection-service-contract.md +3 -0
  21. package/skills/abelien-hardware/scripts/build-search-index.mjs +50 -6
  22. package/skills/abelien-hardware/scripts/dimensions.mjs +184 -0
  23. package/skills/abelien-hardware/scripts/finalize-selection.mjs +11 -0
  24. package/skills/abelien-hardware/scripts/publish-search-index.mjs +2 -1
  25. package/skills/abelien-hardware/scripts/search-index-release.mjs +15 -2
  26. package/skills/abelien-hardware/scripts/tos-wiki-search.mjs +38 -20
  27. package/skills/abelien-hardware-selection/domain.json +2 -2
  28. package/skills/abelien-hardware-selection/references/selection-service-contract.md +12 -1
  29. package/skills/abelien-hardware-selection/scripts/select.mjs +9 -0
  30. package/skills/abelien-harness/references/workflow-spec.md +15 -2
  31. package/skills/abelien-harness/scripts/generate3d-host.mjs +398 -0
  32. package/skills/abelien-harness/scripts/generate3d.mjs +54 -15
  33. package/skills/abelien-harness/scripts/receipt.mjs +8 -2
  34. package/skills/abelien-harness/scripts/studio.mjs +18 -10
  35. package/skills/abelien-structure/SKILL.md +3 -1
  36. package/skills/abelien-structure/references/build123d-execution.md +1 -1
  37. package/skills/abelien-structure/scripts/run_stage.py +8 -1
  38. package/skills/abelien-workspace/SKILL.md +2 -0
  39. package/skills/abelien-workspace/domain.json +1 -1
  40. package/skills/abelien-workspace/workflows/definitions/a6e8254e1839d94530ae243cba779e423953b7169cbce338f0503d748d44800b.json +175 -0
  41. package/skills/abelien-workspace/workflows/definitions/b37f3b47e1e8bd1208859de57456152b149416e21949297b22bba937c51a5670.json +149 -0
  42. package/skills/abelien-workspace/workflows/definitions/b780bf280d61596b43eb6dbc6a417cf8df1ec9a7ae79a6de10f048da9ed58729.json +229 -0
  43. package/skills/abelien-workspace/workflows/user-journey-hardware-algorithm.json +1 -1
  44. package/skills/abelien-workspace/workflows/user-journey-hardware-candidates.json +1 -1
  45. package/skills/abelien-workspace/workflows/user-journey-hardware.json +1 -1
@@ -1,6 +1,6 @@
1
1
  # 硬件 Wiki 分类索引格式
2
2
 
3
- 本文件只在发布、校验或迁移 TOS 硬件 Wiki 搜索索引时读取。完整 Wiki 仍是型号事实与选型证据源;索引只负责在下载正文前缩小候选,不替代 Wiki。
3
+ 本文件只在发布、校验或迁移 TOS 硬件 Wiki 搜索索引时读取。完整 Wiki 是型号选型证据;尺寸仅以数据库导出快照为编辑源,生成 Wiki 和索引须逐字段同源。索引在 Top-K 前执行保守尺寸筛选,不替代 Wiki。
4
4
 
5
5
  ## 对象布局
6
6
 
@@ -28,7 +28,7 @@ npm run build:hardware-search-index -- \
28
28
  --output /absolute/path/to/tos-search-index
29
29
  ```
30
30
 
31
- 构建器逐页验证 `record_id`、三级 `category_path` 和 Wiki SHA-256与 Manifest 一致,随后输出 `_catalog.json` 和 `indexes/releases/<release_id>/**/*.json.gz`。输出目录只包含搜索索引,不复制 Wiki 正文。
31
+ 构建器逐页验证 `record_id`、三级 `category_path` 和 Wiki SHA-256与 Manifest 一致,随后输出 `_catalog.json` 和 `indexes/releases/<release_id>/**/*.json.gz`。不带尺寸快照时输出仅有搜索索引;带 `--component-snapshot` 时另生成 Wiki 和 Manifest,见下节。
32
32
 
33
33
  使用[发布命令](search-index-release.md)先上传并回读全部不可变分片,最后以条件 PUT 替换 `_catalog.json`。这样搜索端不会看到指向尚未上传分片的新目录,并发发布也不会静默覆盖。发布完成后自动验证目录记录总数、Manifest、分类数和每个分片 SHA-256,并通过正式 `hardware.search` 执行真实冒烟。
34
34
 
@@ -39,3 +39,32 @@ npm run build:hardware-search-index -- \
39
39
  默认缺少 `_catalog.json` 时返回 `WIKI_SEARCH_INDEX_MISSING`,不会悄悄下载整个分类。迁移期可显式设置 `TOS_ALLOW_LEGACY_MANIFEST_FALLBACK=true`;此时响应的 `retrieval_mode` 为 `legacy_manifest_full_scan`,并在 `unknowns[]` 写入 `SEARCH_INDEX_FALLBACK`。迁移开关不应作为长期生产配置。
40
40
 
41
41
  正常响应的 `retrieval_mode` 为 `category_index`。每个槽位同时返回 `available_documents`、`indexed_candidates` 和 `fetched_documents`,用于核对“索引看了多少、全文实际下载多少”。
42
+
43
+
44
+ ## 数据库尺寸快照 v1
45
+
46
+ 数据库导出格式为 `abelien.component-dimensions.v1`,不是可手填的 Wiki 事实。`records` 按 component_id 的 Unicode 编码顺序排序且不得重复。每项包含 component_id、revision、dimensions_mm(正有限三元组或 null)、dimension_basis(measured/datasheet/estimate/unknown)、dimension_geometry(solid_box/bounding_box/unknown),可含 evidence_ref。revision 是去掉 revision 的记录递归按键排序、无空格 UTF-8 JSON 的 SHA-256 十六进制;snapshot_id 同法哈希整个 records 数组。缺尺寸必须 null/unknown/unknown。占位、默认、未证明的库尺寸不能升级成 measured 或 datasheet;solid_box 只能来自明确形体证据,不从 AABB、名称或占位 CAD 推断。
47
+
48
+ ```bash
49
+ npm run build:hardware-search-index -- \
50
+ --source /absolute/path/to/硬件wiki \
51
+ --component-snapshot /absolute/path/to/component-dimensions.json \
52
+ --output /absolute/path/to/generated-release
53
+ ```
54
+
55
+ source 与 output 须是互不嵌套的目录。每个 Wiki record_id 必须命中数据库导出,缺行失败;多余数据库行允许存在。构建器保留原人工 frontmatter 和正文,仅在 output/wiki 生成尺寸字段及来源,原文件不改。输出路径为 `wiki/components/<snapshot_id>/<content_sha>/<原相对路径>`,Manifest 与导出另存;catalog 指向不可变 `wiki/snapshots/<manifest_sha>/` 清单和数据库快照。再次构建已生成源时会核对 `_component-dimensions.json` 和全部尺寸字段;没有快照却手填尺寸、摘要不符或尺寸篡改均拒绝。
56
+
57
+ 新增 dimension_source/dimensions_mm 以单行严格 JSON 写入并解析(包括有限科学计数值);原有 capability_coverage 等 YAML inline map 沿用旧解析语义,不因尺寸扩展改写或拒绝。
58
+
59
+ 候选和 selected_models 携带 dimensions_mm、dimension_basis、dimension_geometry、dimension_source={component_id,revision,snapshot_id,evidence_ref?}、dimension_check={status,decision_required,reasons,method}。status 只表达粗筛装配结果:clear 为所有粗筛通过,risk 为至少一项失败但不构成证明,incompatible 为用户 hard 上限+measured/datasheet 尺寸下的证明,unknown 为无法判定;依据与上限权威仍看 dimension_basis 和 limit source,不另编码进 status。decision_required 仅在用户 hard 上限+measured/datasheet 尺寸且轴对齐装法全部失败时为 true。索引读入时每条 Wiki sha256 必须存在且合法;即使该行会被尺寸剔除,也不能缺 hash。实际抓取时验证正文 hash、身份、分类及全部尺寸元数据与索引相同。source_snapshot 包含 catalog 和实际分片内容摘要,不以 ETag 代替内容身份。
60
+
61
+ 旧 v1 索引缺内容 hash 必须重新构建,迁移开关也不绕过 hash;缺尺寸或缺 geometry 的旧条目仍可读,但 dimension_check=unknown,不获得 hard rejection 或 clear。新索引为 legacy 无尺寸记录显式输出 null/unknown。搜索保留所有 unknown 和 estimate 候选;`dimension_screen` 报 evaluated/rejected/retained/demoted 和逐个硬拒绝原因。按相关度排序后,risk 候选保留但不占 Top-K 名额,因此每个 risk 会多带入一个可能装得下的替补,总抓取数不超过 TOS_MAX_FULL_WIKI_FETCH_PER_SLOT;返回列表先列非 risk、后列 risk,各层内保持相关度顺序,unknown 不降级。`all_indexed_candidates_rejected` 仅指该槽位已评估分类索引,不能说所有硬件都不适用。
62
+
63
+ ## 尺寸判定与占用边界
64
+
65
+ `requirements.limits[].envelope` 支持 box.max_mm/min_mm 与 cylinder.max_diameter_mm/max_height_mm/min_diameter_mm/min_height_mm;保留同一条 text/id/source/strength。范围按最大外形减去两倍明确保证的 wall_mm.minimum(缺省 0)求最大腔体;estimate 不参与硬拒绝。所有尺寸有限正数,范围有序,无混合形状字段;最低壁厚必须留出正的最大腔体。较小范围端或估算壁厚不可行不扩大硬拒绝范围。
66
+
67
+ 只有 user+hard 边界和 measured/datasheet 组件可硬拒绝。solid_box 可用最小边、对角线、实体体积三项必要条件;任意 bounding_box 只可用最大支撑跨度超过容器最大弦长,不能用包围盒最小边、对角线或体积。六轴置换、圆柱 footprint 只是可装下粗筛,失败是 risk,未知形体是 unknown。clear 只表示单件粗筛,不代表多件装配、CAD 或工程通过。
68
+
69
+
70
+ 新域包 hardware 1.2.0 / selection 0.5.0 / workspace 1.3.0 使用此加法契约。已有持久结果只读原样;旧 workflow JSON 摘要不固定模块代码,因此不可用旧定义配新包宣称旧行为仍相同。宿主必须固定完整发布包,为尺寸筛选开新运行,不能原地改写旧 CAD 输入或已失败/未知副作用。
@@ -65,3 +65,12 @@ npm run smoke:hardware-search -- \
65
65
  构建器以源 Manifest SHA-256 作为 `release_id`,分片写入 `indexes/releases/<release_id>/...`。发布器先验证远端 Manifest,再以不可变语义上传并回读全部分片;只有全部 SHA、Schema、分类和记录数通过后,才用条件 PUT 替换 `_catalog.json`。并发发布导致 Catalog ETag 变化时显式失败,不覆盖别人的新版本。
66
66
 
67
67
  Catalog 激活失败只会留下未引用的不可变分片,不影响旧版本;搜索端始终从唯一 `_catalog.json` 读取一个完整 release。发布命令默认最后执行真实 `hardware.search`,确保 Catalog、分类索引和至少一篇 Wiki 正文能够按生产读取路径访问。`--skip-smoke` 只用于已有独立冒烟门禁的受控 CI,不能把单元测试当成生产连通性证明。
68
+
69
+
70
+ ## 尺寸快照发布
71
+
72
+ 数据库导出由 Web 仓库显式只读命令产生;Harness 不接收数据库凭据。尺寸发布使用同一命令的 `--component-snapshot /absolute/path/to/component-dimensions.json`,先运行 `--dry-run --output /absolute/path/to/review` 检查生成 Wiki、Manifest、导出和索引。确定发布环境后去掉 dry-run,才会执行远端写入。
73
+
74
+ 这条路径按 content-addressed key 上传生成 Wiki,再上传不可变导出和 Manifest、索引分片,最后条件激活 `_catalog.json`;每个对象冲突即失败,不覆盖旧 Wiki、旧清单或旧快照。生成路径从 built.generated_source 读取,不能把原始 source 目录的 Wiki 当成新的正文。无需 bootstrap-manifest,catalog 使用该 release 自己的版本化 Manifest。故障只留下未引用的不可变文件;本地测试的内存 TOS 只证明执行顺序和摘要,不证明线上发布成功。
75
+
76
+ v1 迁移:所有索引记录必须具备 Wiki 内容 SHA-256。旧无 hash 索引重建再发布;旧无尺寸/geometry 记录保持 unknown。dimensions 更新会改变源 revision、snapshot_id、生成 Wiki 内容 hash、Manifest 身份与分片摘要;旧固定产物保持原字节,不原地“补齐”过去的候选。
@@ -23,3 +23,6 @@ URL 来自 `ABELIEN_HARDWARE_REVISE_URL`。请求包含 `operation: "revise_exis
23
23
  ## Supplemental source binding
24
24
 
25
25
  For new journeys, `hardware.web-search` and `hardware.web-merge` follow [web-evidence-contract](web-evidence-contract.md). Proposal-only finalize consumes the merged recall; formal selection uses its persisted ID and revalidates original recall/evidence. Finalize now keeps only model assessment fields from the proposal and uses candidate facts for manufacturer/model/classification/source/geometry/CAD. No self-reported source or CAD claim can override an external candidate.
26
+
27
+
28
+ Dimension metadata is copied only from frozen candidates, with deterministic single-part screening shared by search/finalize/select. Name comes from the candidate title; `bom_item_id` is explicitly a selection-line identity, not a catalog BOM identity. Active envelope output may add advisory occupancy. The [local selection contract](../../abelien-hardware-selection/references/selection-service-contract.md#结构化尺寸与正式门禁) defines unknown/pending behavior and the [index contract](search-index-format.md#数据库尺寸快照-v1) defines database provenance. Finalize never upgrades its proposed result to formal verified authority.
@@ -5,6 +5,8 @@ import path from "node:path";
5
5
  import process from "node:process";
6
6
  import zlib from "node:zlib";
7
7
  import { fileURLToPath } from "node:url";
8
+ import { validateComponentSnapshot, fieldsFromSnapshotRecord, dimensionFields } from './dimensions.mjs';
9
+ import { digest } from './selection-request.mjs';
8
10
  import { parseHardwareWiki } from "./tos-wiki-search.mjs";
9
11
 
10
12
  const CATALOG_SCHEMA = "abelien.hardware-wiki-search-catalog.v1";
@@ -57,13 +59,49 @@ function compactRecord(entry, parsed) {
57
59
  functional_capabilities: meta.functional_capabilities ?? [],
58
60
  capability_coverage: meta.capability_coverage ?? [],
59
61
  selection_profile: meta.selection_profile,
62
+ ...dimensionFields(meta),
60
63
  search_text: searchText,
61
64
  };
62
65
  }
63
66
 
64
- export function buildSearchIndex({ sourceDir, outputDir, releaseId }) {
65
- const source = path.resolve(sourceDir);
67
+ function generatedSource(source, output, snapshotFile) {
68
+ if (source === output || source.startsWith(output + path.sep) || output.startsWith(source + path.sep)) throw fail("generated output and source must be separate directories");
69
+ const snapshot = validateComponentSnapshot(JSON.parse(fs.readFileSync(snapshotFile, "utf8")));
70
+ const records = new Map(snapshot.records.map(record => [record.component_id, record]));
71
+ const manifest = JSON.parse(fs.readFileSync(path.join(source, "_manifest.json"), "utf8"));
72
+ if (!Array.isArray(manifest)) throw fail("manifest must be an array");
73
+ const generated = path.join(output, "wiki");
74
+ const nextManifest = manifest.map(entry => {
75
+ const parsed = parseHardwareWiki(fs.readFileSync(safeSourceFile(source, entry.path), "utf8"));
76
+ if (parsed.meta.record_id !== entry.record_id) throw fail(`record_id mismatch for ${entry.path}`);
77
+ if (entry.sha256 && entry.sha256 !== sha256(parsed.content)) throw fail(`sha256 mismatch for ${entry.path}`);
78
+ const record = records.get(entry.record_id);
79
+ if (!record) throw fail(`component snapshot missing Wiki component: ${entry.record_id}`);
80
+ const fields = fieldsFromSnapshotRecord(record, snapshot.snapshot_id);
81
+ // Only replace generated top-level dimension keys; preserve every other frontmatter line and prose.
82
+ const lines = parsed.frontMatter.split("\n"), kept = [];
83
+ let skipping = false;
84
+ for (const line of lines) {
85
+ if (/^[^\s#][^:]*:/.test(line)) skipping = /^(dimensions_mm|dimension_basis|dimension_geometry|dimension_source):/.test(line);
86
+ if (!skipping) kept.push(line);
87
+ }
88
+ const content = `---\n${kept.join("\n")}\n${Object.entries(fields).map(([key, value]) => `${key}: ${JSON.stringify(value)}`).join("\n")}\n---\n${parsed.body}`;
89
+ const generatedPath = `wiki/components/${snapshot.snapshot_id}/${sha256(content)}/${entry.path}`;
90
+ const target = safeSourceFile(generated, generatedPath); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, content);
91
+ return { ...entry, path: generatedPath, sha256: sha256(content) };
92
+ }).sort((a, b) => a.record_id < b.record_id ? -1 : a.record_id > b.record_id ? 1 : 0);
93
+ fs.mkdirSync(generated, { recursive: true });
94
+ fs.writeFileSync(path.join(generated, "_manifest.json"), `${JSON.stringify(nextManifest, null, 2)}\n`);
95
+ fs.writeFileSync(path.join(generated, "_component-dimensions.json"), `${JSON.stringify(snapshot, null, 2)}\n`);
96
+ return generated;
97
+ }
98
+
99
+ export function buildSearchIndex({ sourceDir, outputDir, releaseId, componentSnapshot }) {
66
100
  const output = path.resolve(outputDir);
101
+ const source = componentSnapshot ? generatedSource(path.resolve(sourceDir), output, componentSnapshot) : path.resolve(sourceDir);
102
+ const sourceSnapshotFile = path.join(source, "_component-dimensions.json");
103
+ const snapshot = fs.existsSync(sourceSnapshotFile) ? validateComponentSnapshot(JSON.parse(fs.readFileSync(sourceSnapshotFile, "utf8"))) : null;
104
+ const sourceRecords = new Map(snapshot?.records.map(row => [row.component_id, row]) ?? []);
67
105
  const manifestFile = path.join(source, "_manifest.json");
68
106
  if (!fs.existsSync(manifestFile)) throw fail(`manifest not found: ${manifestFile}`);
69
107
  const manifestBytes = fs.readFileSync(manifestFile);
@@ -82,6 +120,11 @@ export function buildSearchIndex({ sourceDir, outputDir, releaseId }) {
82
120
  if (recordIds.has(entry.record_id)) throw fail(`duplicate record_id in manifest: ${entry.record_id}`);
83
121
  recordIds.add(entry.record_id);
84
122
  const parsed = parseHardwareWiki(fs.readFileSync(safeSourceFile(source, entry.path), "utf8"));
123
+ const hasDimensions = ["dimensions_mm", "dimension_basis", "dimension_source", "dimension_geometry"].some(key => Object.hasOwn(parsed.meta, key));
124
+ if (hasDimensions) {
125
+ const expected = sourceRecords.get(entry.record_id);
126
+ if (!expected || digest(dimensionFields(parsed.meta)) !== digest(fieldsFromSnapshotRecord(expected, snapshot.snapshot_id))) throw fail(`Wiki dimensions do not match component snapshot: ${entry.path}`);
127
+ } else if (snapshot) throw fail(`snapshot-backed Wiki missing dimension fields: ${entry.path}`);
85
128
  const record = compactRecord(entry, parsed);
86
129
  const key = entry.category_path.join("/");
87
130
  if (!grouped.has(key)) grouped.set(key, []);
@@ -122,18 +165,19 @@ export function buildSearchIndex({ sourceDir, outputDir, releaseId }) {
122
165
  schema: CATALOG_SCHEMA,
123
166
  release_id: resolvedReleaseId,
124
167
  generated_at: latestSourceUpdate,
125
- source_manifest: { object_key: "_manifest.json", sha256: sourceManifestSha, record_count: manifest.length },
168
+ source_manifest: { object_key: snapshot ? `wiki/snapshots/${sourceManifestSha}/_manifest.json` : "_manifest.json", sha256: sourceManifestSha, record_count: manifest.length },
126
169
  categories,
170
+ ...(snapshot ? { component_snapshot: { snapshot_id: snapshot.snapshot_id, object_key: `wiki/snapshots/${sourceManifestSha}/_component-dimensions.json`, sha256: sha256(fs.readFileSync(sourceSnapshotFile)) } } : {}),
127
171
  };
128
172
  fs.writeFileSync(path.join(output, "_catalog.json"), `${JSON.stringify(catalog, null, 2)}\n`);
129
- return { documents: manifest.length, categories: categories.length, output, release_id: resolvedReleaseId, source_manifest_sha256: sourceManifestSha };
173
+ return { documents: manifest.length, categories: categories.length, output, release_id: resolvedReleaseId, source_manifest_sha256: sourceManifestSha, ...(snapshot ? { generated_source: source, component_snapshot_id: snapshot.snapshot_id } : {}) };
130
174
  }
131
175
 
132
176
  function parseArgs(argv) {
133
177
  const flags = {};
134
178
  for (let i = 0; i < argv.length; i++) {
135
179
  const name = argv[i];
136
- if (name !== "--source" && name !== "--output" && name !== "--release-id") throw fail(`unknown argument: ${name}`);
180
+ if (name !== "--source" && name !== "--output" && name !== "--release-id" && name !== "--component-snapshot") throw fail(`unknown argument: ${name}`);
137
181
  if (!argv[i + 1]) throw fail(`${name} requires a value`);
138
182
  flags[name.slice(2)] = argv[++i];
139
183
  }
@@ -144,7 +188,7 @@ function parseArgs(argv) {
144
188
  if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
145
189
  try {
146
190
  const flags = parseArgs(process.argv.slice(2));
147
- process.stdout.write(`${JSON.stringify({ ok: true, data: buildSearchIndex({ sourceDir: flags.source, outputDir: flags.output, releaseId: flags["release-id"] }) })}\n`);
191
+ process.stdout.write(`${JSON.stringify({ ok: true, data: buildSearchIndex({ sourceDir: flags.source, outputDir: flags.output, releaseId: flags["release-id"], componentSnapshot: flags["component-snapshot"] }) })}\n`);
148
192
  } catch (cause) {
149
193
  process.stderr.write(`${JSON.stringify({ ok: false, error: { code: cause.code ?? "INDEX_BUILD_FAILED", message: cause.message } })}\n`);
150
194
  process.exitCode = 1;
@@ -0,0 +1,184 @@
1
+ // Coarse single-part screening. This module never grants CAD/engineering authority.
2
+ import { canonical, digest } from './selection-request.mjs';
3
+ const object = v => v !== null && typeof v === 'object' && !Array.isArray(v);
4
+ const positive = v => typeof v === 'number' && Number.isFinite(v) && v > 0;
5
+ const nonnegative = v => typeof v === 'number' && Number.isFinite(v) && v >= 0;
6
+ const text = v => typeof v === 'string' && v.trim().length > 0;
7
+ const hash = v => typeof v === 'string' && /^[a-f0-9]{64}$/.test(v);
8
+ const tuple = v => Array.isArray(v) && v.length === 3 && v.every(positive);
9
+ const invalid = message => { throw Object.assign(new Error(message), { code: 'VALIDATION' }); };
10
+ function keys(value, required, optional, label) {
11
+ if (!object(value) || required.some(key => !Object.hasOwn(value, key)) || Object.keys(value).some(key => ![...required, ...optional].includes(key))) invalid(`${label} has missing or unsupported fields`);
12
+ }
13
+ export function validateEnvelope(envelope) {
14
+ const box = envelope?.shape === 'box';
15
+ const cylinder = envelope?.shape === 'cylinder';
16
+ if (!box && !cylinder) invalid('envelope shape must be box or cylinder');
17
+ keys(envelope, box ? ['shape', 'max_mm'] : ['shape', 'max_diameter_mm', 'max_height_mm'], box ? ['min_mm', 'wall_mm'] : ['min_diameter_mm', 'min_height_mm', 'wall_mm'], 'envelope');
18
+ if (box) {
19
+ if (!tuple(envelope.max_mm) || (envelope.min_mm !== undefined && (!tuple(envelope.min_mm) || envelope.min_mm.some((v, i) => v > envelope.max_mm[i])))) invalid('box dimensions must be finite positive ordered triples');
20
+ } else {
21
+ for (const axis of ['diameter', 'height']) {
22
+ const max = envelope[`max_${axis}_mm`], min = envelope[`min_${axis}_mm`];
23
+ if (!positive(max) || (min !== undefined && (!positive(min) || min > max))) invalid('cylinder dimensions must be finite positive ordered ranges');
24
+ }
25
+ }
26
+ if (envelope.wall_mm !== undefined) {
27
+ keys(envelope.wall_mm, ['minimum'], ['estimate'], 'wall_mm');
28
+ const { minimum, estimate } = envelope.wall_mm;
29
+ if (!nonnegative(minimum) || (estimate !== undefined && (!nonnegative(estimate) || estimate < minimum))) invalid('wall minimum/estimate must be finite nonnegative ordered values');
30
+ }
31
+ const max = box ? envelope.max_mm : [envelope.max_diameter_mm, envelope.max_height_mm];
32
+ if (max.some(v => v - 2 * (envelope.wall_mm?.minimum ?? 0) <= 0)) invalid('guaranteed minimum wall leaves no positive maximum cavity');
33
+ return envelope;
34
+ }
35
+ export function validateRequirementEnvelopes(requirements) {
36
+ for (const row of requirements?.limits ?? []) if (row?.envelope !== undefined) validateEnvelope(row.envelope);
37
+ }
38
+ const BASE_FIELDS = ['component_id', 'dimensions_mm', 'dimension_basis', 'dimension_geometry'];
39
+ function dimensionRecord(value) {
40
+ const record = Object.fromEntries(BASE_FIELDS.map(key => [key, value[key]]));
41
+ if (value.evidence_ref !== undefined) record.evidence_ref = value.evidence_ref;
42
+ return record;
43
+ }
44
+ function validRecord(value) {
45
+ return object(value) && text(value.component_id) && (value.dimensions_mm === null || tuple(value.dimensions_mm))
46
+ && ['measured', 'datasheet', 'estimate', 'unknown'].includes(value.dimension_basis)
47
+ && ['solid_box', 'bounding_box', 'unknown'].includes(value.dimension_geometry)
48
+ && (value.evidence_ref === undefined || text(value.evidence_ref))
49
+ && (value.dimensions_mm !== null || (value.dimension_basis === 'unknown' && value.dimension_geometry === 'unknown'));
50
+ }
51
+ export function validateComponentSnapshot(snapshot) {
52
+ keys(snapshot, ['schema', 'snapshot_id', 'records'], [], 'component snapshot');
53
+ if (snapshot.schema !== 'abelien.component-dimensions.v1' || !Array.isArray(snapshot.records) || !hash(snapshot.snapshot_id)) invalid('unsupported component dimension snapshot');
54
+ let previous;
55
+ for (const row of snapshot.records) {
56
+ keys(row, [...BASE_FIELDS, 'revision'], ['evidence_ref'], 'component snapshot record');
57
+ if (!validRecord(row) || !hash(row.revision) || row.revision !== digest(dimensionRecord(row))) invalid('component dimension record or revision is invalid');
58
+ if (previous !== undefined && previous >= row.component_id) invalid('component records must be unique and sorted by component_id');
59
+ previous = row.component_id;
60
+ }
61
+ if (snapshot.snapshot_id !== digest(snapshot.records)) invalid('component snapshot hash does not match records');
62
+ return snapshot;
63
+ }
64
+ // Only source-bound records can contribute a trusted basis. Legacy unbound fields stay display-only unknown.
65
+ export function dimensionFields(value) {
66
+ const source = value?.dimension_source;
67
+ const id = value?.component_id ?? value?.record_id;
68
+ const validSize = tuple(value?.dimensions_mm);
69
+ const fields = { dimensions_mm: validSize ? [...value.dimensions_mm] : null, dimension_basis: 'unknown', dimension_geometry: 'unknown', dimension_source: null };
70
+ if (!object(source) || source.component_id !== id || !hash(source.revision) || !hash(source.snapshot_id)) return fields;
71
+ const record = { component_id: id, dimensions_mm: fields.dimensions_mm, dimension_basis: value.dimension_basis,
72
+ dimension_geometry: value.dimension_geometry ?? 'unknown', ...(source.evidence_ref !== undefined ? { evidence_ref: source.evidence_ref } : {}) };
73
+ // Missing geometry is a legacy gap, not permission to apply solid-box predicates.
74
+ if (value.dimension_geometry === undefined) { fields.dimension_source = { ...source }; return fields; }
75
+ if (!validRecord(record) || source.revision !== digest(record)) invalid('dimension source revision does not match candidate fields');
76
+ return { dimensions_mm: record.dimensions_mm, dimension_basis: record.dimension_basis, dimension_geometry: record.dimension_geometry, dimension_source: { ...source } };
77
+ }
78
+ export function fieldsFromSnapshotRecord(record, snapshotId) {
79
+ return { dimensions_mm: record.dimensions_mm, dimension_basis: record.dimension_basis, dimension_geometry: record.dimension_geometry,
80
+ dimension_source: { component_id: record.component_id, revision: record.revision, snapshot_id: snapshotId, ...(record.evidence_ref !== undefined ? { evidence_ref: record.evidence_ref } : {}) } };
81
+ }
82
+ // Compare raw dimension-bearing fields too: normalizing unknowns must not hide index/Wiki drift.
83
+ export function sameDimensionMetadata(a, b) {
84
+ const pick = value => ({ dimensions_mm: value.dimensions_mm ?? null, dimension_basis: value.dimension_basis ?? 'unknown', dimension_geometry: value.dimension_geometry ?? 'unknown', dimension_source: value.dimension_source ?? null });
85
+ return canonical(pick(a)) === canonical(pick(b));
86
+ }
87
+ function cavity(envelope, wall) {
88
+ if (envelope.shape === 'box') {
89
+ const sides = envelope.max_mm.map(v => v - 2 * wall);
90
+ return { sides, width: Math.min(...sides), chord: Math.hypot(...sides), volume: sides.reduce((a, b) => a * b, 1), valid: sides.every(positive) };
91
+ }
92
+ const diameter = envelope.max_diameter_mm - 2 * wall, height = envelope.max_height_mm - 2 * wall;
93
+ return { diameter, height, width: Math.min(diameter, height), chord: Math.hypot(diameter, height), volume: Math.PI * (diameter / 2) ** 2 * height, valid: positive(diameter) && positive(height) };
94
+ }
95
+ const exceeds = (a, b) => a - b > 1e-9 * Math.max(1, Math.abs(a), Math.abs(b));
96
+ function coarseFit(dims, container) {
97
+ if (!container.valid) return false;
98
+ return [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]].some(p => container.sides
99
+ ? p.every((axis, i) => !exceeds(dims[axis], container.sides[i]))
100
+ : !exceeds(dims[p[2]], container.height) && !exceeds(Math.hypot(dims[p[0]], dims[p[1]]), container.diameter));
101
+ }
102
+ // status is the coarse fit result only: clear = every coarse check passed, risk = some check failed without proof,
103
+ // incompatible = proof under a user hard limit with measured/datasheet sizes. Data/limit authority stays in basis and limit source.
104
+ // decision_required = user hard limit + trusted sizes, yet no axis-aligned mounting fits: only the user can trade the part against the limit.
105
+ export function evaluateDimensions(candidate, requirements) {
106
+ validateRequirementEnvelopes(requirements);
107
+ const fields = dimensionFields(candidate), reasons = [], method = 'coarse-single-part-v2';
108
+ const unknown = code => ({ status: 'unknown', decision_required: false, reasons: [{ code }], method });
109
+ const limits = (requirements?.limits ?? []).filter(row => row?.envelope !== undefined);
110
+ if (!limits.length) return unknown('PRODUCT_ENVELOPE_MISSING');
111
+ if (!fields.dimensions_mm || fields.dimension_basis === 'unknown' || fields.dimension_geometry === 'unknown') return unknown('COMPONENT_DIMENSIONS_UNKNOWN');
112
+ const dims = fields.dimensions_mm;
113
+ if (!positive(dims.reduce((a, b) => a * b, 1)) || !positive(Math.hypot(...dims))
114
+ || limits.some(row => { const c = cavity(row.envelope, row.envelope.wall_mm?.minimum ?? 0); return !positive(c.volume) || !positive(c.chord); }))
115
+ return unknown('NUMERIC_RANGE_UNSUPPORTED');
116
+ let incompatible = false, risk = false, decisionRequired = false;
117
+ for (const row of limits) {
118
+ const hard = row.source === 'user' && row.strength === 'hard' && ['measured', 'datasheet'].includes(fields.dimension_basis);
119
+ const container = cavity(row.envelope, row.envelope.wall_mm?.minimum ?? 0);
120
+ const failures = fields.dimension_geometry === 'solid_box'
121
+ ? [['MIN_SUPPORT_WIDTH', Math.min(...dims), container.width], ['SOLID_DIAGONAL', Math.hypot(...dims), container.chord], ['SOLID_VOLUME', dims.reduce((a, b) => a * b, 1), container.volume]]
122
+ : [['MAX_SUPPORT_SPAN', Math.max(...dims), container.chord]];
123
+ for (const [code, observed, maximum] of failures) if (exceeds(observed, maximum)) {
124
+ reasons.push({ code, limit_id: row.id, observed, maximum, hard }); if (hard) incompatible = true; else risk = true;
125
+ }
126
+ // Parts are mounted axis-aligned in practice; failing that is a risk, never a proof, because a tilted part may still fit.
127
+ if (!coarseFit(dims, container)) {
128
+ risk = true; if (hard) decisionRequired = true;
129
+ reasons.push({ code: 'AXIS_ALIGNED_FIT_FAILS', limit_id: row.id, hard: false });
130
+ } else if (row.envelope.wall_mm?.estimate !== undefined && !coarseFit(dims, cavity(row.envelope, row.envelope.wall_mm.estimate))) {
131
+ // Estimated walls only hint; they never reject and never ask the user to decide.
132
+ risk = true; reasons.push({ code: 'ESTIMATED_WALL_FIT_FAILS', limit_id: row.id, hard: false });
133
+ }
134
+ }
135
+ return { status: incompatible ? 'incompatible' : risk ? 'risk' : 'clear', decision_required: !incompatible && decisionRequired, reasons, method };
136
+ }
137
+ export function screenDimensions(records, requirements) {
138
+ const checks = records.map(record => ({ record, check: evaluateDimensions(record, requirements) }));
139
+ const rejected = checks.filter(item => item.check.status === 'incompatible');
140
+ const survivors = checks.filter(item => item.check.status !== 'incompatible');
141
+ return { survivors, summary: {
142
+ status: records.length && rejected.length === records.length ? 'all_indexed_candidates_rejected' : records.length ? 'screened' : 'no_indexed_candidates',
143
+ scope: 'indexed_category_candidates', evaluated: records.length, rejected: rejected.length, retained: survivors.length,
144
+ demoted: survivors.filter(item => item.check.status === 'risk').length,
145
+ rejections: rejected.map(({ record, check }) => ({ component_id: record.component_id ?? record.record_id, ...check })),
146
+ } };
147
+ }
148
+ // Risk candidates stay visible but do not consume Top-K slots, so each one brings in a replacement that may fit.
149
+ // Input must already be sorted by relevance; output lists non-risk candidates first, each tier keeping relevance order.
150
+ export function shortlistByDimensionTier(ranked, topK, cap, statusOf) {
151
+ const picked = [];
152
+ let fitting = 0;
153
+ for (const item of ranked) {
154
+ if (fitting >= topK || picked.length >= cap) break;
155
+ picked.push(item);
156
+ if (statusOf(item) !== 'risk') fitting++;
157
+ }
158
+ return orderByDimensionTier(picked, statusOf);
159
+ }
160
+ export function orderByDimensionTier(items, statusOf) {
161
+ return [...items.filter(item => statusOf(item) !== 'risk'), ...items.filter(item => statusOf(item) === 'risk')];
162
+ }
163
+ export function assemblyOccupancy(models, requirements, policy = {}) {
164
+ validateRequirementEnvelopes(requirements);
165
+ let estimated_volume_mm3 = 0, unknown_lines = 0, includedLines = 0;
166
+ const seen = new Set();
167
+ for (const model of models) {
168
+ if (seen.has(model.bom_item_id)) continue;
169
+ if (model.bom_item_id) seen.add(model.bom_item_id);
170
+ const fields = dimensionFields(model);
171
+ if (!fields.dimensions_mm || fields.dimension_basis === 'unknown' || !Number.isSafeInteger(model.quantity) || model.quantity < 1) { unknown_lines++; continue; }
172
+ const volume = fields.dimensions_mm.reduce((a, b) => a * b, model.quantity);
173
+ if (!positive(volume) || !Number.isFinite(estimated_volume_mm3 + volume)) { unknown_lines++; continue; }
174
+ estimated_volume_mm3 += volume;
175
+ includedLines++;
176
+ }
177
+ const containers = (includedLines ? requirements?.limits ?? [] : []).filter(row => row?.envelope).flatMap(row => {
178
+ const value = cavity(row.envelope, row.envelope.wall_mm?.minimum ?? 0).volume;
179
+ const ratio = estimated_volume_mm3 / value;
180
+ return positive(value) && nonnegative(ratio) ? [{ limit_id: row.id, cavity_volume_mm3: value, ratio }] : [];
181
+ });
182
+ return { advisory: true, method: 'sum-selected-line-bounding-volumes-v1', estimated_volume_mm3, unknown_lines, containers,
183
+ ...(positive(policy.occupancy_warning_ratio) ? { warning: containers.some(c => c.ratio > policy.occupancy_warning_ratio), warning_ratio: policy.occupancy_warning_ratio } : {}) };
184
+ }
@@ -1,4 +1,6 @@
1
1
  import crypto from "node:crypto";
2
+ import { assemblyOccupancy, dimensionFields, evaluateDimensions } from "./dimensions.mjs";
3
+ import { selectionRequestDigest } from "./selection-request.mjs";
2
4
 
3
5
  function error(message, details) {
4
6
  const out = new Error(message);
@@ -23,6 +25,7 @@ export async function finalizeHardwareSelection({ input }) {
23
25
  if (!recall || recall.status !== "completed" || !recall.source_snapshot) throw error("completed knowledge_recall is required");
24
26
  if (!["proposed", "verified", "needs_confirmation"].includes(proposal.status)) throw error("selection_proposal.status is not recognized");
25
27
  if (!["adapt_system", "assemble_new"].includes(proposal.plan_mode)) throw error("selection_proposal.plan_mode is not recognized");
28
+ if (recall.request_digest && recall.request_digest !== selectionRequestDigest(input)) throw error("recall does not match selection requirements and slots");
26
29
  const proposedModels = requireArray(proposal.selected_models, "selected_models");
27
30
  const coverageLedger = requireArray(proposal.coverage_ledger, "coverage_ledger");
28
31
  const remainingSlots = requireArray(proposal.remaining_slots, "remaining_slots");
@@ -71,7 +74,14 @@ export async function finalizeHardwareSelection({ input }) {
71
74
  slot_id: selected.slot_id, component_id: selected.component_id,
72
75
  });
73
76
  }
77
+ const dimensionCheck = evaluateDimensions(candidate, input.requirements);
78
+ if (dimensionCheck.status === "incompatible") throw error("selected component is dimensionally incompatible; choose a surviving candidate", { slot_id: selected.slot_id, component_id: selected.component_id, dimension_check: dimensionCheck });
74
79
  return {
80
+ name: String(candidate.title ?? candidate.name ?? candidate.model ?? candidate.component_id),
81
+ // This is a deterministic selection-line identity, NOT an upstream catalog BOM identity.
82
+ bom_item_id: `selection-line-${digest({ slot_id: selected.slot_id, component_id: candidate.component_id }).slice(0, 24)}`,
83
+ ...dimensionFields(candidate), dimension_check: dimensionCheck,
84
+ ...(candidate.interfaces !== undefined ? { interfaces: candidate.interfaces } : {}),
75
85
  slot_id: selected.slot_id, component_id: selected.component_id, quantity: selected.quantity,
76
86
  selection_reason: selected.selection_reason, wiki_evidence: selected.wiki_evidence,
77
87
  ...(selected.constraint_checks !== undefined ? { constraint_checks: selected.constraint_checks } : {}),
@@ -97,6 +107,7 @@ export async function finalizeHardwareSelection({ input }) {
97
107
  revision: `r-${hash.slice(0, 12)}`,
98
108
  plan_mode: proposal.plan_mode,
99
109
  selected_models: selectedModels,
110
+ ...(input.requirements?.limits?.some(row => row?.envelope) ? { occupancy: assemblyOccupancy(selectedModels, input.requirements, input.policy) } : {}),
100
111
  coverage_ledger: coverageLedger,
101
112
  remaining_slots: remainingSlots,
102
113
  unknowns,
@@ -8,7 +8,7 @@ import { publishHardwareSearchIndex } from "./search-index-release.mjs";
8
8
  function parseArgs(argv) {
9
9
  const flags = {};
10
10
  const booleans = new Set(["--dry-run", "--skip-smoke", "--bootstrap-manifest"]);
11
- const valued = new Set(["--source", "--output", "--release-id", "--env-file", "--concurrency"]);
11
+ const valued = new Set(["--source", "--output", "--release-id", "--env-file", "--concurrency", "--component-snapshot"]);
12
12
  for (let i = 0; i < argv.length; i++) {
13
13
  const name = argv[i];
14
14
  if (booleans.has(name)) flags[name.slice(2)] = true;
@@ -30,6 +30,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
30
30
  sourceDir: path.resolve(cwd, flags.source),
31
31
  outputDir: flags.output ? path.resolve(cwd, flags.output) : undefined,
32
32
  releaseId: flags["release-id"],
33
+ componentSnapshot: flags["component-snapshot"] ? path.resolve(cwd, flags["component-snapshot"]) : undefined,
33
34
  env,
34
35
  fetchImpl: deps.fetchImpl || fetch,
35
36
  dryRun: Boolean(flags["dry-run"]),
@@ -69,6 +69,7 @@ export function validateLocalRelease(outputDir) {
69
69
  if (index.source_manifest_sha256 !== catalog.source_manifest?.sha256) {
70
70
  throw releaseError("INDEX_RELEASE_INVALID", `index Manifest digest mismatch: ${descriptor.index_key}`);
71
71
  }
72
+ if (index.records.some(record => !/^[a-f0-9]{64}$/.test(record.sha256 ?? ""))) throw releaseError("INDEX_RELEASE_INVALID", "every Wiki needs a content SHA-256; rebuild legacy indexes");
72
73
  documents += index.records.length;
73
74
  files.push({ key: descriptor.index_key, file, bytes, sha256: descriptor.sha256 });
74
75
  }
@@ -242,12 +243,12 @@ export async function smokePublishedIndex({ env, fetchImpl = fetch, categoryPath
242
243
  };
243
244
  }
244
245
 
245
- export async function publishHardwareSearchIndex({ sourceDir, outputDir, releaseId, env, fetchImpl = fetch, dryRun = false, smoke = true, concurrency, bootstrapManifest = false } = {}) {
246
+ export async function publishHardwareSearchIndex({ sourceDir, outputDir, releaseId, componentSnapshot, env, fetchImpl = fetch, dryRun = false, smoke = true, concurrency, bootstrapManifest = false } = {}) {
246
247
  if (!sourceDir) throw releaseError("USAGE", "sourceDir is required");
247
248
  const temporary = !outputDir;
248
249
  const output = outputDir ? path.resolve(outputDir) : fs.mkdtempSync(path.join(os.tmpdir(), "abelien-hardware-index-"));
249
250
  try {
250
- const built = buildSearchIndex({ sourceDir, outputDir: output, releaseId });
251
+ const built = buildSearchIndex({ sourceDir, outputDir: output, releaseId, componentSnapshot });
251
252
  const local = validateLocalRelease(output);
252
253
  if (dryRun) return {
253
254
  status: "validated",
@@ -262,6 +263,18 @@ export async function publishHardwareSearchIndex({ sourceDir, outputDir, release
262
263
  };
263
264
 
264
265
  const config = createTosConfig(env);
266
+ if (built.generated_source) {
267
+ const manifestBytes = fs.readFileSync(path.join(built.generated_source, "_manifest.json"));
268
+ const entries = JSON.parse(manifestBytes);
269
+ const limit = Number(concurrency || env?.TOS_PUBLISH_CONCURRENCY || 6);
270
+ await mapConcurrent(entries, Number.isFinite(limit) ? limit : 6, async entry => {
271
+ const bytes = fs.readFileSync(safeFile(built.generated_source, entry.path));
272
+ if (sha256(bytes) !== entry.sha256) throw releaseError("SOURCE_WIKI_MISMATCH", "generated Wiki changed after build");
273
+ await putImmutable(config, `${config.prefix}${entry.path}`, bytes, fetchImpl);
274
+ });
275
+ await putImmutable(config, `${config.prefix}${local.catalog.component_snapshot.object_key}`, fs.readFileSync(path.join(built.generated_source, "_component-dimensions.json")), fetchImpl);
276
+ await putImmutable(config, `${config.prefix}${local.catalog.source_manifest.object_key}`, manifestBytes, fetchImpl);
277
+ }
265
278
  let manifest = await requireMatchingManifest(config, local.catalog, fetchImpl, { optional: true });
266
279
  if (!manifest && !bootstrapManifest) {
267
280
  throw releaseError("SOURCE_MANIFEST_MISSING", "TOS Wiki Manifest is missing; rerun with --bootstrap-manifest to verify every remote Wiki object before creating it", {