abelien-harness 0.9.0 → 0.10.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.
@@ -0,0 +1,192 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://git.abelien.cn/abelien/abelienharness/skills/abelien-algorithm/schemas/algorithm-candidates.v1.json",
4
+ "title": "abelien.algorithm-candidates.v1",
5
+ "description": "algorithm.candidates 的输入去掉 research_id(由宿主按本步检索结果填入)。宿主可以把本文件原样用作工具参数定义;字段与 scripts/candidates.mjs 的 CANDIDATE_FIELDS/NEEDS_FIELDS 一致,由测试保证。每个 reaction 要么至少一个候选,要么写进 unresolved。",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": [
9
+ "candidates"
10
+ ],
11
+ "properties": {
12
+ "candidates": {
13
+ "type": "array",
14
+ "maxItems": 40,
15
+ "description": "每个 reaction 1–3 个候选;违反硬约束的不提交。",
16
+ "items": {
17
+ "type": "object",
18
+ "additionalProperties": false,
19
+ "required": [
20
+ "reaction_id",
21
+ "implementation_ref",
22
+ "implementation_type",
23
+ "execution_target",
24
+ "summary"
25
+ ],
26
+ "properties": {
27
+ "reaction_id": {
28
+ "type": "string",
29
+ "description": "需求里的 reaction id。"
30
+ },
31
+ "implementation_ref": {
32
+ "type": "string",
33
+ "minLength": 1,
34
+ "maxLength": 80,
35
+ "description": "实现的简短名称(如 FastLED、Open-Meteo Forecast API、microWakeWord),不带网址、括号说明或用途;说明写进 summary。绑定时原样引用。"
36
+ },
37
+ "implementation_version": {
38
+ "type": [
39
+ "string",
40
+ "null"
41
+ ],
42
+ "maxLength": 60,
43
+ "description": "来源写明的确定版本(如 3.9.0、v2.1.0、One Call 3.0 的 3.0);来源没写就填 null,不填 latest、main。"
44
+ },
45
+ "implementation_type": {
46
+ "enum": [
47
+ "firmware",
48
+ "library",
49
+ "model",
50
+ "cloud_service"
51
+ ]
52
+ },
53
+ "execution_target": {
54
+ "enum": [
55
+ "edge",
56
+ "cloud",
57
+ "hybrid"
58
+ ]
59
+ },
60
+ "library_entry_id": {
61
+ "type": [
62
+ "string",
63
+ "null"
64
+ ],
65
+ "description": "库内已适配算法的 entry_id;库外候选省略或填 null。"
66
+ },
67
+ "summary": {
68
+ "type": "string",
69
+ "minLength": 1,
70
+ "maxLength": 500,
71
+ "description": "怎么用它实现这个 reaction。"
72
+ },
73
+ "needs": {
74
+ "type": "object",
75
+ "additionalProperties": false,
76
+ "description": "对硬件的需求,按来源原文记录;原文没写的填 null 或留空列表,不猜。",
77
+ "properties": {
78
+ "compute": {
79
+ "type": [
80
+ "string",
81
+ "null"
82
+ ],
83
+ "maxLength": 500
84
+ },
85
+ "memory": {
86
+ "type": [
87
+ "string",
88
+ "null"
89
+ ],
90
+ "maxLength": 500
91
+ },
92
+ "storage": {
93
+ "type": [
94
+ "string",
95
+ "null"
96
+ ],
97
+ "maxLength": 500
98
+ },
99
+ "runtime": {
100
+ "type": [
101
+ "string",
102
+ "null"
103
+ ],
104
+ "maxLength": 500
105
+ },
106
+ "network": {
107
+ "type": [
108
+ "string",
109
+ "null"
110
+ ],
111
+ "maxLength": 500
112
+ },
113
+ "interfaces": {
114
+ "type": "array",
115
+ "maxItems": 20,
116
+ "items": {
117
+ "type": "string",
118
+ "minLength": 1,
119
+ "maxLength": 120
120
+ },
121
+ "description": "每项一个接口的短名,如 I2C、GPIO、I2S microphone。"
122
+ },
123
+ "sensors": {
124
+ "type": "array",
125
+ "maxItems": 20,
126
+ "items": {
127
+ "type": "string",
128
+ "minLength": 1,
129
+ "maxLength": 120
130
+ }
131
+ }
132
+ }
133
+ },
134
+ "quotes": {
135
+ "type": "array",
136
+ "maxItems": 6,
137
+ "description": "逐字取自本次研究读到的正文(可以是为别的 reaction 读到的页面,同一份资料常常支撑几个 reaction);库外候选至少一条。",
138
+ "items": {
139
+ "type": "object",
140
+ "additionalProperties": false,
141
+ "required": [
142
+ "page_id",
143
+ "text"
144
+ ],
145
+ "properties": {
146
+ "page_id": {
147
+ "type": "string",
148
+ "description": "本次研究的页面 id,如 p3。"
149
+ },
150
+ "text": {
151
+ "type": "string",
152
+ "minLength": 8,
153
+ "maxLength": 400
154
+ }
155
+ }
156
+ }
157
+ }
158
+ }
159
+ }
160
+ },
161
+ "unresolved": {
162
+ "type": "array",
163
+ "description": "确实找不到候选的 reaction,每个只写一次。",
164
+ "items": {
165
+ "type": "object",
166
+ "additionalProperties": false,
167
+ "required": [
168
+ "reaction_id",
169
+ "reason"
170
+ ],
171
+ "properties": {
172
+ "reaction_id": {
173
+ "type": "string"
174
+ },
175
+ "reason": {
176
+ "type": "string",
177
+ "minLength": 1,
178
+ "maxLength": 500
179
+ }
180
+ }
181
+ }
182
+ },
183
+ "unknowns": {
184
+ "type": "array",
185
+ "items": {
186
+ "type": "string",
187
+ "minLength": 1,
188
+ "maxLength": 500
189
+ }
190
+ }
191
+ }
192
+ }
@@ -0,0 +1,119 @@
1
+ // algorithm.candidates(P2-03 算法并行车道):核验 Agent 读完 implementation 研究后为每个 reaction 提出的候选实现,
2
+ // 保存为 status=proposed 的 algorithm-research。这一步不依赖硬件,可在硬件选型进行中完成;与硬件的兼容核对留给
3
+ // algorithm.finalize 的 bind 操作。引用必须逐字出现在本次研究存下的正文里(规范化空白与全半角后比较);同一份实现资料
4
+ // 常常同时支撑几个 reaction,所以可以引用本次研究读到的任一页。只出现在检索摘要里的引用标为 search_snippet,可用于发现,
5
+ // 不能当作证据。一次提交里每个候选的问题一起报出,免得 Agent 一轮只改一处。
6
+ import { text, record, digest, failure } from "../../abelien-harness/scripts/web/common.mjs";
7
+ import { SCHEMA, IMPLEMENTATION_TYPES, EXECUTION_TARGETS, LIMITS, invalid, words, normalize, validateResearch } from "./evidence.mjs";
8
+
9
+ /** Field sets of schemas/algorithm-candidates.v1.json; a test keeps the two in step. */
10
+ export const CANDIDATE_FIELDS = Object.freeze(["reaction_id", "implementation_ref", "implementation_version", "implementation_type", "execution_target", "library_entry_id", "summary", "needs", "quotes"]);
11
+ export const NEEDS_FIELDS = Object.freeze(["compute", "memory", "storage", "runtime", "network", "interfaces", "sensors"]);
12
+ export const CANDIDATE_LIMITS = Object.freeze({ refChars: 80, versionChars: 60, interfaceChars: 120, issues: 8 });
13
+ const FLOATING = /^(latest|main|master|head|unknown|unversioned|stable|\*)$/i;
14
+ const LINK = /https?:\/\/|www\./i;
15
+
16
+ /** A stable name the binding and the PRD can repeat as is; where it came from and what it does belong in quotes and summary. */
17
+ function implementationRef(value, label) {
18
+ if (!text(value) || [...value.trim()].length > CANDIDATE_LIMITS.refChars || LINK.test(value) || /[\r\n]/.test(value))
19
+ invalid(`${label} must be a short name (1–${CANDIDATE_LIMITS.refChars} chars, no URL or explanation; put details in summary)`);
20
+ return value.trim();
21
+ }
22
+
23
+ /** The version the source itself states, or null when it states none; a floating label is not a version. */
24
+ function implementationVersion(value, label) {
25
+ if (value === undefined || value === null) return null;
26
+ if (!text(value) || [...value.trim()].length > CANDIDATE_LIMITS.versionChars || LINK.test(value) || FLOATING.test(value.trim()))
27
+ invalid(`${label} must be the definite version the source states (≤${CANDIDATE_LIMITS.versionChars} chars, not latest/main), or null`);
28
+ return value.trim();
29
+ }
30
+
31
+ function onlyKeys(value, keys, label) { if (!record(value) || Object.keys(value).some(key => !keys.includes(key))) invalid(`${label} has unsupported fields`); }
32
+
33
+ /** What the implementation needs from hardware, in the source's own words; unknown stays null, never guessed. */
34
+ function readNeeds(needs, label) {
35
+ onlyKeys(needs, NEEDS_FIELDS, label);
36
+ const out = {};
37
+ for (const key of ["compute", "memory", "storage", "runtime", "network"]) {
38
+ const value = needs[key];
39
+ if (value !== undefined && value !== null && (!text(value) || [...value].length > LIMITS.summaryChars)) invalid(`${label}.${key} must be null or 1–${LIMITS.summaryChars} chars`);
40
+ out[key] = value === undefined || value === null ? null : value.trim();
41
+ }
42
+ for (const key of ["interfaces", "sensors"]) {
43
+ const value = needs[key] ?? [];
44
+ if (!Array.isArray(value) || value.length > 20 || value.some(item => !text(item) || [...item].length > CANDIDATE_LIMITS.interfaceChars)) invalid(`${label}.${key} must list up to 20 strings of 1–${CANDIDATE_LIMITS.interfaceChars} chars`);
45
+ out[key] = value.map(item => item.trim());
46
+ }
47
+ return out;
48
+ }
49
+
50
+ export async function proposeAlgorithmCandidates({ input, readResource, readBlob }) {
51
+ onlyKeys(input, ["research_id", "candidates", "unresolved", "unknowns"], "input");
52
+ if (typeof readResource !== "function" || typeof readBlob !== "function") throw Object.assign(new Error("resource and content readers are required"), { code: "ALGORITHM_HOST" });
53
+ if (!text(input.research_id) || !input.research_id.startsWith("algorithm-research:")) invalid("research_id must be an algorithm-research resource ID");
54
+ const resource = await readResource(input.research_id);
55
+ if (!resource || resource.kind !== "algorithm-research") invalid("research_id does not name saved algorithm research");
56
+ const research = validateResearch(resource.body);
57
+ if (research.status !== "collected" || research.purpose !== "implementation") invalid("candidates are proposed from collected implementation research");
58
+ const reactionIds = new Set(research.reactions.map(item => item.id));
59
+ const libraryIds = new Set((research.library ?? []).map(item => item.entry_id));
60
+ const pages = new Map(research.pages.filter(page => page.status === "read").map(page => [page.id, page]));
61
+ const texts = new Map();
62
+ const pageText = pageId => {
63
+ if (!texts.has(pageId)) texts.set(pageId, normalize(String(readBlob(pages.get(pageId).text_blob_ref)?.text ?? "")));
64
+ return texts.get(pageId);
65
+ };
66
+ const candidates = Array.isArray(input.candidates) ? input.candidates : invalid("candidates must be an array");
67
+ if (candidates.length > LIMITS.candidates) invalid(`candidates must list up to ${LIMITS.candidates} entries`);
68
+ const covered = new Set(), issues = [];
69
+ // Each candidate reports its first problem; all of them come back together in one VALIDATION.
70
+ const collect = (fn, at) => {
71
+ try { return fn(); } catch (error) { if (error?.code !== "VALIDATION") throw error; issues.push(error.message.startsWith(at) ? error.message : `${at}: ${error.message}`); return null; }
72
+ };
73
+ const accepted = candidates.map((candidate, index) => collect(() => {
74
+ const at = `candidates[${index}]`;
75
+ onlyKeys(candidate, CANDIDATE_FIELDS, at);
76
+ if (!reactionIds.has(candidate.reaction_id)) invalid(`${at}.reaction_id must name a researched reaction`);
77
+ if (!IMPLEMENTATION_TYPES.includes(candidate.implementation_type)) invalid(`${at}.implementation_type must be one of ${IMPLEMENTATION_TYPES.join(", ")}`);
78
+ if (!EXECUTION_TARGETS.includes(candidate.execution_target)) invalid(`${at}.execution_target must be one of ${EXECUTION_TARGETS.join(", ")}`);
79
+ if (candidate.library_entry_id !== undefined && candidate.library_entry_id !== null && !libraryIds.has(candidate.library_entry_id)) invalid(`${at}.library_entry_id must name an in-library algorithm given to the research`);
80
+ const quotes = Array.isArray(candidate.quotes) ? candidate.quotes : [];
81
+ // An in-library algorithm may stand on the library entry alone; anything else needs source quotes.
82
+ if ((!quotes.length && !candidate.library_entry_id) || quotes.length > LIMITS.quotes) invalid(`${at}.quotes must list 1–${LIMITS.quotes} source quotes unless library_entry_id is given`);
83
+ const checked = quotes.map((quote, q) => {
84
+ const where = `${at}.quotes[${q}]`;
85
+ onlyKeys(quote, ["page_id", "text"], where);
86
+ const page = pages.get(quote.page_id);
87
+ if (!page) invalid(`${where}.page_id must name a read page of this research`);
88
+ const said = words(quote.text, `${where}.text`, LIMITS.quoteChars);
89
+ if ([...normalize(said)].length < LIMITS.quoteMinChars) invalid(`${where}.text must be at least ${LIMITS.quoteMinChars} characters`);
90
+ if (!pageText(quote.page_id).includes(normalize(said))) invalid(`${where}.text must appear verbatim in page ${quote.page_id}`);
91
+ return { page_id: page.id, url: page.final_url ?? page.url, text: said, text_source: page.text_source };
92
+ });
93
+ const out = { reaction_id: candidate.reaction_id, implementation_ref: implementationRef(candidate.implementation_ref, `${at}.implementation_ref`),
94
+ implementation_version: implementationVersion(candidate.implementation_version, `${at}.implementation_version`),
95
+ implementation_type: candidate.implementation_type, execution_target: candidate.execution_target, library_entry_id: candidate.library_entry_id ?? null,
96
+ summary: words(candidate.summary, `${at}.summary`, LIMITS.summaryChars), needs: readNeeds(candidate.needs ?? {}, `${at}.needs`), quotes: checked,
97
+ evidence: checked.some(quote => quote.text_source === "page") ? "page" : candidate.library_entry_id ? "library" : "search_snippet" };
98
+ covered.add(candidate.reaction_id);
99
+ return out;
100
+ }, `candidates[${index}]`));
101
+ const unresolved = Array.isArray(input.unresolved) ? input.unresolved : [];
102
+ const unresolvedIds = new Set();
103
+ const gaps = unresolved.map((item, index) => collect(() => {
104
+ onlyKeys(item, ["reaction_id", "reason"], `unresolved[${index}]`);
105
+ if (!reactionIds.has(item.reaction_id) || covered.has(item.reaction_id) || unresolvedIds.has(item.reaction_id)) invalid(`unresolved[${index}].reaction_id must name a researched reaction without candidates, once`);
106
+ unresolvedIds.add(item.reaction_id);
107
+ return { reaction_id: item.reaction_id, reason: words(item.reason, `unresolved[${index}].reason`, LIMITS.summaryChars) };
108
+ }, `unresolved[${index}]`));
109
+ // A reaction whose only candidate was rejected above is reported there, not again here.
110
+ const rejected = new Set(candidates.filter((_, index) => accepted[index] === null).map(candidate => candidate?.reaction_id));
111
+ for (const id of reactionIds) if (!covered.has(id) && !unresolvedIds.has(id) && !rejected.has(id)) issues.push(`reaction ${id} needs at least one candidate or an unresolved reason`);
112
+ if (issues.length) throw failure("VALIDATION", [...issues.slice(0, CANDIDATE_LIMITS.issues), ...(issues.length > CANDIDATE_LIMITS.issues ? [`and ${issues.length - CANDIDATE_LIMITS.issues} more`] : [])].join("; "));
113
+ const unknowns = Array.isArray(input.unknowns) ? input.unknowns.map((item, index) => words(item, `unknowns[${index}]`, LIMITS.summaryChars)) : [];
114
+ const stable = { collected_id: resource.id, candidates: accepted, unresolved: gaps, unknowns };
115
+ const id = `alg-prop-${digest(stable).slice(0, 16)}`;
116
+ return validateResearch({ id, schema: SCHEMA, status: "proposed", request_digest: research.request_digest, purpose: research.purpose, collected_id: resource.id,
117
+ context: research.context, reactions: research.reactions, limits: research.limits, library: research.library, hardware: null,
118
+ searches: [], pages: research.pages, candidates: accepted, unresolved: gaps, unknowns: [...research.unknowns, ...unknowns] });
119
+ }
@@ -0,0 +1,69 @@
1
+ // algorithm-research v1 的字段定义与校验(P2-03 算法并行车道)。研究分两种:implementation 只凭需求检索实现
2
+ // (与硬件选型并行),hardware 在硬件登记后检索已选型号的厂商资料。页面正文由 Harness 自己读取并按 sha256 存证,
3
+ // algorithm.finalize 的 bind 操作只认这里存下的正文,不认 Agent 自述。
4
+ import { createPublicReader } from "../../abelien-harness/scripts/web/public-read.mjs";
5
+ import { createVolcSearch } from "../../abelien-harness/scripts/web/volc.mjs";
6
+ import { redact } from "../../abelien-harness/scripts/web/redact.mjs";
7
+ import { failure, text, record } from "../../abelien-harness/scripts/web/common.mjs";
8
+
9
+ export const SCHEMA = "abelien.algorithm-research.v1";
10
+ export const PURPOSES = Object.freeze(["implementation", "hardware"]);
11
+ export const STATUSES = Object.freeze(["in_progress", "collected", "proposed"]);
12
+ export const IMPLEMENTATION_TYPES = Object.freeze(["firmware", "library", "model", "cloud_service"]);
13
+ export const EXECUTION_TARGETS = Object.freeze(["edge", "cloud", "hybrid"]);
14
+ /** Version of the collected shape, part of every request digest: a shape change collects afresh under a new id. */
15
+ export const RESEARCH_SHAPE = 1;
16
+ export const LIMITS = Object.freeze({ queries: 8, count: 5, pages: 12, library: 50, reactions: 20, textChars: 2000, queryChars: 200,
17
+ candidates: 40, quotes: 6, quoteChars: 400, quoteMinChars: 8, summaryChars: 500,
18
+ pageBytes: 8 * 1024 * 1024, excerptChars: 1200, pageTimeoutMs: 25000, pageConnectTimeoutMs: 8000, pageRoundMs: 90000, searchTimeoutMs: 30000, concurrency: 4 });
19
+
20
+ export const CODES = Object.freeze({ transport: "ALGORITHM_WEB_TRANSPORT", dns: "ALGORITHM_WEB_DNS", access: "ALGORITHM_WEB_URL", redirect: "ALGORITHM_WEB_PAGE",
21
+ timeout: "ALGORITHM_WEB_TIMEOUT", url: "ALGORITHM_WEB_URL", config: "ALGORITHM_WEB_CONFIG", readTimeout: "ALGORITHM_PUBLIC_READ_TIMEOUT",
22
+ capability: "ALGORITHM_WEB_CAPABILITY", provider: "ALGORITHM_WEB_PROVIDER", auth: "ALGORITHM_WEB_AUTH", rateLimit: "ALGORITHM_WEB_RATE_LIMIT", response: "ALGORITHM_WEB_RESPONSE" });
23
+
24
+ export const reader = createPublicReader({ codes: CODES, userAgent: "Abelien-Algorithm-Evidence/1", accept: "text/html,text/plain,text/markdown,application/xhtml+xml", decodeCharset: true });
25
+ export const volc = createVolcSearch({ codes: CODES, ownsError: error => Boolean(error?.code?.startsWith("ALGORITHM_WEB_")),
26
+ redact: (raw, env = {}) => redact(raw, [env.WEB_SEARCH_API_KEY].filter(text)), timeoutMessage: "algorithm network request timed out" });
27
+
28
+ export const invalid = message => { throw failure("VALIDATION", message); };
29
+ export const words = (value, label, max = LIMITS.textChars) => {
30
+ if (!text(value) || [...value].length > max) invalid(`${label} must be 1–${max} chars`);
31
+ return value.trim();
32
+ };
33
+ // NFKC folds full-width forms before whitespace collapses, as reference.finalize does for its quotes.
34
+ export const normalize = value => value.normalize("NFKC").replace(/\s+/g, " ").trim();
35
+
36
+ /**
37
+ * The research reads clarify's requirements as they are: reaction[] {id, text} and limits[] with strength; only the
38
+ * fields the research needs are kept, so the request digest does not move with unrelated requirement edits.
39
+ */
40
+ export function readRequirements(requirements) {
41
+ if (!record(requirements)) invalid("requirements must be clarify's Requirements object");
42
+ const reactions = Array.isArray(requirements.reaction) ? requirements.reaction : [];
43
+ if (!reactions.length || reactions.length > LIMITS.reactions) invalid(`requirements.reaction must list 1–${LIMITS.reactions} reactions`);
44
+ const ids = new Set();
45
+ const out = reactions.map((reaction, index) => {
46
+ if (!record(reaction) || !text(reaction.id) || ids.has(reaction.id)) invalid(`requirements.reaction[${index}] needs a unique id`);
47
+ ids.add(reaction.id);
48
+ return { id: reaction.id, text: words(reaction.text, `requirements.reaction[${index}].text`) };
49
+ });
50
+ const limits = Array.isArray(requirements.limits) ? requirements.limits : [];
51
+ const context = record(requirements.context) ? requirements.context.text ?? null : typeof requirements.context === "string" ? requirements.context : null;
52
+ return { context: text(context) ? context.trim() : null, reactions: out,
53
+ limits: limits.filter(record).map(limit => ({ id: limit.id ?? null, text: limit.text ?? null, strength: limit.strength ?? null })) };
54
+ }
55
+
56
+ export function validateResearch(body) {
57
+ if (!record(body) || body.schema !== SCHEMA || !STATUSES.includes(body.status) || !text(body.id) || !PURPOSES.includes(body.purpose)) invalid("algorithm research has an invalid schema, id, status or purpose");
58
+ for (const key of ["reactions", "searches", "pages", "unknowns"]) if (!Array.isArray(body[key])) invalid(`algorithm research ${key} must be an array`);
59
+ for (const [index, page] of body.pages.entries()) {
60
+ const at = `pages[${index}]`;
61
+ if (!record(page) || page.id !== `p${index + 1}` || !text(page.url) || !["read", "unavailable"].includes(page.status)) invalid(`${at} has an invalid id, url or status`);
62
+ if (page.status === "read" && !(/^sha256:[0-9a-f]{64}$/.test(page.text_blob_ref ?? "") && /^[0-9a-f]{64}$/.test(page.text_sha256 ?? "") && ["page", "search_snippet"].includes(page.text_source)))
63
+ invalid(`${at} is read but lacks its saved text, hash or text_source`);
64
+ }
65
+ if (body.status === "proposed") {
66
+ if (!text(body.collected_id) || !Array.isArray(body.candidates) || !Array.isArray(body.unresolved)) invalid("proposed research needs collected_id, candidates and unresolved");
67
+ }
68
+ return body;
69
+ }
@@ -27,22 +27,75 @@ function array(value, name) {
27
27
  return value;
28
28
  }
29
29
 
30
- export async function finalizeAlgorithmSelection({ input, readResource, readEvidence }) {
31
- const research = input?.research;
32
- if (!text(input?.hardware_selection_id) || !text(input?.hardware_selection_revision)) throw error("hardware selection id and revision are required");
30
+ /** Field sets of schemas/algorithm-bindings.v1.json (what the Agent submits as `research` for operation=bind); a test keeps them in step. */
31
+ export const PRIMARY_SOURCE_TYPES = Object.freeze(["hardware_vendor", "implementation_official", "huggingface", "cloud_vendor"]);
32
+ export const COMPATIBILITY_KEYS = Object.freeze(["io", "runtime", "dependencies", "network", "license", "resources"]);
33
+ export const CHECK_STATUSES = Object.freeze(["pass", "conflict", "unknown"]);
34
+ export const BIND_FIELDS = Object.freeze({
35
+ research: ["bindings", "sources", "unknowns", "blocking_unknowns", "unavailable_sources"],
36
+ binding: ["reaction_id", "implementation_ref", "implementation_version", "implementation_type", "execution_target", "hardware_component_ids", "source_ids", "compatibility", "constraint_checks"],
37
+ check: ["status", "summary", "source_ids"],
38
+ constraint: ["constraint_id", "status", "summary", "source_ids"],
39
+ source: ["id", "research_id", "page_id", "source_type", "title", "publisher", "version", "excerpt", "hardware_component_ids", "implementation_ref"],
40
+ });
41
+ const RECEIPT_FIELDS = ["url", "accessed_at", "content_sha256", "evidence_ref"];
42
+
43
+ /**
44
+ * operation=bind(P2-03 算法并行车道):候选来自 algorithm.candidates 保存的 proposed 研究。来源只写 research_id 与
45
+ * page_id,回执字段(url、accessed_at、content_sha256、evidence_ref)由 Harness 从自己存下的研究正文填写,提交里的同名字段
46
+ * 一律丢弃(检索摘要不算);宿主注入的 readEvidence 仍然优先用于核对。每条绑定须选该 reaction 已提议的候选之一。
47
+ * 硬件基线可以尚未 verified:此时照常核对,但结果固定为 needs_confirmation,并写明原因。
48
+ */
49
+ async function prepareBind(input, { readResource, readBlob, readEvidence }) {
50
+ if (!text(input.proposed_id) || !input.proposed_id.startsWith("algorithm-research:")) throw error("bind needs proposed_id of saved algorithm candidates");
51
+ if (typeof readBlob !== "function") throw error("bind needs the store content reader");
52
+ const proposed = await readResource(input.proposed_id);
53
+ if (!proposed || proposed.kind !== "algorithm-research" || proposed.body?.status !== "proposed" || !text(proposed.body.collected_id)) throw error("proposed_id does not name proposed algorithm candidates");
54
+ const allowed = async id => {
55
+ if (id === proposed.body.collected_id) return true;
56
+ const resource = await readResource(id);
57
+ return resource?.kind === "algorithm-research" && resource.body?.status === "collected" && resource.body.purpose === "hardware"
58
+ && resource.body.hardware?.selection_id === input.hardware_selection_id && resource.body.hardware?.revision === input.hardware_selection_revision;
59
+ };
60
+ const storeEvidence = async ref => {
61
+ const match = /^(algorithm-research:[A-Za-z0-9][A-Za-z0-9_-]{0,127})#(p\d{1,3})$/.exec(ref ?? "");
62
+ if (!match || !await allowed(match[1])) return null;
63
+ const page = (await readResource(match[1]))?.body?.pages?.find(item => item?.id === match[2]);
64
+ if (!page || page.status !== "read" || page.text_source !== "page" || !text(page.fetched_at)) return null;
65
+ return { url: page.final_url ?? page.url, content_sha256: page.text_sha256, fetched_at: page.fetched_at, content: String(readBlob(page.text_blob_ref)?.text ?? "") };
66
+ };
67
+ /** The source as submitted, minus any receipt fields, plus the receipt of the saved page it names (none when it names no read page). */
68
+ const withReceipt = async source => {
69
+ if (!record(source)) return source;
70
+ const rest = Object.fromEntries(Object.entries(source).filter(([key]) => !RECEIPT_FIELDS.includes(key)));
71
+ const ref = text(source.research_id) && text(source.page_id) ? `${source.research_id}#${source.page_id}` : null;
72
+ const page = ref ? await storeEvidence(ref) : null;
73
+ return page ? { ...rest, url: page.url, accessed_at: page.fetched_at, content_sha256: page.content_sha256, evidence_ref: ref } : rest;
74
+ };
75
+ return { readEvidence: readEvidence ?? storeEvidence, allowUnverified: true, proposed, withReceipt };
76
+ }
77
+
78
+ export async function finalizeAlgorithmSelection({ input, readResource, readEvidence, readBlob }) {
79
+ if (input?.operation !== undefined && !["select", "bind"].includes(input.operation)) throw error("operation must be select or bind");
33
80
  if (typeof readResource !== "function") throw error("store-backed hardware baseline reader is required");
81
+ const bind = input?.operation === "bind" ? await prepareBind(input, { readResource, readBlob, readEvidence }) : null;
82
+ if (bind) readEvidence = bind.readEvidence;
83
+ const research = bind && record(input?.research) && !Array.isArray(input.research.candidates)
84
+ ? { ...input.research, candidates: bind.proposed.body.candidates } : input?.research;
85
+ if (!text(input?.hardware_selection_id) || !text(input?.hardware_selection_revision)) throw error("hardware selection id and revision are required");
34
86
  const baseline = await readResource(input.hardware_selection_id);
35
- if (!baseline || baseline.kind !== "hardware-selection" || baseline.body?.status !== "verified") throw error("hardware baseline must be an existing verified hardware-selection resource");
87
+ if (!baseline || baseline.kind !== "hardware-selection" || (baseline.body?.status !== "verified" && !bind?.allowUnverified)) throw error("hardware baseline must be an existing verified hardware-selection resource");
36
88
  if (baseline.body.revision !== input.hardware_selection_revision) throw error("hardware selection revision is stale");
37
89
  const models = baseline.body.selected_models;
38
90
  if (!Array.isArray(models) || !models.length || models.some(item => !text(item?.component_id) || !text(item?.manufacturer) || !text(item?.model))) throw error("hardware baseline needs exact component IDs, manufacturers and models");
39
91
  if (!record(research)) throw error("research is required");
40
92
  const bindings = array(research.bindings, "bindings");
41
93
  const candidates = array(research.candidates, "candidates");
42
- const sources = array(research.sources, "sources");
94
+ const sources = bind ? await Promise.all(array(research.sources, "sources").map(bind.withReceipt)) : array(research.sources, "sources");
43
95
  const unavailableSources = array(research.unavailable_sources, "unavailable_sources");
44
96
  const unknowns = array(research.unknowns, "unknowns");
45
97
  const blockingUnknowns = [...array(research.blocking_unknowns, "blocking_unknowns")];
98
+ if (bind && baseline.body.status !== "verified") blockingUnknowns.push(`hardware baseline is ${baseline.body.status ?? "unverified"}; the binding stays to be confirmed`);
46
99
  const issues = [];
47
100
  const check = (condition, path, message) => { if (!condition) issues.push({ path, message }); };
48
101
  const hardwareIds = new Set(models.map(item => item.component_id));
@@ -69,7 +122,7 @@ export async function finalizeAlgorithmSelection({ input, readResource, readEvid
69
122
  if (text(limit?.id)) constraints.add(limit.id);
70
123
  }
71
124
  const sourceMap = new Map();
72
- const primaryTypes = new Set(["hardware_vendor", "implementation_official", "huggingface", "cloud_vendor"]);
125
+ const primaryTypes = new Set(PRIMARY_SOURCE_TYPES);
73
126
  let retrievalVerified = sources.length > 0;
74
127
  for (const [index, source] of sources.entries()) {
75
128
  const at = `sources[${index}]`;
@@ -94,6 +147,8 @@ export async function finalizeAlgorithmSelection({ input, readResource, readEvid
94
147
  if (!record(binding)) continue;
95
148
  seen.add(binding.reaction_id);
96
149
  check(text(binding.implementation_ref) && pinned(binding.implementation_version), at, "implementation needs a stable reference and pinned version");
150
+ if (bind) check(values(bind.proposed.body.candidates).some(item => item?.reaction_id === binding.reaction_id && item.implementation_ref === binding.implementation_ref),
151
+ at, "binding must pick one of the proposed candidates of its reaction (same implementation_ref)");
97
152
  check(["firmware", "library", "model", "cloud_service"].includes(binding.implementation_type), at, "implementation_type is invalid");
98
153
  check(["edge", "cloud", "hybrid"].includes(binding.execution_target), at, "execution_target is invalid");
99
154
  const ids = values(binding.hardware_component_ids);
@@ -102,7 +157,7 @@ export async function finalizeAlgorithmSelection({ input, readResource, readEvid
102
157
  const cited = values(binding.source_ids).map(id => sourceMap.get(id)).filter(Boolean);
103
158
  for (const id of ids) check(cited.some(source => source.source_type === "hardware_vendor" && values(source.hardware_component_ids).includes(id)), at, `missing exact-device manufacturer evidence: ${id}`);
104
159
  check(cited.some(source => ["implementation_official", "huggingface", "cloud_vendor"].includes(source.source_type) && source.implementation_ref === binding.implementation_ref && source.version === binding.implementation_version), at, "missing matching implementation-version evidence");
105
- for (const key of ["io", "runtime", "dependencies", "network", "license", "resources"]) {
160
+ for (const key of COMPATIBILITY_KEYS) {
106
161
  const evidence = binding.compatibility?.[key];
107
162
  check(evidence?.status === "pass" && text(evidence.summary) && sourceRefs(evidence.source_ids), `${at}.compatibility.${key}`, "compatibility requires a passing evidence-backed assessment");
108
163
  }
@@ -127,6 +182,7 @@ export async function finalizeAlgorithmSelection({ input, readResource, readEvid
127
182
  hardware_selection_revision: input.hardware_selection_revision,
128
183
  hardware_selection_digest: baseline.meta?.digest ?? `sha256:${digest(baseline.body)}`,
129
184
  requirements: input.requirements ?? {}, reactions, research, quality_issues: issues,
185
+ ...(bind ? { operation: "bind", proposed_id: input.proposed_id } : {}),
130
186
  };
131
187
  const hash = digest(stable);
132
188
  return {
@@ -135,6 +191,7 @@ export async function finalizeAlgorithmSelection({ input, readResource, readEvid
135
191
  hardware_selection_id: input.hardware_selection_id,
136
192
  hardware_selection_revision: input.hardware_selection_revision,
137
193
  hardware_selection_digest: stable.hardware_selection_digest,
194
+ ...(bind ? { operation: "bind", proposed_id: input.proposed_id } : {}),
138
195
  source_snapshot: `research:${hash.slice(0, 16)}`,
139
196
  revision: `r-${hash.slice(0, 12)}`,
140
197
  bindings, candidates, sources,
@@ -0,0 +1,27 @@
1
+ // algorithm.lane-state(P2-03 算法并行车道):只读。宿主在每个自动阶段开跑前调用,按本草稿工作目录里已存的资源回答
2
+ // 「当前需求有没有已提议的候选」「这些候选有没有绑定到当前硬件」。需求是否相同按 algorithm.research 的同一规范化规则
3
+ // 判断(落点、reaction、limits),宿主不必复制这条规则;结果映射成 workspace.lanes-next 的 artifacts。
4
+ import { text, record, digest } from "../../abelien-harness/scripts/web/common.mjs";
5
+ import { readRequirements, invalid } from "./evidence.mjs";
6
+
7
+ const sameRequirements = (body, wanted) => digest({ context: body.context ?? null, reactions: body.reactions ?? [], limits: body.limits ?? [] }) === digest(wanted);
8
+ const newest = list => [...list].sort((a, b) => String(b.meta?.updatedAt ?? "").localeCompare(String(a.meta?.updatedAt ?? "")))[0] ?? null;
9
+
10
+ export async function algorithmLaneState({ input, listResources }) {
11
+ if (!record(input) || Object.keys(input).some(key => !["requirements", "hardware_selection_id", "hardware_selection_revision"].includes(key))) invalid("input accepts requirements, hardware_selection_id and hardware_selection_revision only");
12
+ if (typeof listResources !== "function") throw Object.assign(new Error("store listing is required"), { code: "ALGORITHM_HOST" });
13
+ const hasHardware = input.hardware_selection_id !== undefined && input.hardware_selection_id !== null;
14
+ if (hasHardware && (!text(input.hardware_selection_id) || !input.hardware_selection_id.startsWith("hardware-selection:") || !text(input.hardware_selection_revision)))
15
+ invalid("hardware_selection_id must be a hardware-selection resource ID with its revision");
16
+ const normalized = readRequirements(input.requirements);
17
+ const wanted = { context: normalized.context, reactions: normalized.reactions, limits: normalized.limits };
18
+ const proposed = newest(listResources("algorithm-research").filter(item => item.body?.status === "proposed" && sameRequirements(item.body, wanted)));
19
+ const selection = proposed && hasHardware ? newest(listResources("algorithm-selection").filter(item => item.body?.operation === "bind" && item.body.proposed_id === proposed.id
20
+ && item.body.hardware_selection_id === input.hardware_selection_id && item.body.hardware_selection_revision === input.hardware_selection_revision)) : null;
21
+ return {
22
+ requirements_digest: digest(wanted),
23
+ proposed_id: proposed?.id ?? null,
24
+ selection_id: selection?.id ?? null,
25
+ selection_status: selection?.body.status ?? null,
26
+ };
27
+ }