abelien-harness 0.3.0 → 0.5.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.
- package/.env.example +3 -0
- package/README.md +9 -3
- package/package.json +1 -1
- package/skills/abelien-3dgen/SKILL.md +4 -2
- package/skills/abelien-3dgen/providers/tripo.json +64 -1
- package/skills/abelien-3dgen/references/engineering-lowpoly-qualification.md +20 -0
- package/skills/abelien-3dgen/references/host-api.md +128 -0
- package/skills/abelien-3dgen/references/provider-profile.md +4 -0
- package/skills/abelien-appearance/SKILL.md +2 -0
- package/skills/abelien-appearance/references/local-component-edit.md +31 -0
- package/skills/abelien-clarify/SKILL.md +8 -3
- package/skills/abelien-clarify/references/examples.md +9 -0
- package/skills/abelien-clarify/scripts/check_result.py +53 -1
- package/skills/abelien-clarify/scripts/test_check_result.py +40 -0
- package/skills/abelien-guide/SKILL.md +2 -0
- package/skills/abelien-guide/references/shared-response-rules.md +2 -2
- package/skills/abelien-hardware/SKILL.md +12 -2
- package/skills/abelien-hardware/domain.json +4 -4
- package/skills/abelien-hardware/references/search-index-format.md +41 -2
- package/skills/abelien-hardware/references/search-index-release.md +12 -1
- package/skills/abelien-hardware/references/selection-service-contract.md +3 -0
- package/skills/abelien-hardware/references/web-evidence-contract.md +2 -2
- package/skills/abelien-hardware/scripts/build-search-index.mjs +50 -6
- package/skills/abelien-hardware/scripts/dimensions.mjs +184 -0
- package/skills/abelien-hardware/scripts/electrical.mjs +125 -0
- package/skills/abelien-hardware/scripts/finalize-selection.mjs +15 -0
- package/skills/abelien-hardware/scripts/provider-chain-contract.mjs +10 -6
- package/skills/abelien-hardware/scripts/publish-search-index.mjs +2 -1
- package/skills/abelien-hardware/scripts/search-index-release.mjs +45 -19
- package/skills/abelien-hardware/scripts/tos-wiki-search.mjs +40 -20
- package/skills/abelien-hardware/scripts/volc-web-search.mjs +6 -1
- package/skills/abelien-hardware/scripts/web-common.mjs +4 -1
- package/skills/abelien-hardware/scripts/web-provider-chain.mjs +8 -2
- package/skills/abelien-hardware/scripts/web-search-plan.mjs +16 -1
- package/skills/abelien-hardware/scripts/web-search.mjs +4 -2
- package/skills/abelien-hardware-selection/SKILL.md +1 -1
- package/skills/abelien-hardware-selection/domain.json +2 -2
- package/skills/abelien-hardware-selection/references/selection-service-contract.md +32 -2
- package/skills/abelien-hardware-selection/scripts/select.mjs +35 -7
- package/skills/abelien-harness/references/workflow-spec.md +15 -2
- package/skills/abelien-harness/scripts/generate3d-host.mjs +398 -0
- package/skills/abelien-harness/scripts/generate3d.mjs +54 -15
- package/skills/abelien-harness/scripts/receipt.mjs +8 -2
- package/skills/abelien-harness/scripts/studio.mjs +18 -10
- package/skills/abelien-structure/SKILL.md +3 -1
- package/skills/abelien-structure/references/build123d-execution.md +1 -1
- package/skills/abelien-structure/scripts/run_stage.py +8 -1
- package/skills/abelien-workspace/SKILL.md +2 -0
- package/skills/abelien-workspace/domain.json +1 -1
- package/skills/abelien-workspace/references/hardware-journey-orchestration.md +1 -1
- package/skills/abelien-workspace/workflows/definitions/0852e4949f5a6246880c1a26bf5ccaa42f8cda06b960e7c7ce234a0a176878f9.json +229 -0
- package/skills/abelien-workspace/workflows/definitions/7c69ea0d51099b1ae947f08b056ea67f5cad6b40ae8bcb00efb496fa5b9d5ed3.json +149 -0
- package/skills/abelien-workspace/workflows/definitions/9b5c65ad03fdecc0148931a7677cfa684a3fb04ab2738d6f7a1086a2c79ac86c.json +149 -0
- package/skills/abelien-workspace/workflows/definitions/a6e8254e1839d94530ae243cba779e423953b7169cbce338f0503d748d44800b.json +175 -0
- package/skills/abelien-workspace/workflows/definitions/b37f3b47e1e8bd1208859de57456152b149416e21949297b22bba937c51a5670.json +149 -0
- package/skills/abelien-workspace/workflows/definitions/b780bf280d61596b43eb6dbc6a417cf8df1ec9a7ae79a6de10f048da9ed58729.json +229 -0
- package/skills/abelien-workspace/workflows/definitions/c1b5c6c653f0c4761d51efee923aafbbeea05512e7f35c60f7f7d36ec49fc1d1.json +229 -0
- package/skills/abelien-workspace/workflows/definitions/e391e14c14cb39408a0a9e226ed54ffab7a36badea305ae4a3eafbac8124fc02.json +175 -0
- package/skills/abelien-workspace/workflows/definitions/fd8ce7074e2044985778446e93153864940bb9465471c1c71c41328cefff70bf.json +175 -0
- package/skills/abelien-workspace/workflows/user-journey-hardware-algorithm.json +3 -3
- package/skills/abelien-workspace/workflows/user-journey-hardware-candidates.json +3 -3
- package/skills/abelien-workspace/workflows/user-journey-hardware.json +3 -3
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "abelien-hardware",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Hardware library recall,
|
|
3
|
+
"version": "1.3.0",
|
|
4
|
+
"description": "Hardware library recall, English Volc query and source evidence (Prismer off by default) with envelope candidates; formal selection remains hardware.select",
|
|
5
5
|
"resources": [
|
|
6
6
|
"hardware-recall",
|
|
7
7
|
"hardware-proposal",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
],
|
|
14
14
|
"capabilities": {
|
|
15
15
|
"hardware.search": {
|
|
16
|
-
"description": "按三级功能槽位读取 TOS
|
|
16
|
+
"description": "按三级功能槽位读取 TOS 压缩分类索引,先保守尺寸筛选再下载 Top-K Wiki,返回供 Agent 语义比较的完整候选页",
|
|
17
17
|
"adapter": {
|
|
18
18
|
"type": "module",
|
|
19
19
|
"path": "scripts/tos-wiki-search.mjs",
|
|
@@ -155,7 +155,7 @@
|
|
|
155
155
|
"produces": "hardware-selection"
|
|
156
156
|
},
|
|
157
157
|
"hardware.web-search": {
|
|
158
|
-
"description": "
|
|
158
|
+
"description": "English Volc query and direct source reads (Prismer switchable, off by default), safe source evidence and original-task recovery",
|
|
159
159
|
"adapter": {
|
|
160
160
|
"type": "module",
|
|
161
161
|
"path": "scripts/web-search.mjs",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 硬件 Wiki 分类索引格式
|
|
2
2
|
|
|
3
|
-
本文件只在发布、校验或迁移 TOS 硬件 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
|
|
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,42 @@ 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 输入或已失败/未知副作用。
|
|
71
|
+
|
|
72
|
+
## 电气字段(消费方契约)
|
|
73
|
+
|
|
74
|
+
生成 Wiki 头部可带两行严格内联 JSON:
|
|
75
|
+
|
|
76
|
+
- `electrical`:`{schema:"abelien.component-electrical.v1", basis, power_inputs, power_outputs, buses}`。`basis` 为 measured / datasheet / estimate / unknown;供电项为 `{id, voltage_v:[min,max], connector?, max_current_a?}`,id 与连接器为小写短横线标识,电压为有序正数;总线项为 `{type, role, count, logic_level_v?}`,type 取 i2c / spi / uart / usb / i2s / can / pwm / gpio / adc,role 为 controller 或 peripheral。不接受多余字段。
|
|
77
|
+
- `electrical_source`:`{component_id, revision, snapshot_id, evidence_ref?}`,`revision` 是 `{component_id, ...electrical}` 规范 JSON 的 SHA-256。
|
|
78
|
+
|
|
79
|
+
只有两行都在、源绑定一致的记录才进入候选;只有 `electrical` 没有来源时按未知处理,摘要不符则拒绝。组件数据库是电气数据唯一编辑源,生产方(P2-04.03)沿尺寸快照同一条只读导出与构建管道写入,禁止在 Wiki 上手改。占位、默认或无出处的数据只能标 estimate 或 unknown。
|
|
80
|
+
|
|
@@ -62,6 +62,17 @@ npm run smoke:hardware-search -- \
|
|
|
62
62
|
|
|
63
63
|
## 激活与失败语义
|
|
64
64
|
|
|
65
|
-
构建器以源 Manifest SHA-256 作为 `release_id`,分片写入 `indexes/releases/<release_id>/...`。发布器先验证远端 Manifest,再以不可变语义上传并回读全部分片;只有全部 SHA、Schema
|
|
65
|
+
构建器以源 Manifest SHA-256 作为 `release_id`,分片写入 `indexes/releases/<release_id>/...`。发布器先验证远端 Manifest,再以不可变语义上传并回读全部分片;只有全部 SHA、Schema、分类和记录数通过后,才替换 `_catalog.json`。
|
|
66
|
+
|
|
67
|
+
存储是阿里云 OSS(S3 兼容签名),这条路径**没有条件 PUT**:`If-None-Match`/`If-Match` 返回 400 NotImplemented,`x-oss-forbid-overwrite` 被忽略、照常覆盖。所以不可变对象靠先读后写:不存在才上传,已存在则比对 SHA,字节不同以 `IMMUTABLE_OBJECT_CONFLICT` 失败,上传后一律回读校验。激活前重读 `_catalog.json`,按内容 SHA-256 与发布开始时读到的版本比对(OSS 对 JSON 对象不回 ETag),变了就以 `CATALOG_ACTIVATION_RACE` 失败,不覆盖别人的新版本。重读到写入之间仍有很短的窗口,写后校验会比对 `release_id`,输掉竞争的一方因此失败,不会误报成功;同一时刻只应有一人发布。结果的 `previous_catalog` 记录被替换的 catalog(sha256、release_id),作为回滚点。
|
|
66
68
|
|
|
67
69
|
Catalog 激活失败只会留下未引用的不可变分片,不影响旧版本;搜索端始终从唯一 `_catalog.json` 读取一个完整 release。发布命令默认最后执行真实 `hardware.search`,确保 Catalog、分类索引和至少一篇 Wiki 正文能够按生产读取路径访问。`--skip-smoke` 只用于已有独立冒烟门禁的受控 CI,不能把单元测试当成生产连通性证明。
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
## 尺寸快照发布
|
|
73
|
+
|
|
74
|
+
数据库导出由 Web 仓库显式只读命令产生;Harness 不接收数据库凭据。尺寸发布使用同一命令的 `--component-snapshot /absolute/path/to/component-dimensions.json`,先运行 `--dry-run --output /absolute/path/to/review` 检查生成 Wiki、Manifest、导出和索引。确定发布环境后去掉 dry-run,才会执行远端写入。
|
|
75
|
+
|
|
76
|
+
这条路径按 content-addressed key 上传生成 Wiki,再上传不可变导出和 Manifest、索引分片,最后按上节语义激活 `_catalog.json`;每个对象冲突即失败,不覆盖旧 Wiki、旧清单或旧快照。生成路径从 built.generated_source 读取,不能把原始 source 目录的 Wiki 当成新的正文。无需 bootstrap-manifest,catalog 使用该 release 自己的版本化 Manifest。故障只留下未引用的不可变文件;本地测试的内存 TOS 只证明执行顺序和摘要,不证明线上发布成功。
|
|
77
|
+
|
|
78
|
+
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.
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Read the [architecture](../../../docs/architecture/hardware-online-fallback.md) and [official API inventory](../../../docs/architecture/prismer-api-inventory.md). The trusted current-workdir reader/checkpoint owns all source bodies. A model cannot submit URL/key/provider/command/source content or default envelopes.
|
|
4
4
|
|
|
5
|
-
hardware.web-search input is exactly {requirements,slots,recall_id,unresolved_slots}. Each unresolved item is {slot_id,reason,query,count?}; every zero-candidate slot must occur and all-unsuitable candidates need a reason. Query is nonempty; Count is a positive integer/
|
|
5
|
+
hardware.web-search input is exactly {requirements,slots,recall_id,unresolved_slots}. Each unresolved item is {slot_id,reason,query,count?}; every zero-candidate slot must occur and all-unsuitable candidates need a reason. Query is nonempty; Count is a positive integer. New plans (P2-05.02) must write the query in English (no Chinese/Japanese/Korean characters; keep model numbers and manufacturer names) and Count is 1–5, default 5; validateNewHardwareWebPlan rejects violations with VALIDATION. A plan already frozen in saved evidence keeps the rules it was accepted under (validateFrozenHardwareWebPlan). Full selectionRequestDigest preserves original requirements, quantities and constraints. unresolved_slots lists only slots needing search: omit suitable slots entirely, never submit empty query/count0 placeholders; all suitable means []. Empty unresolved list with suitable candidates uses zero network/credentials. The shared validateHardwareWebPlan({input,readResource}) in scripts/web-search-plan.mjs returns {source} after the original-recall, query/count and new-plan checks used by the preflight helper; search applies the new-plan checks only when the plan is not the one frozen in existing evidence, before configuration, checkpoints or provider calls. It does not filter or default invalid entries.
|
|
6
6
|
|
|
7
|
-
The trusted fixed policy is prismer_then_volc.
|
|
7
|
+
The trusted fixed policy is prismer_then_volc. Prismer is temporarily off (P2-05.02): unless the trusted host sets ABELIEN_HARDWARE_WEB_PRISMER=enabled (default disabled; any other value is HARDWARE_WEB_CONFIG), every prismer attempt (query, context_url, parse) is saved unsent as status=unavailable, reason=prismer_disabled with no receipt, and that marker authorizes the same group's volcengine/query or direct_https/direct_page attempt; PDF/image sources then stay unavailable (parse_unavailable). Volc requests use Count = plan count or 5; Volc receipts saved by 0.4.0 keep their frozen default Count 10 request. When enabled, the fixed Prismer query POST /api/context/load sends inputType=query, search.topK and explicit return.topK/format=both. URL-load sends inputType=url/format=both; only result.raw supplies source text. cached/cachedAt/meta and complete raw/response/summary/cost/time are retained. HQCC-only is unavailable. PDF/image suffixes from original query URLs use actual Parse {url,mode:fast,output:markdown,wait:true}. Synchronous requestId is nullable; async taskId is persisted before fixed status/result GET. Callback/endpoints are ignored. Source text is API raw/parsed markdown, not original HTTP bytes or a verified realtime/final URL.
|
|
8
8
|
|
|
9
9
|
New hardware-web-evidence has provider=prismer, protocol=prismer-volc-source-v2, schema_version=2, provider_policy=prismer_then_volc; original recall_id/recall_digest/request_digest/requirements/slots; plan_digest/unresolved_slots/query_plans; all searches; all pages; unknowns. Its stable ID depends only on original recall_id/request_digest. Every paid attempt has stable id/status/request/raw/raw_sha256/canonical response_sha256/received_at/http_status/request_id/response. Ordered searches[].attempts use prismer/query then the optional volcengine/query; pages[].attempts use prismer/context_url|parse then optional direct_https/direct_page. Each group retains selected_attempt_id/selected_provider and the exact selected receipt alias. Page primary attempt.kind is context_url or parse; pending Parse includes task_id and observations of each fixed GET. Page has source_url/returned_url (nullable), text/text_sha256/content_sha256/retrieval/cached/cachedAt/meta/loaded_at. Prismer source_bytes_verified/remote_redirects_verified remain false. Direct receipts retain actual wire and safe persisted raw/text digests, final_url/fetched_at, DNS/redirect/IP/TLS trace; redaction modifies only the safe copy and raw_redacted=true makes source_bytes_verified=false. PDF/image denial remains unavailable without a direct Parse substitute.
|
|
10
10
|
|
|
@@ -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
|
-
|
|
65
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Deterministic electrical compatibility for a selected set of parts (star topology, v1).
|
|
2
|
+
// Only measured/datasheet records can prove pass, fail or a supply gap; anything else stays unknown. This is not a circuit simulation.
|
|
3
|
+
import { digest } from './selection-request.mjs';
|
|
4
|
+
const object = v => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
5
|
+
const positive = 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 slug = v => typeof v === 'string' && /^[a-z0-9][a-z0-9-]{0,47}$/.test(v);
|
|
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 const ELECTRICAL_SCHEMA = 'abelien.component-electrical.v1';
|
|
14
|
+
export const BUS_TYPES = ['i2c', 'spi', 'uart', 'usb', 'i2s', 'can', 'pwm', 'gpio', 'adc'];
|
|
15
|
+
// Multi-drop buses only need to exist on the controller; the others are point-to-point and their counts add up.
|
|
16
|
+
const SHARED_BUSES = ['i2c', 'spi', 'can'];
|
|
17
|
+
const TRUSTED = ['measured', 'datasheet'];
|
|
18
|
+
const BASES = ['measured', 'datasheet', 'estimate', 'unknown'];
|
|
19
|
+
const range = v => Array.isArray(v) && v.length === 2 && v.every(positive) && v[0] <= v[1];
|
|
20
|
+
|
|
21
|
+
function validatePower(rows, label) {
|
|
22
|
+
if (!Array.isArray(rows)) invalid(`${label} must be an array`);
|
|
23
|
+
const ids = new Set();
|
|
24
|
+
for (const row of rows) {
|
|
25
|
+
keys(row, ['id', 'voltage_v'], ['connector', 'max_current_a'], label);
|
|
26
|
+
if (!slug(row.id) || ids.has(row.id) || !range(row.voltage_v) || (row.connector !== undefined && !slug(row.connector))
|
|
27
|
+
|| (row.max_current_a !== undefined && !positive(row.max_current_a))) invalid(`${label} entries need unique ids, ordered positive voltage ranges and slug connectors`);
|
|
28
|
+
ids.add(row.id);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// Strict consumer-side contract; the producer is the component database export (P2-04.03).
|
|
32
|
+
export function validateElectricalRecord(value) {
|
|
33
|
+
keys(value, ['schema', 'basis', 'power_inputs', 'power_outputs', 'buses'], [], 'electrical record');
|
|
34
|
+
if (value.schema !== ELECTRICAL_SCHEMA || !BASES.includes(value.basis)) invalid('unsupported electrical record schema or basis');
|
|
35
|
+
validatePower(value.power_inputs, 'electrical power_inputs');
|
|
36
|
+
validatePower(value.power_outputs, 'electrical power_outputs');
|
|
37
|
+
if (!Array.isArray(value.buses)) invalid('electrical buses must be an array');
|
|
38
|
+
for (const bus of value.buses) {
|
|
39
|
+
keys(bus, ['type', 'role', 'count'], ['logic_level_v'], 'electrical bus');
|
|
40
|
+
if (!BUS_TYPES.includes(bus.type) || !['controller', 'peripheral'].includes(bus.role) || !Number.isSafeInteger(bus.count) || bus.count < 1
|
|
41
|
+
|| (bus.logic_level_v !== undefined && !positive(bus.logic_level_v))) invalid('electrical bus needs a known type, role, positive count and positive logic level');
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
// Source-bound records only; an unbound or legacy record is reported as unknown, never trusted.
|
|
46
|
+
export function electricalFields(value) {
|
|
47
|
+
const record = value?.electrical, source = value?.electrical_source, id = value?.component_id ?? value?.record_id;
|
|
48
|
+
if (record === undefined && source === undefined) return {};
|
|
49
|
+
if (!object(source) || source.component_id !== id || !hash(source.revision) || !hash(source.snapshot_id)
|
|
50
|
+
|| Object.keys(source).some(key => !['component_id', 'revision', 'snapshot_id', 'evidence_ref'].includes(key))
|
|
51
|
+
|| (source.evidence_ref !== undefined && !text(source.evidence_ref))) return { electrical: null, electrical_source: null };
|
|
52
|
+
validateElectricalRecord(record);
|
|
53
|
+
if (source.revision !== digest({ component_id: id, ...record })) invalid('electrical source revision does not match candidate fields');
|
|
54
|
+
return { electrical: record, electrical_source: { ...source } };
|
|
55
|
+
}
|
|
56
|
+
const overlaps = (a, b) => a[0] <= b[1] && b[0] <= a[1];
|
|
57
|
+
const within = (inner, outer) => inner[0] >= outer[0] && inner[1] <= outer[1];
|
|
58
|
+
const isController = part => part.category_path?.[0] === 'compute-control' && ['controller-board', 'controller-module'].includes(part.category_path?.[1]);
|
|
59
|
+
const isPower = part => part.category_path?.[0] === 'power-energy';
|
|
60
|
+
const isLevelShifter = part => part.category_path?.[0] === 'electrical-interconnect' && part.category_path?.[1] === 'level-shifting';
|
|
61
|
+
const trusted = part => TRUSTED.includes(part.electrical?.basis);
|
|
62
|
+
const ref = part => ({ slot_id: part.slot_id, component_id: part.component_id });
|
|
63
|
+
|
|
64
|
+
// Supply match against trusted rails only. A missing rail is a supply gap (add a power part), never a reason to reject the consumer.
|
|
65
|
+
function supply(consumer, sources, rule, dimension) {
|
|
66
|
+
const parts = [consumer, ...sources].map(ref), inputs = consumer.electrical?.power_inputs ?? [];
|
|
67
|
+
if (!trusted(consumer) || !inputs.length) return { rule, dimension, status: 'unknown', parts, reason: trusted(consumer) ? 'NO_DECLARED_INPUT' : 'UNTRUSTED_OR_MISSING_DATA' };
|
|
68
|
+
const rails = sources.filter(trusted).flatMap(source => (source.electrical.power_outputs ?? []).map(output => ({ source, output })));
|
|
69
|
+
const fit = rails.find(({ output }) => inputs.some(input => within(output.voltage_v, input.voltage_v)));
|
|
70
|
+
if (fit) {
|
|
71
|
+
const input = inputs.find(row => within(fit.output.voltage_v, row.voltage_v));
|
|
72
|
+
const connectorRisk = Boolean(input.connector && fit.output.connector && input.connector !== fit.output.connector);
|
|
73
|
+
return { rule, dimension, status: connectorRisk ? 'risk' : 'pass', parts, reason: connectorRisk ? 'CONNECTOR_MISMATCH' : 'RAIL_WITHIN_INPUT',
|
|
74
|
+
rail: { component_id: fit.source.component_id, id: fit.output.id, voltage_v: fit.output.voltage_v, connector: fit.output.connector ?? null },
|
|
75
|
+
input: { id: input.id, voltage_v: input.voltage_v, connector: input.connector ?? null } };
|
|
76
|
+
}
|
|
77
|
+
if (sources.some(source => !trusted(source))) return { rule, dimension, status: 'unknown', parts, reason: 'UNTRUSTED_SOURCE_MAY_SUPPLY' };
|
|
78
|
+
const partial = rails.some(({ output }) => inputs.some(input => overlaps(output.voltage_v, input.voltage_v)));
|
|
79
|
+
return { rule, dimension, status: partial ? 'risk' : 'gap', parts, reason: partial ? 'RAIL_PARTIALLY_OUTSIDE_INPUT' : 'NO_SUPPLY_RAIL_FOR_INPUT' };
|
|
80
|
+
}
|
|
81
|
+
// fail (incompatible part) > gap (missing part) > pass (all checks pass on trusted data) > risk > unknown.
|
|
82
|
+
const aggregate = (checks, dimension, members) => {
|
|
83
|
+
const rows = checks.filter(check => check.dimension === dimension);
|
|
84
|
+
for (const status of ['fail', 'gap']) if (rows.some(check => check.status === status)) return status;
|
|
85
|
+
if (rows.length && rows.every(check => check.status === 'pass') && members.every(trusted)) return 'pass';
|
|
86
|
+
return rows.some(check => check.status === 'risk') ? 'risk' : 'unknown';
|
|
87
|
+
};
|
|
88
|
+
// parts: [{slot_id, component_id, category_path, quantity, electrical}] for the whole selection.
|
|
89
|
+
export function evaluateElectrical(parts) {
|
|
90
|
+
const method = 'electrical-star-v1', controllers = parts.filter(isController);
|
|
91
|
+
if (controllers.length !== 1) return { method, topology: 'not_star', dimensions: { power: 'unknown', interfaces: 'unknown' }, checks: [],
|
|
92
|
+
reasons: [{ code: controllers.length ? 'MULTIPLE_CONTROLLERS' : 'NO_CONTROLLER', controllers: controllers.map(ref) }] };
|
|
93
|
+
const [controller] = controllers, sources = parts.filter(isPower), shifter = parts.some(isLevelShifter);
|
|
94
|
+
const peripherals = parts.filter(part => part !== controller && !isPower(part) && !isLevelShifter(part));
|
|
95
|
+
const checks = [];
|
|
96
|
+
// Rule 1: a selected supply must power the controller. Without one the controller is powered from outside the selection.
|
|
97
|
+
if (sources.length) checks.push(supply(controller, sources, 'controller_supply', 'power'));
|
|
98
|
+
// Rule 2: each peripheral needs a controller rail or a selected supply rail inside one of its input ranges.
|
|
99
|
+
for (const part of peripherals) if (part.electrical?.power_inputs?.length || !trusted(part)) checks.push(supply(part, [controller, ...sources], 'peripheral_supply', 'power'));
|
|
100
|
+
// Rule 3: bus types the peripherals need must exist on the controller; point-to-point counts must also fit.
|
|
101
|
+
const need = new Map();
|
|
102
|
+
for (const part of peripherals) for (const bus of part.electrical?.buses ?? []) if (bus.role === 'peripheral')
|
|
103
|
+
need.set(bus.type, [...(need.get(bus.type) ?? []), { part, bus, count: bus.count * (part.quantity ?? 1) }]);
|
|
104
|
+
for (const part of peripherals) if (!trusted(part)) checks.push({ rule: 'bus_availability', dimension: 'interfaces', status: 'unknown', parts: [ref(part), ref(controller)], reason: 'UNTRUSTED_OR_MISSING_DATA' });
|
|
105
|
+
for (const [type, rows] of need) {
|
|
106
|
+
const parts2 = [ref(controller), ...rows.map(row => ref(row.part))];
|
|
107
|
+
if (!trusted(controller) || rows.some(row => !trusted(row.part))) { checks.push({ rule: 'bus_availability', dimension: 'interfaces', status: 'unknown', bus: type, parts: parts2, reason: 'UNTRUSTED_OR_MISSING_DATA' }); continue; }
|
|
108
|
+
const offered = (controller.electrical.buses ?? []).filter(bus => bus.type === type && bus.role === 'controller');
|
|
109
|
+
const available = offered.reduce((sum, bus) => sum + bus.count, 0);
|
|
110
|
+
const required = SHARED_BUSES.includes(type) ? 1 : rows.reduce((sum, row) => sum + row.count, 0);
|
|
111
|
+
checks.push({ rule: 'bus_availability', dimension: 'interfaces', bus: type, parts: parts2, required, available,
|
|
112
|
+
status: available >= required ? 'pass' : 'fail', reason: !available ? 'BUS_TYPE_MISSING' : available < required ? 'BUS_COUNT_SHORT' : 'BUS_AVAILABLE' });
|
|
113
|
+
// Rule 4: a logic-level mismatch is only a risk in v1; cables and level shifters can bridge it.
|
|
114
|
+
for (const row of rows) {
|
|
115
|
+
const level = offered.find(bus => bus.logic_level_v !== undefined)?.logic_level_v;
|
|
116
|
+
if (level === undefined || row.bus.logic_level_v === undefined) continue;
|
|
117
|
+
const mismatch = Math.abs(level - row.bus.logic_level_v) > 1e-9;
|
|
118
|
+
checks.push({ rule: 'logic_level', dimension: 'interfaces', bus: type, parts: [ref(controller), ref(row.part)], controller_v: level, peripheral_v: row.bus.logic_level_v,
|
|
119
|
+
status: mismatch && !shifter ? 'risk' : 'pass', reason: !mismatch ? 'LOGIC_LEVEL_MATCH' : shifter ? 'LEVEL_SHIFTER_SELECTED' : 'LOGIC_LEVEL_MISMATCH' });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const powerMembers = [controller, ...sources, ...peripherals.filter(part => checks.some(check => check.dimension === 'power' && check.parts.some(row => row.component_id === part.component_id)))];
|
|
123
|
+
return { method, topology: 'star', controller: ref(controller), checks,
|
|
124
|
+
dimensions: { power: aggregate(checks, 'power', powerMembers), interfaces: aggregate(checks, 'interfaces', [controller, ...peripherals]) } };
|
|
125
|
+
}
|