From 2be2ae1529ea0e423b14f95ae76c123ac9fcc77f Mon Sep 17 00:00:00 2001 From: chamsechan Date: Sat, 3 Oct 2026 23:24:51 +0800 Subject: [PATCH 01/29] docs: record architecture review and adoption criteria --- plans/ARCHITECTURE_REVIEW.md | 393 +++++++++++++++++++++++++++++++++++ 1 file changed, 393 insertions(+) create mode 100644 plans/ARCHITECTURE_REVIEW.md diff --git a/plans/ARCHITECTURE_REVIEW.md b/plans/ARCHITECTURE_REVIEW.md new file mode 100644 index 00000000..cd839a40 --- /dev/null +++ b/plans/ARCHITECTURE_REVIEW.md @@ -0,0 +1,393 @@ +# 架构审查:结论与优化建议 + +| 项 | 内容 | +| --- | --- | +| 状态 | 审查与复核完成;建议已分级,改进项均未实施;边界设计见[第六节](#六待决定事项) | +| 基线 | `main@a664e37`(2026-10-03) | +| 范围 | 审查四层源码、构建与分层守卫、测试和工具;本次仅修订审查文档,未修改代码 | +| 约束 | 保持 Operator 函数表、宿主结构体、槽位名、错误码枚举集合、`.conf` 和 Pipeline JSON 格式;错误码返回语义的调整须单独决策,不能视为内部重构 | + +本文件按 [plans/ 的规则](README.md)存放:它是工作文档,不是现行规则。各项落地后,把形成的规则写进对应的现行指南; +全部完成或转为 RFC 后删除本文件。文中行号以基线为准。 + +**审查标准**(来自需求方): + +1. 对外契约不变:这是平台调用本框架生成的库的固定方式。 +2. 每一层新增内容都方便,例如新增统一接口的 Node、新增 Backend。 +3. 抽象层级之间相对隔离。 +4. 框架处理大部分调度,使用者具备初步 C++ 和少量框架知识即可开发业务。 +5. 框架本身容易理解:没有不必要的概念,没有可以合并或抽象却散落各处的代码。 + +**验证方式**:标"已复现"的两项用现有 `build/` 产物实际运行,步骤见[附录](#附录复现步骤)。 +复核还执行了 10 个相关测试,全部通过,筛选命令见[附录 C](#c-复核测试)。其余判断来自基线代码、现行指南和测试断言。 +这些证据不代表真实厂商运行时或目标硬件验收;`include/platform_mock/` 中的声明只是仓库约定, +真实 SDK 的契约只能在授权内网中核验。 + +## 结论 + +整体架构合理,不需要推倒重来。主线是:Operator 门面 → 校验后不可变的执行计划 → 带类型端口的节点 DAG → +模型能力接口 → 后端执行协议。每一段职责清楚,异常拦截、输出池回滚、厂商头文件隔离都做得扎实。 +应采纳核心问题判断,按采纳条件逐项实施;本文不是已批准的整体重构方案。 + +| 分级 | 项目 | 处理原则 | +| --- | --- | --- | +| 优先修复 | 端口数量关系误判;通用 Node 承担外部响应序列化 | 修复合法流程与职责边界,保留来源检查和现有响应格式 | +| 优先决策 | 错误码的公开语义;致命错误与单条业务无效结果的区别 | 明确行为与验收后再实施,不能按内部重构处理 | +| 后续重构 | Map/Batch 生命周期、端口绑定、配置声明与实例状态等重复 | 逐项证明收益,保持诊断、所有权和并发契约 | +| 保留或暂缓 | 转换器契约元数据、自定义 allocator、原子快照、Model 来源接口 | 不因当前只有测试使用或外层已有锁就删除 | + +新增 Node 已经很顺手;Model、Backend 和外部业务接入仍有简化空间,但扩展步骤多并不等于其中的契约检查冗余。 + +**建议保留的设计**: + +- 门面的 `noexcept` 与双重 catch、先解码再租用输出、租约自动回滚、输出 deleter 只持有弱引用。 +- 校验一次生成不可变计划,Pipeline 只消费不重算;模型批量原子注册;构建状态机。 +- "普通函数 + Spec"的节点写法和 `MakeLlmTextSpec`;签名错误有编译期提示;业务目录源码自动收录。 +- 参数快照的原子发布、失败回滚、旧快照存活和整批配置一致性。 +- Model 与 Backend 分离,经中性执行协议对接;厂商头文件只出现在 Backend 的源文件里。 +- Catalog 作为唯一事实来源,Studio 经 CLI 调用 Validator,不复制规则。 + +## 一、优先处理的问题与契约决策 + +### 1. 内部错误码原样返回给宿主(已复现) + +仓库的 mock 错误码定义了 0、-1…-7、-99、-100([error_codes.h](../include/platform_mock/error_codes.h)), +`Process` 还会把 Pipeline 的返回值原样交给宿主([operator_adapter.cpp:388](../src/adapter/operator/operator_adapter.cpp#L388))。 +这存在数值碰撞和故障分类不清的问题,但不能据此断言真实平台只有上述错误码。 + +- **实测**:一个 `StructuredJsonParseNode(failure_policy=fail)` 的方案,`ops.Process` 返回 **-6102**,不在枚举里(附录 A)。 +- **会撞上已有错误码**: + - Qwen 推理失败返回 -1([qwen_causal_lm_model.cpp:146-149](../src/engine/models/qwen_causal_lm/qwen_causal_lm_model.cpp#L146-L149)), + 经 [model_calls.h:52-59](../include/nodes/model_calls.h#L52-L59) 和 [function_node.h:1637](../include/nodes/function_node.h#L1637) + 原样上传;宿主若只按 mock 枚举解释,会误判为"无效句柄"。 + - 批处理执行器捕获异常返回 -4([fixed_batch_executor.h:122](../include/engine/fixed_batch_executor.h#L122)), + 与门面表示"输出容量不足"的 -4 相同。 + - Create 时配置校验失败、模型加载失败都返回 -3"输入非法" + ([io_binding_resolver.cpp:221](../src/adapter/io_binding_resolver.cpp#L221)、 + [shared_algorithm_runtime.cpp:131](../src/adapter/shared_algorithm_runtime.cpp#L131))。 +- **现行契约**:[宿主调用与生命周期](../doc/dev_guide/operator_output_allocation.md#宿主调用与生命周期)明确允许透传, + 并要求结合诊断判断故障层;[契约测试](../tests/contract/abi/test_adapter_contract_security.cpp#L262)还断言 + 模型错误 -731 原样返回。因此,映射属于公开返回行为的调整,不能称为"不涉及外部契约"的局部修复。 +- **采纳方向**:在接入适配层集中定义公开错误映射,按失败阶段或类别分类,原始码、节点及所在层级保留在 + `GetOperatorLastError()`。不能只按整数映射:模型执行的 -4 与输出容量的 -4 含义不同。 + 现有 mock 枚举没有单独的普通执行失败类别;在保持枚举集合的约束下,目标码及其解释须先决定(见第六节)。 +- **验收**:同步现行指南与错误码契约测试,覆盖 Create、输入转换、节点/模型执行、输出容量、Control 和异常路径; + 核实宿主看到的分类不会因内部错误码碰撞而变化。真实 SDK 对接仍需另行核验,不能承诺仅改一个映射函数即可完成。 + +### 2. 端口数量关系校验会拒绝合法流程(已复现) + +[pipeline_validator.cpp:104-110](../src/core/pipeline_validator.cpp#L104-L110) 要求生产端和消费端的 cardinality +一致(除非一方是 `N:M`,或消费端是 `N:1`)。现有声明混用了节点自身的数量转换关系与边上数据的数量约束, +按字符串直接比较会拒绝可合法执行的组合: + +- **拆分后接不了逐项节点**:`TextChunkNode → TextRuleMatchNode` 报 `PORT_CARDINALITY_MISMATCH`(附录 B)。 + RuleMatch 逐条处理并保留来源编号 (req_id, sub_id),这个流程本来是正确的。 +- **自定义 Map 节点同样受限**:`MakeMapSpec` 把输入写死为 `1:1`([function_node.h:309](../include/nodes/function_node.h#L309)), + 不能放在任何拆分节点之后。按同一逻辑,聚合输出(`N:1`)也接不了逐项节点。 +- **Embedding 能接在拆分之后,是因为它声明了 `N:M`**,跳过了这项 cardinality 比较;类型、来源和存活期检查仍在。 +- **来源和存活期需分别评价**:当前生产消费者多声明 `preserve` 或 `aggregate`,静态 provenance 比较通常放行; + 这不代表运行时来源校验无用。`global` 和 `independent` 暂无生产声明,`session` 则用于语料和 Embedding 缓存, + 不能把整套检查一起删除。 + +**采纳方向**:区分节点的转换关系与边上的数据形状。逐项节点接收任意条目批次,并保留输入的数量、顺序及 +`(req_id, sub_id)`;拆分和聚合显式声明输出关系;出口仍须满足业务要求。不能仅把旧字符串替换为 +"每请求一条、每请求多条、句柄共享"三种标签:数量、来源关系和存活期是不同维度。 + +**设计与验收**:补齐空结果、可选输入、多输入对齐、共享候选、拆分后的逐项变换和聚合后再变换的规则; +保留真正不兼容的出口和来源错误,不能用普遍声明 `N:M` 或关闭检查来绕过。Pipeline JSON 格式保持不变, +Definition、Catalog、Validator、节点运行时及工具的解释须一致。完整形状推导属于跨层设计,不能预先归为局部修复。 +[框架简化计划](FRAMEWORK_SIMPLIFICATION_PLAN.md)第 8 节的阶段 4(跨请求状态)准备在这套字符串上再加 `shared` 和 +`provenance_config_field`,应先明确这里的数量与来源语义,再推进状态工作;无需等待第三节所有重构完成。 + +### 3. 外部响应格式由通用节点生成 + +`TextRuleMatchNode` 自己拼 `match_result_json`([text_rule_match_node.cpp:318-329](../src/common_nodes/text_rule_match_node.cpp#L318-L329)), +两个输出转换器原样写出([keyword_result_output.cpp:22-25](../src/adapter/output/keyword_result_output.cpp#L22-L25)、 +[audio_result_output.cpp:71-72](../src/adapter/output/audio_result_output.cpp#L71-L72))。改节点里一个键名, +就同时改了两个外部契约,违反了 [AGENTS.md](../AGENTS.md) 中"响应组装和序列化留在 Adapter"的规则。 + +`RuleMatchItem`([common_contracts.h:64](../include/core/common_contracts.h#L64))的标量字段、`details` 和序列化字符串 +有信息重叠,但并非三份完全相同的结果:标量字段主要保存首次命中,`details` / JSON 还包含全部 `matches`、 +带 JSON 类型的 slots 常量,以及默认命中的 `raw_query`。只用现有标量字段重建响应会丢失信息。 + +**采纳方向**:Node 输出完整中性匹配结果,由 Adapter 按各自外部契约组装和序列化。内部业务状态与宿主 +`status_code` 的映射也归 Adapter,不要求删除所有内部状态表达。 + +**验收**:覆盖关键词与音频两种外部响应,保持全部字段、类型、默认值和序列化结果;同步检查读取 `details` 的 +内部消费者,覆盖多规则命中、typed slots 和默认命中,不能在搬移序列化时悄悄缩减语义。 + +### 4. 致命错误与单条业务无效结果的区别(待明确) + +任一 Node 返回非零失败,或转换器拒绝结果时,整批不发布新输出;审计结果的 `risk_level` 不在枚举内也会触发 +转换失败。整批回滚和租约归还有[契约测试](../tests/contract/abi/test_operator_safety.cpp#L261)。 +这是当前的致命错误语义,应与单条业务结果无效分开描述。 + +内置成功路径通常产生 `status_code=0`,但框架没有强制它恒为 0:关键词、音频、文档转换器会复制内部状态, +[行编码测试](../tests/unit/adapter/test_io_converters.cpp#L697)明确验证非零状态 23 被保留。 +`StructuredJsonParseNode` 也已有逐项 `is_valid`、`parse_status` 和 `diagnostic`; +`emit_diagnostic` / `configured_fallback` 可使坏项不触发节点失败,见 +[测试](../tests/unit/nodes/test_structured_json_parse_node.cpp#L94)。下游是否接受这些结果仍由具体契约决定。 + +**建议**:保持致命错误的整批原子性,在各业务契约中明确单条无效结果、fallback 和 `status_code` 的含义。 +不能把保留整批回滚等同于"所有 status_code 恒为 0",也不能仅因某业务需要单条失败就认定必须新增框架级错误通道。 +若确有通用部分成功需求,再设计失败项的来源、下游过滤/聚合、输出组装及跨请求状态提交规则,并单独验收。 + +## 二、对照五条标准 + +| 标准 | 现状 | 主要差距 | +| --- | --- | --- | +| 1. 外部契约不变 | ABI 与载体保持稳定;现行返回行为包含内部码透传 | 错误分类易碰撞;调整返回语义需要契约决策(问题 1) | +| 2. 各层新增方便 | 新增 Node 很顺;Model、Backend、业务契约偏重 | 见第四节 | +| 3. 层次隔离 | 编译期隔离很强 | 响应格式在节点;业务边界与提示文案在 Core 可进一步分离;中性来源编号本身不构成越层 | +| 4. 使用者门槛 | 编排和简单节点低;Batch 节点中等;新业务契约高 | 端口三元字符串、`TraceableItem`、Adapter 标识过多 | +| 5. 易理解、无冗余 | 存在重复实现和未使用结构,也有必要的契约与扩展接口 | 见第三节;不能把仅由测试使用的能力一律视为死代码 | + +## 三、简化建议及采纳条件 + +本节区分实现重复、未使用结构与已支持的扩展能力。仅由测试使用不是删除理由;撤销能力、改变并发保证或 +改变配置含义都需要按契约变更评价,不能统一归为低风险内部清理。 + +### 接入适配层 + +- **部署信息有多处转存,可减少重复字段**: + [PreparedDeployment](../src/adapter/deployment_preparation.h#L25) → + [ValidatedIoPlan](../src/adapter/io_binding_resolver.h#L19) → + [ResolvedOperatorConfig](../src/adapter/operator/operator_config_resolver.h#L18) → + [SharedAlgorithmRuntime](../src/adapter/shared_algorithm_runtime.h) 和 + [OperatorHandle](../src/adapter/operator/operator_adapter.cpp#L27)。这些对象分别承担部署准备、校验结果、宿主配置和 + 可变运行资源,不能仅按对象数量判断冗余。`SharedAlgorithmRuntime` 还承担注册审计、Pipeline 构建和异常处理, + 并非只做转发([shared_algorithm_runtime.cpp:24](../src/adapter/shared_algorithm_runtime.cpp#L24))。 + 可研究共享不可变部署数据及更准确的命名,但须保留预检与资源加载的阶段边界、失败原子性和输出池生命周期。 +- **端口绑定实现重复,可抽公共实现**:`InputPortBindings` 和 `OutputPortBindings` + ([io_converter.h:150](../include/adapter/io_converter.h#L150)、[io_converter.h:194](../include/adapter/io_converter.h#L194)) + 的查找与存储逻辑相同。保留输入/输出方向约束及错误诊断即可,不必扩展作者接口。 +- **输入限额检查重复,可统一规则来源**:值类型校验用 `ResolvedInputLimits` + ([operator_builtin_value_types.cpp:132](../src/adapter/operator/operator_builtin_value_types.cpp#L132)), + 转换器再用常量重查一遍([audit_input.cpp:45](../src/adapter/input/audit_input.cpp#L45))。而 `ResolvedInputLimits` + 从未被配置,始终是默认值([operator_config_resolver.cpp:348](../src/adapter/operator/operator_config_resolver.cpp#L348)); + 当前两处限额均来自同一组默认常量,没有实际数值不一致的证据。 + 业务字段语义归 Converter,但值类型扫描宿主内存前必须保留安全字节上限、长度与指针校验,见 + [operator_value_type_registry.cpp:281](../src/adapter/operator/operator_value_type_registry.cpp#L281)。 + 统一校验不能扩大读取范围,也不能无意改变错误优先级。 +- **契约元数据应保留,可减少重复填写**:`schema_id`、`schema_version`、`external_type`、槽位 `value_type` 和 + `capacity_policy` 实际参与同一业务外部协议的一致性检查,不一致会拒绝接入,见 + [io_binding_registry.cpp:14](../src/adapter/io_binding_registry.cpp#L14)。其中部分已有默认值,并非每项都要手写。 + 可由工厂或类型推导重复的载体信息,但须保留协议身份、版本和容量策略检查,不能把这些字段视为无效元数据。 +- **自定义输出分配器应保留**:当前生产类型没有额外具名 allocator,但注册、参数解析与 `OutputConfigReader` + 是已支持的扩展能力([operator_value_type.h:310](../include/adapter/operator_value_type.h#L310)),用于嵌套布局、 + 数组容量及分配/释放。`allocator`、`params` 也是现有 Pipeline 配置字段,见 + [输出分配方案](../doc/dev_guide/operator_output_allocation.md#选择参数)。测试正在证明这条接入路径。 + 删除属于撤销能力,并非死代码清理;本文不建议删除。 +- **未使用结果结构可清理,但保留测试扩展所需类型**:[biz_results.h](../include/adapter/biz_results.h) 中 + 6 个结构没有类型引用,可核实后删除;`KeywordResult` 用于输出池与自定义 allocator 测试,须保留或移到测试夹具。 +- **宿主类型集中登记可研究分散注册**:类型清单写在框架头里([io_converter.h:35](../include/adapter/io_converter.h#L35)), + 内置值类型集中在 [operator_builtin_value_types.cpp](../src/adapter/operator/operator_builtin_value_types.cpp), + 新增宿主结构要改这两个集中文件。收敛注册步骤须保留类型与布局核验,不把宿主类型带入下层; + 外部环境只能处理现有 mock 和中性接缝,不能推测真实 SDK 的新类型。 + +### 流程编排层 + +- **业务审计与图校验可进一步分离**:Validator 同时使用 `BizDefinition` 和 Adapter 传入的 `PipelineIoBoundary`,并互相比对 + ([pipeline_validator.cpp:965](../src/core/pipeline_validator.cpp#L965)、[pipeline_validator.cpp:1200](../src/core/pipeline_validator.cpp#L1200))。 + [NodeDefinition::biz_names](../include/core/node_definition.h#L87) 没有生产节点设置限制。 + 可研究 Core 只接收中性 IO 边界,由 Adapter 持有业务定义及一致性审计,但须保留独立业务契约、 + 全量注册审计、Catalog 可见性和 CLI 预检;这是跨层调整,不只是搬文件。 + 不应借此取消[简化计划第 9 节](FRAMEWORK_SIMPLIFICATION_PLAN.md#9-不做的事项及理由)要求保留的独立契约检查。 +- **修复建议生成目前在 Core,可移到工具层**:中文文案与 JSON patch 生成代码位于 + [pipeline_validator.cpp:375-943](../src/core/pipeline_validator.cpp#L375-L943),编进端侧 SDK,校验失败时执行。 + 可使 Core 只输出结构化原因、路径和事实,由 CLI 提供中文建议与 JSON patch。须保留单一 Validator 规则来源, + 工具不得重新实现校验;还需验证 SDK 诊断和 Studio 的修复建议不退化。 +- **Control 有重复解析与 schema 校验,可优化传递方式**:Core 与节点各检查载荷 + ([pipeline.cpp:582](../src/core/pipeline.cpp#L582)、[control_authoring.h:140](../include/nodes/control_authoring.h#L140)), + Node 接收字符串后会再次解析。可研究复用已解析载荷,但 Core 的广播预检与 Node 的独立调用校验各有职责。 + 不能取消预检,让前面的节点已更新后才发现后面节点的 schema 不兼容;语义失败的现行 best-effort 行为也须保持。 +- **JSON 基础检查可共用,入口语义不必合并**:`ConfigFieldDefinition`、 + [control_payload.h](../include/contracts/control_payload.h)、[json_structure.h](../include/contracts/json_structure.h) + 分别承担配置字段、Control schema 和结构检查。可共用类型、范围、枚举等基础规则;不能假定三者的 + 默认值补齐、未知字段处理和错误定位契约完全相同。 +- **测试便利接口须按职责评价**:`RegisterModel`、`UpdateModelRevision`、`GetModelRegistration`、`GetAllModels`、 + `HasModel`、`SetResource`、`GetResource` 当前主要由测试使用([session_context.h:129](../include/core/session_context.h#L129)), + 部分用于模型注入、原子注册、缓存版本及资源所有权验证。无运行时需求的便利封装可移到测试支持; + 不能只按调用者数量删除它们所证明的能力。 + `RuntimeOptions::biz_type` 与 `depth_num` 可核实运行时读者后清理;`has_device_id` 有生产用途, + [pipeline.cpp:43](../src/core/pipeline.cpp#L43)用它决定是否向 Backend 传递设备编号,不能当作死字段删除。 + +### 能力节点层 + +- **Map/Batch 生命周期重复,适合后续重构**:`AuthorNode`([function_node.h:1379](../include/nodes/function_node.h#L1379)) + 与 Batch 版([function_node.h:1539](../include/nodes/function_node.h#L1539))做同样的绑定、参数解析和 Control。 + Map 可研究作为 Batch 的包装,保留现有作者写法;验收空批、逐项失败诊断、数量/顺序/来源、不可复制参数和 Control 回滚。 +- **声明与实例状态可分离,但不能承诺去掉全部类型擦除**:输入、输出、模型、参数各有 + "虚接口 + 实现 + Holder + Clone"。输入/输出声明混入解析后的 key,可移到 AuthorNode 实例,并共享只读声明以减少复制。 + 但参数绑定本来就主要是声明([parameter_binding.h:166](../include/nodes/parameter_binding.h#L166)), + 异构字段类型仍需类型擦除或等价模板机制。先设计清楚状态归属,再判断哪些 Clone 和 Holder 可以删除。 +- **旧类写法辅助函数可收窄到内部运行时与测试**:`NodeBase` 的 `BindPort`、`Require`、`Publish` 等主要由测试使用 + ([node_base.h:246](../include/nodes/node_base.h#L246))。清理时保留所需的运行时测试接缝,业务作者仍只使用 Spec。 +- **模型调用类可减少持有与绑定样板**:5 个 `XxxCall` 类已有 + [InvokeAlignedModel](../include/nodes/model_calls.h#L38)共用执行与对齐逻辑。可以抽公共持有实现; + `Generate`、`Embed`、`Transcribe` 等具名能力方法有助理解,不必为消除少量样板改成一套通用调用语法。 +- **参数快照与原子发布应保留**:Operator 句柄锁只保证该入口串行,不能替代 Node 自身的并发契约。 + [ConfigurationSnapshot](../include/nodes/configuration_snapshot.h#L45)保证 writer 串行、原子发布、旧快照存活和失败回滚; + [并发测试](../tests/unit/nodes/test_function_node.cpp#L2415)直接并发调用 Node 的 Process / Control。 + 普通指针替换会削弱保证,本文不建议改成无同步的"拷贝、校验、替换"。 + +### 模型执行层 + +- **身份声明可减少重复,但保留实际实例核验**:模型类型、能力、并发函数与 Definition 有重复,工厂再逐项比对 + ([model_runtime_factory.cpp:185](../src/engine/runtime/model_runtime_factory.cpp#L185))。Backend 类型名也写三遍。 + 可以由类型化注册或公共实现提供声明,能力由接口推导(已有 [ModelCapabilityTraits](../include/engine/model_capability_traits.h))。 + 但实际能力接口、Session 协议、批次策略及并发一致性仍须核验,不能把全部运行时检查变成仅验证声明自身。 +- **Backend Provider 可研究函数化注册**:现有 `IInferenceBackend` 实现主要作为一次 `Load` 的工厂 + ([backend_interface.h:133](../include/engine/backend_interface.h#L133))。直接注册 Load 函数可以减少 Provider 类, + 但须保持异常屏障、失败诊断、Session 所有权和协议检查;属于可选简化,不阻塞现有接入。 +- **配置声明和读取可以统一到中性工具**:Model/Backend 手写字段声明与 `json.value()`;Node 的 + `Parameters` + `Field` 已有类型绑定。可提取公共配置工具到 `contracts` 等中性位置, + 不能让 Model/Backend 反向依赖 `nodes`。保留语义预检、默认值补齐及资源相关创建验证。 +- **Backend 条件编译可由 CMake 收敛**:源文件始终收录,内部多处 `#ifdef HAVE_X`(例如 + [onnxruntime_backend.cpp](../src/engine/backends/onnxruntime/onnxruntime_backend.cpp))。可将可测的中性工具函数单独成文件, + 厂商实现按开关收录;须验证开关启用/关闭两种构建、Catalog 注册、缺失 Backend 的诊断及 vendor 依赖隔离。 +- **Model 来源接口暂缓修改**:`FixedBatchExecutor` 已统一恢复来源并去 padding + ([fixed_batch_executor.h:148](../include/engine/fixed_batch_executor.h#L148)),Model 作者多数无需自己复制编号。 + 调用门面还用编号检查输出对齐;若按下标直接回填,部分重排错误将失去检测依据。Qwen 也用编号派生种子。 + 只有出现明确接入负担时,再单独设计普通向量接口、顺序保证、种子传递和来源验证的替代机制。 + +### 横向 + +- **注册表公共操作可抽取,完整泛型注册表按需设计**:多个注册表重复实现冲突记录与查询, + [registry_support.h](../src/engine/runtime/registry_support.h)目前只覆盖 Model/Backend。 + 可先共用小型操作;统一容器前须核实锁内操作、回调重入、返回对象寿命及失败封闭行为,不能跨层引入具体组件依赖。 +- **运行时契约与编排实现可物理分开**:`include/core` 同时包含节点可用的中性契约 + ([node_core_contracts.txt](../cmake_ext/node_core_contracts.txt))和编排引擎。 + 迁移可使目录更贴近已存在的 `edgeflow_runtime_contracts` 编译边界,减少清单特例; + 仍须保留头文件视图、编译探针和分层守卫,验证下层没有获得完整 Core 依赖。此项不阻塞功能修复。 + +## 四、各层"新增一个东西"的改动面 + +以下是生产接入步骤的简化方向,不是已实现或已批准的目标接口。测试、文档和分层验证仍按受影响契约补齐。 + +| 新增 | 现在要改的地方 | 可收敛方向与必须保留的工作 | +| --- | --- | --- | +| Node(已有类型) | 1 个文件 + 1 个宏 | 已经很好 | +| 新端口值类型 | 类型声明及 Blackboard 类型特化 | 可就近声明类型特征;共享类型的归属与唯一类型名仍须明确 | +| Model(已有能力) | 目录、CMake 清单、Definition、Create、身份函数、手读 JSON | 减少身份与配置样板;创建、预处理语义和实际能力核验仍需要 | +| Backend(已有协议) | 目录、CMake、依赖脚本、条件编译、Provider 类、Session | 可减少 Provider 与条件编译样板;依赖 pin、隔离、加载/释放及开关验证仍需要 | +| 新模型能力 | 接口、能力名、Call、载荷、类型特征,必要时还有协议与脚手架 | 共用声明与调用基础设施;能力语义和是否需要新协议仍须设计,不能保证只改三个位置 | +| 业务契约(复用宿主结构) | 业务定义、Binding、转换器及协议标识 | 推导重复载体信息、使用已有默认值;保留独立业务契约和外部协议一致性检查 | +| 新宿主结构 | mock 结构、值类型登记、布局分配、转换器、Demo | 可研究局部类型化注册;布局/容量、分配释放、完整转换和 Demo 接入仍需要 | + +## 五、建议顺序 + +1. 先明确第一节 1 的公开错误分类与第一节 2 的数量/来源语义,按跨层及公开行为设计处理; + 分别记录验收与回退方式,不能以"JSON 没变"替代契约审查。 +2. 优先修复端口误判、迁移 RuleMatch 外部序列化;错误码映射在目标语义确定后实施。 + 每项独立验证,覆盖真正不兼容流程、完整响应信息与错误路径,保持外部载体和配置格式。 +3. 明确第一节 4 的致命错误与业务无效结果语义,默认保留整批回滚;有具体需求时再设计部分成功能力。 +4. 清理确认无引用的旧结构,合并端口绑定公共实现;测试支持可按职责迁移,不成批删除扩展接口。 +5. 再做 Map/Batch 生命周期与声明/实例状态分离;Adapter 数据转存、业务审计归属、工具层建议生成各自评估, + 保留阶段边界、独立契约审计和诊断能力。 +6. Model/Backend 注册、配置与条件编译按收益逐项简化;保留来源接口,完整泛型注册表和目录迁移均不作为发布前置条件。 +7. 跨请求状态排在数量/来源语义修正之后,不必等待所有清理和可选重构。 + +每个实施项遵循 [CONTRIBUTING](../CONTRIBUTING.md#3-design-and-current-contracts) 的设计与验收分类; +阶段完成后运行适用检查和唯一交付门禁,更新当前指南。单纯减少行数不能替代行为、边界与扩展能力的验收。 + +## 六、待决定事项 + +1. **错误码公开语义**:在固定枚举集合下,配置校验、模型加载、节点/模型执行及 Control 失败分别如何分类; + 是否接受调整现行透传行为,以及何时在授权内网核验真实 SDK。未决定前不能随意把普通执行失败映射成异常码。 +2. **端口数量与来源设计**:节点转换关系、边上形状、共享输入及存活期如何分别表达;静态推导、运行时检查和 + 出口约束各负责什么。第一节 2 是采纳方向,尚不是完整替代协议。 +3. **单条业务无效结果**:各业务是否允许非零 status、fallback 或诊断项;哪些错误仍导致整批回滚。 + 框架级部分成功机制暂不列入必做项,待明确业务需求后单独设计。 + +## 附录:复现步骤 + +在仓库外新建一个临时目录,以下命令都在该目录中执行;`` 为仓库根目录,`build/` 为默认构建目录。 + +### A. 内部错误码穿透到宿主 + +测试后端固定返回非 JSON 文本 `test-generation`,`failure_policy` 设为 `fail` 后解析节点失败。 + +`pipeline.json`: + +```json +{ + "deployment": {"io": {"io_binding": "entity_extract.operator.v1"}}, + "models": [ + {"model_id": "llm", "model_type": "qwen_causal_lm", "backend": "test_causal_lm_backend", + "model_path": "neutral-llm.fixture", "model_config": {}, "backend_config": {}} + ], + "pipeline": [ + {"id": "gen", "node_type": "LlmGenerateNode", "config": {"bind_model": "llm"}, + "inputs": {"prompt": "input_sentences"}, "outputs": {"text": "raw"}}, + {"id": "parse", "node_type": "StructuredJsonParseNode", "config": {"failure_policy": "fail"}, + "inputs": {"text": "raw"}, "outputs": {"document": "extracted_entities"}} + ] +} +``` + +`pipeline.conf`: + +```json +{"pipe_path": "pipeline.json"} +``` + +运行: + +```bash +cp /demo/fixtures/mock/artifacts/neutral-llm.fixture . +printf '张三在北京\n' > data.txt +/build/alg_demo --config pipeline.conf --dataset data.txt --output-dir out +``` + +实际输出: + +```text +[OperatorRunner ERROR] ops.Process failed at chunk starting index 0: code=-6102 (Pipeline::Execute failed +with code -6102: Node 'parse' (StructuredJsonParseNode): JSON parse failed for sample: ...) +``` + +### B. 拆分后接逐项节点被拒绝 + +`chunk_then_rule.json`: + +```json +{ + "models": [], + "pipeline": [ + {"id": "chunk", "node_type": "TextChunkNode", "config": {"chunk_size": 4}, + "inputs": {"text": "input_sentences"}, + "outputs": {"chunks": "sentence_chunks", "chunk_counts": "sentence_chunk_counts"}}, + {"id": "rules_per_chunk", "node_type": "TextRuleMatchNode", + "config": {"categories": {"SYSTEM_INIT": ["初始化"]}}, + "inputs": {"text": "sentence_chunks"}, "outputs": {"matches": "chunk_matches"}}, + {"id": "rules_per_request", "node_type": "TextRuleMatchNode", + "config": {"categories": {"SYSTEM_INIT": ["初始化"]}}, + "inputs": {"text": "input_sentences"}, "outputs": {"matches": "rule_matches"}} + ], + "deployment": {"io": {"io_binding": "keyword_match.operator.v1"}} +} +``` + +运行: + +```bash +/build/alg_pipeline_tool validate chunk_then_rule.json +``` + +实际输出(节选):`ok` 为 `false`,诊断码 `PORT_CARDINALITY_MISMATCH`,路径 `/pipeline/1/inputs/text`,消息为 +`Port cardinality mismatch: producer '1:N' cannot feed consumer '1:1'`。 + +### C. 复核测试 + +下列 10 个测试已用现有 `build/` 产物执行通过,用于确认文中提到的现行契约。它们覆盖 Node 并发快照、 +逐项 fallback、外部协议不一致拒绝、自定义 allocator、内部错误码透传、非零行状态及设备编号传递。 +这不是建议方案已实现或通过验收的证据,也不替代实施后的完整门禁。 + +可用以下筛选器重跑: + +```bash +./build/edgeflow_test_nodes_runner --gtest_filter='FunctionNodeTest.WholeBatchProcessConsistencyDuringControl:FunctionNodeTest.WholeBatchProcessConsistencyDuringControlForBatchSpec:FunctionNodeTest.RapidInterleavedControlsAndConcurrentProcesses:ConfigurationSnapshotTest.ReaderHoldsOldSnapshotWhileWriterPublishes:StructuredJsonParseNodeTest.InvalidDocumentHonorsFallbackAndDiagnostic' +./build/edgeflow_test_adapter_runner --gtest_filter='IoBindingRegistryTest.SameSuffixDifferentCarrierIsRejectedBeforeConversion:AdapterContractSecurityTest.TranslationOperatorGeneratesOnceFromRawQueryAndPacksLiteralOutput:OperatorApiTest.CustomAllocatorStillRequiresItsExplicitLayoutParameters:IoConverterTest.EncodeRowsRestoresOrderAndIdsAndChecksWriterCapacity' +./build/edgeflow_test_core_runner --gtest_filter='ModelBackendPipelineTest.PipelinePassesResolvedPathAndTargetToBackend' +``` From 548c03a90c902435774fe242191324efb0aa584c Mon Sep 17 00:00:00 2001 From: chamsechan Date: Sat, 3 Oct 2026 23:51:22 +0800 Subject: [PATCH 02/29] docs(plans): record architecture review decisions and progress table Settle the three pending decisions (stage-based error mapping with -100 for execution failures, shape inference for port cardinality, whole-batch rollback kept) and track each adopted item for incremental delivery. Co-Authored-By: Claude Opus 5.5 --- plans/ARCHITECTURE_REVIEW.md | 59 ++++++++++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/plans/ARCHITECTURE_REVIEW.md b/plans/ARCHITECTURE_REVIEW.md index cd839a40..60242092 100644 --- a/plans/ARCHITECTURE_REVIEW.md +++ b/plans/ARCHITECTURE_REVIEW.md @@ -2,7 +2,7 @@ | 项 | 内容 | | --- | --- | -| 状态 | 审查与复核完成;建议已分级,改进项均未实施;边界设计见[第六节](#六待决定事项) | +| 状态 | 第六节事项已决定;改进项按[实施进度](#七实施进度)逐项落地 | | 基线 | `main@a664e37`(2026-10-03) | | 范围 | 审查四层源码、构建与分层守卫、测试和工具;本次仅修订审查文档,未修改代码 | | 约束 | 保持 Operator 函数表、宿主结构体、槽位名、错误码枚举集合、`.conf` 和 Pipeline JSON 格式;错误码返回语义的调整须单独决策,不能视为内部重构 | @@ -291,14 +291,55 @@ Definition、Catalog、Validator、节点运行时及工具的解释须一致。 每个实施项遵循 [CONTRIBUTING](../CONTRIBUTING.md#3-design-and-current-contracts) 的设计与验收分类; 阶段完成后运行适用检查和唯一交付门禁,更新当前指南。单纯减少行数不能替代行为、边界与扩展能力的验收。 -## 六、待决定事项 - -1. **错误码公开语义**:在固定枚举集合下,配置校验、模型加载、节点/模型执行及 Control 失败分别如何分类; - 是否接受调整现行透传行为,以及何时在授权内网核验真实 SDK。未决定前不能随意把普通执行失败映射成异常码。 -2. **端口数量与来源设计**:节点转换关系、边上形状、共享输入及存活期如何分别表达;静态推导、运行时检查和 - 出口约束各负责什么。第一节 2 是采纳方向,尚不是完整替代协议。 -3. **单条业务无效结果**:各业务是否允许非零 status、fallback 或诊断项;哪些错误仍导致整批回滚。 - 框架级部分成功机制暂不列入必做项,待明确业务需求后单独设计。 +## 六、决定事项 + +以下决定于 2026-10-03 作出,替代原待决定事项。 + +1. **错误码公开语义**:接入适配层按失败阶段集中映射。Create 阶段的参数、配置、部署与模型/后端 + 加载失败返回 -2;Process 与 Control 中节点或模型执行失败返回 -100,其含义改为"未分类的运行时失败"; + 门面、输入、输出容量和 Control 自身的校验码不变。原始码、节点及所在层写入 `GetOperatorLastError()`。 + 真实 SDK 的目标码仍须在授权内网核验。 +2. **端口数量与来源**:保留四种字符串与 Pipeline JSON 格式。输入端口声明节点如何消费,接受任意数量的 + 上游数据;输出端口声明产出关系。Validator 沿 DAG 推导每个数据名在请求内的条目数(每请求一项、 + 某个拆分产生的多项、未知),只在 `1:1` 边界收到多项或同一节点的逐项输入无法配对时报错; + 来源与存活期检查不变。 +3. **单条业务无效结果**:保留致命错误的整批回滚;在各业务契约中写明 `status_code`、fallback 与诊断项的 + 含义;不新增框架级部分成功机制。 +4. **可选重构**:逐项评估,收益明确且契约可保持时才实施;不采纳的在下表记录理由。 + +## 七、实施进度 + +每项完成后单独提交,规则写入对应的现行指南。 + +| 编号 | 项目 | 来源 | 状态 | +| --- | --- | --- | --- | +| A1 | 端口数量关系改为形状推导 | 第一节 2 | 待实施 | +| A2 | RuleMatch 外部响应序列化移到 Adapter | 第一节 3 | 待实施 | +| A3 | 错误码按阶段映射 | 第一节 1 | 待实施 | +| A4 | 致命错误与单条无效结果写入业务契约 | 第一节 4 | 待实施 | +| B1 | 清理未使用的结果结构 | 第三节·接入适配层 | 待实施 | +| B2 | 输入/输出端口绑定共用实现 | 第三节·接入适配层 | 待实施 | +| B3 | 输入限额统一规则来源 | 第三节·接入适配层 | 待实施 | +| B4 | `RuntimeOptions` 死字段与测试便利接口 | 第三节·流程编排层 | 待实施 | +| B5 | `NodeBase` 旧写法辅助函数收窄 | 第三节·能力节点层 | 待评估 | +| C1 | Map/Batch 生命周期合并 | 第三节·能力节点层 | 待评估 | +| C2 | 端口声明与实例状态分离 | 第三节·能力节点层 | 待评估 | +| C3 | 模型调用类共用持有实现 | 第三节·能力节点层 | 待评估 | +| C4 | 修复建议生成移到工具层 | 第三节·流程编排层 | 待评估 | +| C5 | 业务审计与图校验分离 | 第三节·流程编排层 | 待评估 | +| C6 | Control 载荷复用 | 第三节·流程编排层 | 待评估 | +| C7 | JSON 基础检查共用 | 第三节·流程编排层 | 待评估 | +| C8 | 部署信息转存与命名 | 第三节·接入适配层 | 待评估 | +| C9 | 契约元数据减少重复填写 | 第三节·接入适配层 | 待评估 | +| C10 | 宿主类型分散注册 | 第三节·接入适配层 | 待评估 | +| D1 | Model 身份声明去重 | 第三节·模型执行层 | 待评估 | +| D2 | Backend Provider 函数化注册 | 第三节·模型执行层 | 待评估 | +| D3 | Model/Backend 配置声明与读取统一 | 第三节·模型执行层 | 待评估 | +| D4 | Backend 条件编译由 CMake 收敛 | 第三节·模型执行层 | 待评估 | +| D5 | 注册表公共操作抽取 | 第三节·横向 | 待评估 | +| D6 | 运行时契约与编排实现目录分离 | 第三节·横向 | 待评估 | + +保留项(自定义 allocator、参数快照、契约元数据检查、Model 来源接口)不列入实施。 ## 附录:复现步骤 From ee58f1a380ca601d2898b014548172f90a955b56 Mon Sep 17 00:00:00 2001 From: chamsechan Date: Sat, 3 Oct 2026 23:54:31 +0800 Subject: [PATCH 03/29] fix(validator): infer per-request item shapes instead of comparing cardinality strings Input cardinality now states how a node consumes items and accepts any upstream shape, so split outputs can feed item-wise nodes such as TextRuleMatchNode. The Validator derives each key's per-request shape along the DAG and reports PORT_CARDINALITY_MISMATCH only when a 1:1 biz or IO boundary receives split items, or when item-wise inputs of one node cannot pair item by item. TextEmbeddingNode declares its real 1:1 relation instead of the N:M bypass. Provenance and lifetime checks are unchanged; Pipeline JSON and Definition fields keep their format. Co-Authored-By: Claude Opus 5.5 --- doc/CHANGELOG.md | 6 + doc/dev_guide/custom_node_concepts.md | 23 +++ plans/ARCHITECTURE_REVIEW.md | 2 +- src/common_nodes/text_embedding_node.cpp | 2 +- src/core/pipeline_validator.cpp | 124 ++++++++++- tests/RuntimeTests.cmake | 2 +- .../test_pipeline_catalog_validator.cpp | 69 +++++-- .../core/test_validated_pipeline_plan.cpp | 192 +++++++++++++++++- 8 files changed, 389 insertions(+), 31 deletions(-) diff --git a/doc/CHANGELOG.md b/doc/CHANGELOG.md index a7ff02cd..f1709639 100644 --- a/doc/CHANGELOG.md +++ b/doc/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +端口数量关系:输入端口的 cardinality 只描述节点如何消费,接受任意数量的上游数据; +`TextChunkNode` 等拆分输出可以直接接逐项节点(如 `TextRuleMatchNode`)。Validator 改为沿 DAG +推导每个数据名在请求内的条目数,仅在 `1:1` 业务/IO 边界收到多项,或同一节点的逐项输入无法配对时 +报告 `PORT_CARDINALITY_MISMATCH`。`TextEmbeddingNode` 的端口声明由 `N:M` 改为实际的 `1:1`。 +Pipeline JSON 与 Definition 字段不变,见[数量关系声明](dev_guide/custom_node_concepts.md#数量关系声明与-validator-检查)。 + 文档链接检查:门禁新增 `DocLinksTest`,检查仓库内 Markdown 的相对链接、HTML `href`/`src` 和标题锚点;没有扫描到文件或跨文件锚点时直接失败,不会空跑通过。 diff --git a/doc/dev_guide/custom_node_concepts.md b/doc/dev_guide/custom_node_concepts.md index 00f5b6be..dc952bbf 100644 --- a/doc/dev_guide/custom_node_concepts.md +++ b/doc/dev_guide/custom_node_concepts.md @@ -190,6 +190,29 @@ outputs.emplace_back(item.req_id, item.sub_id, new_value); 编号,因此常规业务修改可以集中在载荷上。需要过滤、拆分或聚合时,改用能表达该算法的 批处理实现,并同步声明数量和来源规则;不能让 1:1 模板悄悄丢掉一条输入。 +### 数量关系声明与 Validator 检查 + +端口的 cardinality 声明节点自身的关系,不要求上下游写同一个字符串: + +| 声明 | 输入端口 | 输出端口 | +| --- | --- | --- | +| `1:1` | 逐项消费 | 与逐项输入的条目一一对应;未连接逐项输入时每个请求一项 | +| `1:N` | — | 每项拆出零到多项 | +| `N:1` | 按请求聚合 | 每个请求一项 | +| `N:M` | 任意消费 | 数量关系未知 | + +输入端口接受任意数量的数据,因此逐项节点可以接在拆分之后,聚合后的结果也可以再逐项变换。 +Validator 沿 DAG 推导每个数据名在一个请求内有几项:每请求一项、由某个拆分输出产生的多项, +或未知。只在两处报告 `PORT_CARDINALITY_MISMATCH`: + +- 业务或 IO 边界声明 `1:1`(每个请求恰好一项)却收到拆分产生的多项;边界的其他声明表示 + 属于该请求的一组条目。 +- 同一节点的多个逐项输入数量不同,例如一个每请求一项、另一个来自片段拆分,或来自两个不同的 + 拆分,无法逐项配对。经逐项节点传递的同一拆分结果仍可配对。 + +`N:M` 输出不参与静态推导,由运行时的数量与来源检查兜底;不要为了绕过上述检查而声明 `N:M`。 +来源规则 (provenance) 与存活期 (lifetime) 仍独立检查。 + ## 启用并行或共享资源时:并发声明 假设两个调用同时使用相同的节点逻辑。函数局部的提示词和结果各自独立;如果把上次 diff --git a/plans/ARCHITECTURE_REVIEW.md b/plans/ARCHITECTURE_REVIEW.md index 60242092..6411260e 100644 --- a/plans/ARCHITECTURE_REVIEW.md +++ b/plans/ARCHITECTURE_REVIEW.md @@ -313,7 +313,7 @@ Definition、Catalog、Validator、节点运行时及工具的解释须一致。 | 编号 | 项目 | 来源 | 状态 | | --- | --- | --- | --- | -| A1 | 端口数量关系改为形状推导 | 第一节 2 | 待实施 | +| A1 | 端口数量关系改为形状推导 | 第一节 2 | 已完成:Validator 推导形状;`TextEmbeddingNode` 改为 `1:1`;规则见[数量关系声明](../doc/dev_guide/custom_node_concepts.md#数量关系声明与-validator-检查) | | A2 | RuleMatch 外部响应序列化移到 Adapter | 第一节 3 | 待实施 | | A3 | 错误码按阶段映射 | 第一节 1 | 待实施 | | A4 | 致命错误与单条无效结果写入业务契约 | 第一节 4 | 待实施 | diff --git a/src/common_nodes/text_embedding_node.cpp b/src/common_nodes/text_embedding_node.cpp index f260c03f..18af4eca 100644 --- a/src/common_nodes/text_embedding_node.cpp +++ b/src/common_nodes/text_embedding_node.cpp @@ -75,7 +75,7 @@ NodeResult EmbedText(const EmbeddingInputs& inputs, } auto TextEmbeddingSpec() { - const PortFlow flow{"N:M", "preserve", "request", "lifetime"}; + const PortFlow flow{"1:1", "preserve", "request", "lifetime"}; return MakeBatchSpec( InputsOf( {Required("text", &EmbeddingInputs::text, flow)}), diff --git a/src/core/pipeline_validator.cpp b/src/core/pipeline_validator.cpp index a9114989..66819950 100644 --- a/src/core/pipeline_validator.cpp +++ b/src/core/pipeline_validator.cpp @@ -101,12 +101,69 @@ NodePortDefinition EffectivePortDefinition( return effective; } -bool CardinalityCompatible(const std::string& producer, - const std::string& consumer) { - if (producer == consumer || producer == "N:M" || consumer == "N:M") { - return true; +// Number of items one request has under a Blackboard key. Node cardinality +// states the node's own relation: inputs say how items are consumed ("1:1" +// item by item, "N:1" grouped by request, "N:M" arbitrarily) and accept any +// shape; outputs say how produced items relate to the item-wise inputs. The +// shape is derived along the DAG and checked only where it matters: a boundary +// requiring one item per request, and item-wise inputs that must pair up. +struct KeyShape { + enum class Kind { kUnknown, kPerRequest, kMulti }; + Kind kind = Kind::kUnknown; + std::string origin; // Fan-out that generated the items of a kMulti shape. + + static KeyShape Unknown() { return {}; } + static KeyShape PerRequest() { return {Kind::kPerRequest, {}}; } + static KeyShape Multi(std::string origin) { + return {Kind::kMulti, std::move(origin)}; } - return consumer == "N:1"; + bool Known() const { return kind != Kind::kUnknown; } + bool operator==(const KeyShape& other) const { + return kind == other.kind && origin == other.origin; + } +}; + +std::string DescribeShape(const KeyShape& shape) { + switch (shape.kind) { + case KeyShape::Kind::kPerRequest: + return "one item per request"; + case KeyShape::Kind::kMulti: + return "zero or more items per request generated by '" + shape.origin + + "'"; + case KeyShape::Kind::kUnknown: + break; + } + return "an unknown number of items per request"; +} + +// Biz and IO boundary ports count items per request: "1:1" is exactly one +// item, any other declaration is a collection belonging to the request. +KeyShape BoundaryShape(const PortContract& port, std::string origin) { + return port.cardinality == "1:1" ? KeyShape::PerRequest() + : KeyShape::Multi(std::move(origin)); +} + +KeyShape NodeOutputShape(const PortContract& output, const KeyShape& item_shape, + std::string origin) { + if (output.cardinality == "1:1") return item_shape; + if (output.cardinality == "1:N") return KeyShape::Multi(std::move(origin)); + if (output.cardinality == "N:1") return KeyShape::PerRequest(); + return KeyShape::Unknown(); +} + +void ValidateBoundaryShape(const KeyShape& produced, + const PortContract& consumer, + const std::string& path, const std::string& node_id, + const std::string& logical_port, + const std::string& producer_id, + ValidationReport* report) { + if (consumer.cardinality != "1:1" || produced.kind != KeyShape::Kind::kMulti) + return; + Add(report, DiagnosticCode::kPortCardinalityMismatch, path, + "Port cardinality mismatch: '" + logical_port + + "' requires one item per request but receives " + + DescribeShape(produced), + node_id, logical_port, {producer_id}); } bool ProvenanceCompatible(const std::string& producer, @@ -140,12 +197,6 @@ void ValidatePortFlowContract(const PortContract& producer, const std::string& logical_port, const std::string& producer_id, ValidationReport* report) { - if (!CardinalityCompatible(producer.cardinality, consumer.cardinality)) { - Add(report, DiagnosticCode::kPortCardinalityMismatch, path, - "Port cardinality mismatch: producer '" + producer.cardinality + - "' cannot feed consumer '" + consumer.cardinality + "'", - node_id, logical_port, {producer_id}); - } if (!ProvenanceCompatible(producer.provenance_policy, consumer.provenance_policy)) { Add(report, DiagnosticCode::kPortProvenanceMismatch, path, @@ -1222,6 +1273,10 @@ ValidatedPipelinePlan ValidateAndPlanInternal( ValidatePortFlowContract(it->second, req_in, "/io/input", "$io_input", req_in.blackboard_key, "$ingress", &report); + ValidateBoundaryShape( + BoundaryShape(it->second, "$ingress." + req_in.blackboard_key), + req_in, "/io/input", "$io_input", req_in.blackboard_key, + "$ingress", &report); } } } @@ -1301,12 +1356,25 @@ ValidatedPipelinePlan ValidateAndPlanInternal( return plan; } + std::unordered_map key_shapes; + for (const auto& [key, port] : ingress) { + key_shapes[key] = BoundaryShape(port, "$ingress." + key); + } + auto shape_of = [&](const std::string& key) { + auto it = key_shapes.find(key); + return it == key_shapes.end() ? KeyShape::Unknown() : it->second; + }; + for (const auto& id : report.topological_order) { auto def_it = def_by_id.find(id); if (def_it == def_by_id.end()) continue; const auto& definition = *def_it->second; const auto& node = *node_by_id[id]; const nlohmann::json& normalized_config = normalized_config_by_node.at(id); + // Shape shared by the bound item-wise inputs; a node without one emits a + // single item per request. + KeyShape item_shape = KeyShape::PerRequest(); + std::string item_shape_port; ValidatedNodePlan node_plan; node_plan.node = node; @@ -1380,12 +1448,14 @@ ValidatedPipelinePlan ValidateAndPlanInternal( input.cardinality, input.provenance_policy, input.lifetime, PortDirection::kInput}); bool found = false; + std::string source_id; auto producer_it = producers.find(actual_key); if (producer_it != producers.end() && producer_it->second.size() == 1 && !ingress.count(actual_key)) { const auto& producer = producer_it->second.front(); if (producer.second.type_id == input.type_id) { found = true; + source_id = producer.first; ValidatePortFlowContract(producer.second, input, input_path, id, input.logical_name, producer.first, &report); } @@ -1394,10 +1464,31 @@ ValidatedPipelinePlan ValidateAndPlanInternal( if (root_port != ingress.end() && root_port->second.type_id == input.type_id) { found = true; + source_id = "$ingress"; ValidatePortFlowContract(root_port->second, input, input_path, id, input.logical_name, "$ingress", &report); } } + if (input.cardinality == "1:1") { + const KeyShape shape = + found ? shape_of(actual_key) : KeyShape::Unknown(); + if (item_shape_port.empty()) { + item_shape = shape; + item_shape_port = input.logical_name; + } else if (item_shape.Known() && shape.Known() && + !(item_shape == shape)) { + Add(&report, DiagnosticCode::kPortCardinalityMismatch, input_path, + "Port cardinality mismatch: item-wise input '" + + input.logical_name + "' receives " + DescribeShape(shape) + + ", but item-wise input '" + item_shape_port + "' receives " + + DescribeShape(item_shape) + + "; item-wise inputs must pair item by item", + id, input.logical_name, {source_id}); + item_shape = KeyShape::Unknown(); + } else if (!shape.Known()) { + item_shape = KeyShape::Unknown(); + } + } if (!found && biz && !has_only_unresolved_source(actual_key)) { std::vector suggestions; for (const auto& candidate : catalog.nodes) { @@ -1499,6 +1590,8 @@ ValidatedPipelinePlan ValidateAndPlanInternal( node_plan.ports.push_back( {output.logical_name, actual_key, output.type_id, output.cardinality, output.provenance_policy, output.lifetime, PortDirection::kOutput}); + key_shapes[actual_key] = + NodeOutputShape(output, item_shape, id + "." + output.logical_name); } plan.node_plans[id] = std::move(node_plan); @@ -1540,6 +1633,9 @@ ValidatedPipelinePlan ValidateAndPlanInternal( ValidatePortFlowContract(producer_port, consumer, output_path, producer_id, consumer.blackboard_key, "$egress", &report); + ValidateBoundaryShape(shape_of(consumer.blackboard_key), consumer, + output_path, producer_id, consumer.blackboard_key, + "$egress", &report); } } @@ -1560,6 +1656,9 @@ ValidatedPipelinePlan ValidateAndPlanInternal( ValidatePortFlowContract(ing_it->second, consumer, "/io/output", "$ingress", consumer.blackboard_key, "$io_output", &report); + ValidateBoundaryShape( + shape_of(consumer.blackboard_key), consumer, "/io/output", + "$ingress", consumer.blackboard_key, "$io_output", &report); } continue; } @@ -1585,6 +1684,9 @@ ValidatedPipelinePlan ValidateAndPlanInternal( ValidatePortFlowContract(producer_port, consumer, "/io/output", producer_id, consumer.blackboard_key, "$io_output", &report); + ValidateBoundaryShape(shape_of(consumer.blackboard_key), consumer, + "/io/output", producer_id, consumer.blackboard_key, + "$io_output", &report); } } diff --git a/tests/RuntimeTests.cmake b/tests/RuntimeTests.cmake index 63d7d4cf..96bab0ac 100644 --- a/tests/RuntimeTests.cmake +++ b/tests/RuntimeTests.cmake @@ -215,7 +215,7 @@ edgeflow_add_runner_test(CatalogContractSsotTest test_catalog_contract_ssot edgeflow_add_runner_test(TypedBlackboardContractsTest edgeflow_test_core_runner "TypedBlackboardContractsTest.*" "${_edgeflow_tier1}") edgeflow_add_runner_test(ValidatedPipelinePlanTest edgeflow_test_core_runner - "ValidatedPipelinePlanTest.*" "${_edgeflow_tier1}") + "ValidatedPipelinePlanTest.*:PortShapeTest.*" "${_edgeflow_tier1}") edgeflow_add_runner_test(NodeBaseContractsTest edgeflow_test_core_runner "NodeBaseContractsTest.*:NodeErrorCodesTest.*" "${_edgeflow_tier1}") edgeflow_add_runner_test(NodeOwnershipAndReuseTest edgeflow_test_core_runner diff --git a/tests/integration/pipeline/test_pipeline_catalog_validator.cpp b/tests/integration/pipeline/test_pipeline_catalog_validator.cpp index f4d27a3e..0a74eb3a 100644 --- a/tests/integration/pipeline/test_pipeline_catalog_validator.cpp +++ b/tests/integration/pipeline/test_pipeline_catalog_validator.cpp @@ -1133,31 +1133,68 @@ TEST(PipelineValidatorTest, ExplainRejectsInvalidModelCandidates) { EXPECT_FALSE(proposed_broken_model); } -TEST(PipelineValidatorTest, ExplainReturnsPortFlowMismatchRemediation) { +TEST(PipelineValidatorTest, SplitItemsFeedItemWiseNodeButNotPerRequestEgress) { nlohmann::json root = { {"biz_name", "keyword_match"}, - {"models", nlohmann::json::array()}, {"pipeline", - {{{"id", "producer"}, - {"node_type", "TextCorpusSourceNode"}, - {"depends_on", nlohmann::json::array()}, - {"outputs", {{"corpus", "corpus_text"}}}, - {"config", {{"corpus", {"sample text"}}}}}, - {{"id", "consumer"}, - {"node_type", "TextTemplateNode"}, - {"depends_on", {"producer"}}, - {"inputs", {{"primary", "corpus_text"}}}, - {"outputs", {{"text", "rendered_text"}}}, - {"config", {{"template", "prefix: {{primary}}"}}}}}}}; + {{{"id", "chunk"}, + {"node_type", "TextChunkNode"}, + {"config", {{"chunk_size", 4}}}, + {"inputs", {{"text", "input_sentences"}}}, + {"outputs", + {{"chunks", "sentence_chunks"}, + {"chunk_counts", "sentence_chunk_counts"}}}}, + {{"id", "rules_per_chunk"}, + {"node_type", "TextRuleMatchNode"}, + {"config", {{"categories", {{"SYSTEM_INIT", {"初始化"}}}}}}, + {"inputs", {{"text", "sentence_chunks"}}}, + {"outputs", {{"matches", "chunk_matches"}}}}, + {{"id", "rules_per_request"}, + {"node_type", "TextRuleMatchNode"}, + {"config", {{"categories", {{"SYSTEM_INIT", {"初始化"}}}}}}, + {"inputs", {{"text", "input_sentences"}}}, + {"outputs", {{"matches", "rule_matches"}}}}}}}; + + const auto accepted = PipelineValidator::Validate(root); + EXPECT_TRUE(accepted.ok) << accepted.ToJson().dump(); + + // Per-chunk matches cannot become the one-row-per-request biz output. + root["pipeline"][1]["outputs"]["matches"] = "rule_matches"; + root["pipeline"][2]["outputs"]["matches"] = "request_matches"; + const auto rejected = PipelineValidator::Validate(root); + ASSERT_FALSE(rejected.ok); + ASSERT_EQ(rejected.diagnostics.size(), 1u) << rejected.ToJson().dump(); + EXPECT_EQ(rejected.diagnostics[0].code, + DiagnosticCode::kPortCardinalityMismatch); + EXPECT_EQ(rejected.diagnostics[0].path, "/pipeline/1/outputs/matches"); + EXPECT_EQ(rejected.diagnostics[0].port, "rule_matches"); +} + +TEST(PipelineValidatorTest, ExplainReturnsPortFlowMismatchRemediation) { + nlohmann::json root = {{"biz_name", "keyword_match"}, + {"models", nlohmann::json::array()}, + {"pipeline", + {{{"id", "producer"}, + {"node_type", "TextChunkNode"}, + {"depends_on", nlohmann::json::array()}, + {"inputs", {{"text", "input_sentences"}}}, + {"outputs", {{"chunks", "chunk_text"}}}}, + {{"id", "consumer"}, + {"node_type", "TextEmbeddingNode"}, + {"depends_on", {"producer"}}, + {"inputs", {{"text", "chunk_text"}}}, + {"outputs", {{"embedding", "chunk_embeddings"}}}, + {"config", {{"lifetime", "session"}}}}}}}; const auto report = PipelineValidator::Validate(root); EXPECT_FALSE(report.ok); ASSERT_FALSE(report.diagnostics.empty()); + // Request-scoped chunks cannot back a session-scoped embedding cache. const ValidationDiagnostic* target_diag = nullptr; for (const auto& diag : report.diagnostics) { - if (diag.code == DiagnosticCode::kPortCardinalityMismatch && - diag.node_id == "consumer" && diag.port == "primary") { + if (diag.code == DiagnosticCode::kPortLifetimeMismatch && + diag.node_id == "consumer" && diag.port == "text") { target_diag = &diag; break; } @@ -1168,7 +1205,7 @@ TEST(PipelineValidatorTest, ExplainReturnsPortFlowMismatchRemediation) { EXPECT_EQ(target_diag->remediation->cause, RemediationCause::kPortFlowMismatch); EXPECT_EQ(target_diag->remediation->facts.value("bound_key", ""), - "corpus_text"); + "chunk_text"); EXPECT_EQ(target_diag->remediation->facts.value("producer_id", ""), "producer"); diff --git a/tests/unit/core/test_validated_pipeline_plan.cpp b/tests/unit/core/test_validated_pipeline_plan.cpp index 56748c3b..177d3cc5 100644 --- a/tests/unit/core/test_validated_pipeline_plan.cpp +++ b/tests/unit/core/test_validated_pipeline_plan.cpp @@ -214,6 +214,64 @@ REGISTER_NODE_WITH_DEFINITION(FlowContractProducerNode, REGISTER_NODE_WITH_DEFINITION(FlowContractConsumerNode, MakeFlowContractConsumerDefinition()); +// Shape fixtures: one item-wise pair, one per-request group and one opaque +// relation. Only their Definitions matter to the Validator. +class ShapeFixtureNode : public INode { + public: + bool Init(const NodeInitContext&) override { return true; } + int Process(AlgContext*) override { return 0; } + const std::string& Name() const override { + static const std::string name = "ShapeFixtureNode"; + return name; + } +}; + +class ItemPairNode : public ShapeFixtureNode { + public: + inline static constexpr char kNodeType[] = "ItemPairNode"; +}; + +class RequestGroupNode : public ShapeFixtureNode { + public: + inline static constexpr char kNodeType[] = "RequestGroupNode"; +}; + +class OpaqueFlowNode : public ShapeFixtureNode { + public: + inline static constexpr char kNodeType[] = "OpaqueFlowNode"; +}; + +NodeDefinition MakeShapeFixtureDefinition(std::string node_type, + std::vector in, + std::vector out) { + NodeDefinition def; + def.node_type = std::move(node_type); + def.category = "test"; + def.description = "Port shape fixture"; + def.inputs = std::move(in); + def.outputs = std::move(out); + return def; +} + +REGISTER_NODE_WITH_DEFINITION( + ItemPairNode, MakeShapeFixtureDefinition( + ItemPairNode::kNodeType, + {NodePortDefinition{"left", "TextBatch", true}, + NodePortDefinition{"right", "TextBatch", false}}, + {NodePortDefinition{"paired", "TextBatch", true}})); +REGISTER_NODE_WITH_DEFINITION( + RequestGroupNode, + MakeShapeFixtureDefinition( + RequestGroupNode::kNodeType, + {NodePortDefinition{"items", "TextBatch", true, "N:1", "aggregate"}}, + {NodePortDefinition{"grouped", "TextBatch", true}})); +REGISTER_NODE_WITH_DEFINITION( + OpaqueFlowNode, + MakeShapeFixtureDefinition( + OpaqueFlowNode::kNodeType, + {NodePortDefinition{"items", "TextBatch", true, "N:M"}}, + {NodePortDefinition{"opaque", "TextBatch", true, "N:M"}})); + class ValidatedPipelinePlanTest : public ::testing::Test { protected: void SetUp() override { RegisterTestBizs({"plan_fixture_biz"}); } @@ -299,7 +357,6 @@ TEST_F(ValidatedPipelinePlanTest, RejectsIncompatiblePortExecutionContracts) { auto plan = PipelineValidator::ValidateAndPlan(pipeline_json); EXPECT_FALSE(plan.report.ok); const std::unordered_set expected = { - DiagnosticCode::kPortCardinalityMismatch, DiagnosticCode::kPortProvenanceMismatch, DiagnosticCode::kPortLifetimeMismatch}; std::unordered_set actual; @@ -314,6 +371,139 @@ TEST_F(ValidatedPipelinePlanTest, RejectsIncompatiblePortExecutionContracts) { } } for (const auto code : expected) EXPECT_TRUE(actual.count(code)); + // An item-wise input accepts split items; only provenance and lifetime fail. + EXPECT_FALSE(actual.count(DiagnosticCode::kPortCardinalityMismatch)); +} + +class PortShapeTest : public ::testing::Test { + protected: + void SetUp() override { + if (!PipelineCatalog::FindBiz(kBiz)) { + BizDefinition biz; + biz.biz_name = kBiz; + biz.ingress = {BizPortDefinition{"request", "TextBatch", true}}; + biz.egress = {BizPortDefinition{"result", "TextBatch", false, "1:1"}, + BizPortDefinition{"collection", "TextBatch", false, "N:1", + "aggregate"}}; + ASSERT_TRUE(PipelineCatalog::RegisterBizDefinition(biz)); + } + } + + static nlohmann::json Node(const std::string& id, const std::string& type, + nlohmann::json inputs, nlohmann::json outputs) { + return {{"id", id}, + {"node_type", type}, + {"inputs", std::move(inputs)}, + {"outputs", std::move(outputs)}}; + } + + static nlohmann::json Split(const std::string& id, const std::string& key) { + return Node(id, FlowContractProducerNode::kNodeType, + nlohmann::json::object(), {{"flow", key}}); + } + + static ValidationReport Validate(nlohmann::json nodes) { + return PipelineValidator::Validate( + {{"biz_name", kBiz}, {"pipeline", std::move(nodes)}}); + } + + static std::vector CardinalityErrors( + const ValidationReport& report) { + std::vector found; + for (const auto& diagnostic : report.diagnostics) { + if (diagnostic.code == DiagnosticCode::kPortCardinalityMismatch) + found.push_back(diagnostic); + } + return found; + } + + inline static constexpr char kBiz[] = "port_shape_fixture_biz"; +}; + +TEST_F(PortShapeTest, ItemWiseNodeAcceptsSplitItems) { + const auto report = + Validate({Split("split", "parts"), + Node("pair", ItemPairNode::kNodeType, {{"left", "parts"}}, + {{"paired", "paired_parts"}})}); + EXPECT_TRUE(report.ok) << report.ToJson().dump(); +} + +TEST_F(PortShapeTest, SplitItemsCannotReachOnePerRequestEgress) { + const auto report = + Validate({Split("split", "parts"), + Node("pair", ItemPairNode::kNodeType, {{"left", "parts"}}, + {{"paired", "result"}})}); + const auto errors = CardinalityErrors(report); + ASSERT_EQ(errors.size(), 1u) << report.ToJson().dump(); + EXPECT_EQ(errors[0].path, "/pipeline/1/outputs/paired"); + EXPECT_EQ(errors[0].node_id, "pair"); + EXPECT_EQ(errors[0].port, "result"); + EXPECT_EQ(errors[0].related_nodes, std::vector({"$egress"})); + EXPECT_NE(errors[0].message.find("'split.flow'"), std::string::npos); +} + +TEST_F(PortShapeTest, CollectionEgressAcceptsSplitItems) { + const auto report = + Validate({Split("split", "parts"), + Node("pair", ItemPairNode::kNodeType, {{"left", "parts"}}, + {{"paired", "collection"}})}); + EXPECT_TRUE(report.ok) << report.ToJson().dump(); +} + +TEST_F(PortShapeTest, AggregatedItemsTransformItemWiseToOnePerRequest) { + const auto report = + Validate({Split("split", "parts"), + Node("group", RequestGroupNode::kNodeType, {{"items", "parts"}}, + {{"grouped", "grouped"}}), + Node("pair", ItemPairNode::kNodeType, + {{"left", "grouped"}, {"right", "request"}}, + {{"paired", "result"}})}); + EXPECT_TRUE(report.ok) << report.ToJson().dump(); +} + +TEST_F(PortShapeTest, MisalignedItemWiseInputsAreRejected) { + const auto report = Validate( + {Split("split", "parts"), Node("pair", ItemPairNode::kNodeType, + {{"left", "request"}, {"right", "parts"}}, + {{"paired", "paired_parts"}})}); + const auto errors = CardinalityErrors(report); + ASSERT_EQ(errors.size(), 1u) << report.ToJson().dump(); + EXPECT_EQ(errors[0].path, "/pipeline/1/inputs/right"); + EXPECT_EQ(errors[0].node_id, "pair"); + EXPECT_EQ(errors[0].port, "right"); + EXPECT_EQ(errors[0].related_nodes, std::vector({"split"})); + ASSERT_TRUE(errors[0].remediation.has_value()); + EXPECT_EQ(errors[0].remediation->cause, RemediationCause::kPortFlowMismatch); +} + +TEST_F(PortShapeTest, ItemWiseInputsPairOnlyWithinOneFanOut) { + const auto same_origin = + Validate({Split("split", "parts"), + Node("first", ItemPairNode::kNodeType, {{"left", "parts"}}, + {{"paired", "first_parts"}}), + Node("second", ItemPairNode::kNodeType, {{"left", "parts"}}, + {{"paired", "second_parts"}}), + Node("join", ItemPairNode::kNodeType, + {{"left", "first_parts"}, {"right", "second_parts"}}, + {{"paired", "joined"}})}); + EXPECT_TRUE(same_origin.ok) << same_origin.ToJson().dump(); + + const auto different_origins = + Validate({Split("split_a", "parts_a"), Split("split_b", "parts_b"), + Node("join", ItemPairNode::kNodeType, + {{"left", "parts_a"}, {"right", "parts_b"}}, + {{"paired", "joined"}})}); + const auto errors = CardinalityErrors(different_origins); + ASSERT_EQ(errors.size(), 1u) << different_origins.ToJson().dump(); + EXPECT_EQ(errors[0].port, "right"); +} + +TEST_F(PortShapeTest, UnknownShapeIsLeftToRuntimeChecks) { + const auto report = + Validate({Split("split", "parts"), + Node("opaque", OpaqueFlowNode::kNodeType, {{"items", "parts"}}, + {{"opaque", "result"}})}); + EXPECT_TRUE(report.ok) << report.ToJson().dump(); } TEST_F(ValidatedPipelinePlanTest, From 9391fe022a1e0af961308119da9061a30e654bfd Mon Sep 17 00:00:00 2001 From: chamsechan Date: Sun, 4 Oct 2026 00:08:27 +0800 Subject: [PATCH 04/29] refactor(adapter): serialize rule match responses in output converters TextRuleMatchNode now publishes a neutral RuleMatchItem: scalar fields for the first or default hit, every hit in matches, and typed slots for captures, constants and the default raw_query. The keyword and audio converters share an Adapter-private serializer, so renaming a JSON key no longer happens inside a common Node. A byte-level Operator contract test written against the previous implementation keeps multi-rule hits, typed slots and the default hit unchanged. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 +- doc/CHANGELOG.md | 5 + include/core/common_contracts.h | 35 ++++-- plans/ARCHITECTURE_REVIEW.md | 2 +- src/adapter/output/README.md | 1 + src/adapter/output/audio_result_output.cpp | 5 +- src/adapter/output/keyword_result_output.cpp | 3 +- src/adapter/output/rule_match_response.cpp | 34 ++++++ src/adapter/output/rule_match_response.h | 13 +++ src/common_nodes/text_rule_match_node.cpp | 104 +++++------------- .../abi/test_adapter_contract_security.cpp | 82 ++++++++++++++ tests/integration/demo/test_demo_runner.cpp | 1 - tests/unit/adapter/test_adapter_purity.cpp | 45 ++++---- .../unit/adapter/test_complex_converters.cpp | 13 ++- tests/unit/nodes/test_common_nodes.cpp | 18 ++- .../unit/nodes/test_text_rule_match_node.cpp | 18 +-- 16 files changed, 244 insertions(+), 138 deletions(-) create mode 100644 src/adapter/output/rule_match_response.cpp create mode 100644 src/adapter/output/rule_match_response.h diff --git a/.gitignore b/.gitignore index 97e1b872..2403e42b 100644 --- a/.gitignore +++ b/.gitignore @@ -68,7 +68,8 @@ models/*_vocab.txt # 4. 测试报告与运行日志 (Test Reports, GTest Outputs & Logs) # ------------------------------------------------------------------------------ results/ -output/ +# Demo run outputs; anchored so src/adapter/output/ converters stay tracked. +/output/ *.log *.tlog report.xml diff --git a/doc/CHANGELOG.md b/doc/CHANGELOG.md index f1709639..a319e4b4 100644 --- a/doc/CHANGELOG.md +++ b/doc/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +规则匹配结果:`TextRuleMatchNode` 只输出中性结果,`RuleMatchItem` 以 `matches` 列出全部命中、 +以 `slots` 保存带 JSON 类型的捕获与常量,删除 `match_result_json`、`details`、`captures` 和 +`constants`。关键词响应的 `match_result_json` 与音频响应的 `intent_slot_json` 改由输出转换器序列化, +字段、类型、默认命中与字节内容保持不变。 + 端口数量关系:输入端口的 cardinality 只描述节点如何消费,接受任意数量的上游数据; `TextChunkNode` 等拆分输出可以直接接逐项节点(如 `TextRuleMatchNode`)。Validator 改为沿 DAG 推导每个数据名在请求内的条目数,仅在 `1:1` 业务/IO 边界收到多项,或同一节点的逐项输入无法配对时 diff --git a/include/core/common_contracts.h b/include/core/common_contracts.h index 08674465..4d919151 100644 --- a/include/core/common_contracts.h +++ b/include/core/common_contracts.h @@ -58,8 +58,31 @@ struct RankedCandidate { */ using RankedTextBatch = std::vector>; +/** + * @brief 规则匹配的命中来源 + */ +enum class RuleMatchSource { + kKeyword, // categories 词表命中,pattern 为命中的词 + kRule, // 结构化规则命中,pattern 为规则模式 +}; + +/** + * @brief 一次规则或关键词命中 (RuleMatchHit) + */ +struct RuleMatchHit { + RuleMatchSource source = RuleMatchSource::kKeyword; + std::string category; + std::string rule_id; + std::string pattern; + float score = 1.0f; +}; + /** * @brief 规则与关键词匹配结果载荷 (RuleMatchItem) + * + * 标量字段描述首个命中或默认命中;matches 按匹配顺序列出全部命中;slots 合并 + * 正则捕获、保留 JSON 类型的规则常量和默认命中的 raw_query。外部响应格式由 + * 各输出转换器生成,Node 不序列化。 */ struct RuleMatchItem { int is_hit = 0; @@ -68,22 +91,18 @@ struct RuleMatchItem { std::string rule_id; std::string matched_word; float score = 0.0f; - std::unordered_map captures; - std::unordered_map constants; - std::string match_result_json; - nlohmann::json details = nlohmann::json::object(); + std::vector matches; + nlohmann::json slots = nlohmann::json::object(); RuleMatchItem() = default; - RuleMatchItem(int hit, std::string cat, std::string word, - std::string res_json = {}, float sc = 0.0f, + RuleMatchItem(int hit, std::string cat, std::string word, float sc = 0.0f, std::string rid = {}) : is_hit(hit), status_code(0), category(std::move(cat)), rule_id(std::move(rid)), matched_word(std::move(word)), - score(sc), - match_result_json(std::move(res_json)) {} + score(sc) {} }; /** diff --git a/plans/ARCHITECTURE_REVIEW.md b/plans/ARCHITECTURE_REVIEW.md index 6411260e..7bcad146 100644 --- a/plans/ARCHITECTURE_REVIEW.md +++ b/plans/ARCHITECTURE_REVIEW.md @@ -314,7 +314,7 @@ Definition、Catalog、Validator、节点运行时及工具的解释须一致。 | 编号 | 项目 | 来源 | 状态 | | --- | --- | --- | --- | | A1 | 端口数量关系改为形状推导 | 第一节 2 | 已完成:Validator 推导形状;`TextEmbeddingNode` 改为 `1:1`;规则见[数量关系声明](../doc/dev_guide/custom_node_concepts.md#数量关系声明与-validator-检查) | -| A2 | RuleMatch 外部响应序列化移到 Adapter | 第一节 3 | 待实施 | +| A2 | RuleMatch 外部响应序列化移到 Adapter | 第一节 3 | 已完成:`RuleMatchItem` 改为中性的全部命中与带类型槽位;两个转换器共用 Adapter 序列化,字节级契约测试覆盖 | | A3 | 错误码按阶段映射 | 第一节 1 | 待实施 | | A4 | 致命错误与单条无效结果写入业务契约 | 第一节 4 | 待实施 | | B1 | 清理未使用的结果结构 | 第三节·接入适配层 | 待实施 | diff --git a/src/adapter/output/README.md b/src/adapter/output/README.md index d8bdc20c..54c39d64 100644 --- a/src/adapter/output/README.md +++ b/src/adapter/output/README.md @@ -6,6 +6,7 @@ - 每个输出转换器通过 `REGISTER_OUTPUT_CONVERTER` 注册 `OutputConverterDefinition`。 - 单槽、每请求一个结果使用 `EncodeResultRows`;业务函数接收结果与借用的宿主输出,用 `OutputStringWriter` 写入字符串。框架处理绑定、结果重排、外部编号和 `written_count`。 - Definition 与 `ReadOutputValue` 共用 typed 逻辑端口,实际 key 始终通过 bindings 解析;结果来源复用已有 `IndexResults`。 +- Node 只发布中性结果;外部字段名、默认值和序列化格式在此处生成。多个转换器共用同一外部格式时,序列化函数放在本目录的私有头中,例如 `rule_match_response.h`。 - 负责从 `AlgContext` 读取执行产物,校验基数与来源连续性(provenance),编码并安全写入已分配好的外部输出目标或 Operator 输出池。 - 输出容量检查失败时返回缓冲区不足并标明约束,不静默截断字符串。 - 字符串用 `WriteOutputString` 读取 view 中真实池规格,不抄容量默认值;手工测试视图也必须提供实际容量。排名数组等非字符串输出保持专用填充。 diff --git a/src/adapter/output/audio_result_output.cpp b/src/adapter/output/audio_result_output.cpp index b5381b6d..b0839cfc 100644 --- a/src/adapter/output/audio_result_output.cpp +++ b/src/adapter/output/audio_result_output.cpp @@ -7,6 +7,7 @@ #include "adapter/biz_blackboard_keys.h" #include "adapter/converter_authoring.h" #include "adapter/io_converter.h" +#include "adapter/output/rule_match_response.h" #include "adapter/result_validation.h" #include "contracts/inference_payloads.h" #include "edgeflow/operator/types.h" @@ -68,8 +69,8 @@ int EncodeOperatorAudioResult(AlgContext* context, out->request_id = (*raw_req_ids)[i]; out->status_code = intent_slots_by_request[i]->data.status_code; - const std::string& slot_json = - intent_slots_by_request[i]->data.match_result_json; + const std::string slot_json = + SerializeRuleMatchResponse(intent_slots_by_request[i]->data); if (!WriteOutputString(*destination, kOutputSlot, out->transcribed_text, "transcribed_text", transcripts_by_request[i]->data, diff --git a/src/adapter/output/keyword_result_output.cpp b/src/adapter/output/keyword_result_output.cpp index 778a1099..f3136eb0 100644 --- a/src/adapter/output/keyword_result_output.cpp +++ b/src/adapter/output/keyword_result_output.cpp @@ -8,6 +8,7 @@ #include "adapter/biz_results.h" #include "adapter/converter_authoring.h" #include "adapter/io_converter.h" +#include "adapter/output/rule_match_response.h" #include "adapter/result_validation.h" #include "edgeflow/operator/types.h" @@ -22,7 +23,7 @@ AdapterStatus EncodeKeyword(const RuleMatchItem& result, output->is_hit = result.is_hit; output->status_code = result.status_code; return writer.Write(output->match_result_json, "match_result_json", - result.match_result_json); + SerializeRuleMatchResponse(result)); } int EncodeOperatorKeywordResult(AlgContext* context, diff --git a/src/adapter/output/rule_match_response.cpp b/src/adapter/output/rule_match_response.cpp new file mode 100644 index 00000000..d7de5a00 --- /dev/null +++ b/src/adapter/output/rule_match_response.cpp @@ -0,0 +1,34 @@ +#include "adapter/output/rule_match_response.h" + +#include +#include + +namespace llm_edgeflow { + +std::string SerializeRuleMatchResponse(const RuleMatchItem& result) { + nlohmann::json matches = nlohmann::json::array(); + for (const auto& hit : result.matches) { + nlohmann::json entry; + entry["category"] = hit.category; + if (hit.source == RuleMatchSource::kRule) { + entry["rule_id"] = hit.rule_id; + entry["pattern"] = hit.pattern; + entry["score"] = hit.score; + } else { + entry["matched_word"] = hit.pattern; + } + matches.push_back(std::move(entry)); + } + + nlohmann::json response; + response["matches"] = std::move(matches); + if (result.is_hit && !result.category.empty()) { + response["intent"] = result.category; + response["matched_word"] = result.matched_word; + response["confidence"] = result.score; + response["slots"] = result.slots; + } + return response.dump(); +} + +} // namespace llm_edgeflow diff --git a/src/adapter/output/rule_match_response.h b/src/adapter/output/rule_match_response.h new file mode 100644 index 00000000..6bf3084a --- /dev/null +++ b/src/adapter/output/rule_match_response.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +#include "core/common_contracts.h" + +namespace llm_edgeflow { + +// Serializes a rule match result as the JSON object used by the keyword +// response (match_result_json) and the audio response (intent_slot_json). +std::string SerializeRuleMatchResponse(const RuleMatchItem& result); + +} // namespace llm_edgeflow diff --git a/src/common_nodes/text_rule_match_node.cpp b/src/common_nodes/text_rule_match_node.cpp index 3c72d3e9..825abca0 100644 --- a/src/common_nodes/text_rule_match_node.cpp +++ b/src/common_nodes/text_rule_match_node.cpp @@ -99,8 +99,7 @@ struct RuleSpec { std::string pattern; std::string category; float score = kDefaultScore; - std::unordered_map constants; - std::unordered_map constants_json; + std::unordered_map constants; std::shared_ptr compiled_regex; }; @@ -153,14 +152,7 @@ bool BuildRules(const nlohmann::json& rules_json, if (r_elem.contains("constants") && r_elem["constants"].is_object()) { for (auto it = r_elem["constants"].begin(); it != r_elem["constants"].end(); ++it) { - spec.constants_json[it.key()] = it.value(); - if (it.value().is_string()) { - spec.constants[it.key()] = it.value().get(); - } else if (it.value().is_boolean()) { - spec.constants[it.key()] = it.value().get() ? "true" : "false"; - } else if (it.value().is_number()) { - spec.constants[it.key()] = it.value().dump(); - } + spec.constants[it.key()] = it.value(); } } @@ -214,34 +206,26 @@ NodeResult MatchRules(const RuleInputs& inputs, for (const auto& item : *text_items) { const std::string& sentence = item.data; - uint32_t req_id = item.req_id; - uint32_t sub_id = item.sub_id; - - nlohmann::json matches_array = nlohmann::json::array(); - std::string first_hit_category; - std::string first_hit_word; - std::string first_hit_rule_id; - float first_hit_score = 0.0f; - int is_hit = 0; - std::unordered_map total_captures; - std::unordered_map total_constants; - nlohmann::json slots_obj = nlohmann::json::object(); + RuleMatchItem result; + // The first hit with a category names the result. + auto record_hit = [&](const std::string& category, const std::string& word, + float score, const std::string& rule_id) { + result.is_hit = 1; + if (!result.category.empty()) return; + result.category = category; + result.matched_word = word; + result.score = score; + result.rule_id = rule_id; + }; // 1. 匹配 categories (词表模式) for (const auto& [category, words] : state.category_keywords_list) { for (const auto& w : words) { if (w.empty()) continue; if (sentence.find(w) != std::string::npos) { - is_hit = 1; - if (first_hit_category.empty()) { - first_hit_category = category; - first_hit_word = w; - first_hit_score = 1.0f; - } - nlohmann::json match_elem; - match_elem["category"] = category; - match_elem["matched_word"] = w; - matches_array.push_back(std::move(match_elem)); + record_hit(category, w, 1.0f, {}); + result.matches.push_back( + {RuleMatchSource::kKeyword, category, {}, w, 1.0f}); } } } @@ -280,58 +264,20 @@ NodeResult MatchRules(const RuleInputs& inputs, } if (rule_matched) { - is_hit = 1; - if (first_hit_category.empty()) { - first_hit_category = rule.category; - first_hit_word = rule.pattern; - first_hit_rule_id = rule.id; - first_hit_score = rule.score; - } - for (const auto& [k, v] : rule_captures) { - total_captures[k] = v; - slots_obj[k] = v; - } - for (const auto& [k, v] : rule.constants_json) { - slots_obj[k] = v; - } - for (const auto& [k, v] : rule.constants) { - total_constants[k] = v; - } - - nlohmann::json match_elem; - match_elem["rule_id"] = rule.id; - match_elem["category"] = rule.category; - match_elem["pattern"] = rule.pattern; - match_elem["score"] = rule.score; - matches_array.push_back(std::move(match_elem)); + record_hit(rule.category, rule.pattern, rule.score, rule.id); + for (const auto& [k, v] : rule_captures) result.slots[k] = v; + for (const auto& [k, v] : rule.constants) result.slots[k] = v; + result.matches.push_back({RuleMatchSource::kRule, rule.category, + rule.id, rule.pattern, rule.score}); } } - if (!is_hit && !state.default_category.empty()) { - is_hit = 1; - first_hit_category = state.default_category; - first_hit_score = state.default_score; - first_hit_word = ""; - slots_obj["raw_query"] = sentence; + if (!result.is_hit && !state.default_category.empty()) { + record_hit(state.default_category, "", state.default_score, {}); + result.slots["raw_query"] = sentence; } - nlohmann::json result_json; - result_json["matches"] = matches_array; - if (is_hit && !first_hit_category.empty()) { - result_json["intent"] = first_hit_category; - result_json["matched_word"] = first_hit_word; - result_json["confidence"] = first_hit_score; - result_json["slots"] = slots_obj; - } - - RuleMatchItem match_item( - is_hit, first_hit_category, first_hit_word, result_json.dump(), - is_hit ? first_hit_score : 0.0f, first_hit_rule_id); - match_item.captures = std::move(total_captures); - match_item.constants = std::move(total_constants); - match_item.details = result_json; - - output_matches.emplace_back(req_id, sub_id, std::move(match_item)); + output_matches.emplace_back(item.req_id, item.sub_id, std::move(result)); } return NodeResult::Success(std::move(output_matches)); diff --git a/tests/contract/abi/test_adapter_contract_security.cpp b/tests/contract/abi/test_adapter_contract_security.cpp index b2c6c7bf..da6a43fd 100644 --- a/tests/contract/abi/test_adapter_contract_security.cpp +++ b/tests/contract/abi/test_adapter_contract_security.cpp @@ -1311,4 +1311,86 @@ TEST_F(AdapterContractSecurityTest, OperatorInputLimitsStayUnchanged) { } } +TEST_F(AdapterContractSecurityTest, + KeywordOperatorRuleMatchResponseIsByteStable) { + const auto directory = + std::filesystem::temp_directory_path() / + ("edgeflow-keyword-" + + std::to_string( + std::chrono::steady_clock::now().time_since_epoch().count())); + std::filesystem::create_directory(directory); + struct Cleanup { + std::filesystem::path directory; + ~Cleanup() { + std::error_code error; + std::filesystem::remove_all(directory, error); + } + } cleanup{directory}; + // Keyword and rule hits, typed rule constants, regex captures and the + // default hit all reach the external JSON response. + const nlohmann::json pipeline = nlohmann::json::parse(R"j({ + "deployment": {"io": {"io_binding": "keyword_match.operator.v1"}}, + "models": [], + "pipeline": [{ + "id": "rules", "node_type": "TextRuleMatchNode", + "inputs": {"text": "input_sentences"}, + "outputs": {"matches": "rule_matches"}, + "config": { + "categories": {"A": ["x"]}, + "rules": [ + {"id": "r_nav", "strategy": "regex", "pattern": "导航到(?北京)", + "category": "NAV", "score": 0.75, + "constants": {"flag": true, "n": 3, "s": "v"}}, + {"id": "r_y", "strategy": "contains", "pattern": "y", + "category": "B", "score": 0.5}], + "default_category": "FALLBACK", "default_score": 0.25}}]})j"); + std::ofstream(directory / "pipeline.json") << pipeline.dump(); + std::ofstream(directory / "pipeline.conf") + << "{\"pipe_path\":\"pipeline.json\"}"; + + operator_api::CreateParam create{}; + create.model_path = directory.c_str(); + create.cfg_file_name = "pipeline.conf"; + create.compute_platform = operator_api::ComputePlatform::kCpu; + void* raw_handle = nullptr; + auto op = operator_api::Get_LLM_EDGEFLOW_OperatorTable(); + ASSERT_EQ(op.Create(&raw_handle, &create), 0); + std::unique_ptr handle(raw_handle, op.Destroy); + + std::string hit_text = "x 导航到北京y"; + std::string fallback_text = "nothing"; + CompanyString hit{static_cast(hit_text.size()), hit_text.data()}; + CompanyString fallback{static_cast(fallback_text.size()), + fallback_text.data()}; + CompanyOperatorKeywordInput first{301, &hit}; + CompanyOperatorKeywordInput second{302, &fallback}; + operator_api::NamedIoBatch inputs(2), outputs(2); + inputs[0]["kw.keyword_in"] = operator_api::MakeBorrowedOperatorInput(&first); + inputs[1]["kw.keyword_in"] = operator_api::MakeBorrowedOperatorInput(&second); + outputs[0]["kw.keyword_out"] = nullptr; + outputs[1]["kw.keyword_out"] = nullptr; + ASSERT_EQ(op.Process(handle.get(), inputs, outputs), 0) + << operator_api::GetOperatorLastError(); + + auto response = [&](size_t index) { + const auto* out = static_cast( + outputs[index]["kw.keyword_out"].get()); + EXPECT_NE(out, nullptr); + EXPECT_EQ(out->is_hit, 1); + EXPECT_EQ(out->status_code, 0); + return std::string(out->match_result_json->data, + out->match_result_json->length); + }; + EXPECT_EQ(response(0), + R"j({"confidence":1.0,"intent":"A","matched_word":"x","matches":[)j" + R"j({"category":"A","matched_word":"x"},)j" + R"j({"category":"NAV","pattern":"导航到(?北京)",)j" + R"j("rule_id":"r_nav","score":0.75},)j" + R"j({"category":"B","pattern":"y","rule_id":"r_y","score":0.5}],)j" + R"j("slots":{"city":"北京","flag":true,"n":3,"s":"v"}})j"); + EXPECT_EQ(response(1), + R"j({"confidence":0.25,"intent":"FALLBACK","matched_word":"",)j" + R"j("matches":[],"slots":{"raw_query":"nothing"}})j"); +} + } // namespace llm_edgeflow diff --git a/tests/integration/demo/test_demo_runner.cpp b/tests/integration/demo/test_demo_runner.cpp index e47ba851..b7dcafad 100644 --- a/tests/integration/demo/test_demo_runner.cpp +++ b/tests/integration/demo/test_demo_runner.cpp @@ -47,7 +47,6 @@ class TestDemoStatusNode final : public NodeBase { for (const auto& item : *input) { RuleMatchItem result; result.status_code = item.data == "fail" ? -42 : 0; - result.match_result_json = "{}"; results.emplace_back(item.req_id, item.sub_id, std::move(result)); } output_.Set(context, std::move(results)); diff --git a/tests/unit/adapter/test_adapter_purity.cpp b/tests/unit/adapter/test_adapter_purity.cpp index f0469344..de9f3998 100644 --- a/tests/unit/adapter/test_adapter_purity.cpp +++ b/tests/unit/adapter/test_adapter_purity.cpp @@ -178,8 +178,7 @@ TEST_F(AdapterPurityTest, DocQaAdapterPurity) { harness.Publish("llm_answers", std::move(answers)); RuleMatchBatch intents; - intents.emplace_back(0, 0, - RuleMatchItem(1, "GENERAL_QA", "query", "{}", 0.95f)); + intents.emplace_back(0, 0, RuleMatchItem(1, "GENERAL_QA", "query", 0.95f)); harness.Publish("intent_matches", std::move(intents)); Int32Batch chunk_counts; @@ -219,9 +218,7 @@ TEST_F(AdapterPurityTest, KeywordMatchAdapterPurity) { ASSERT_EQ(harness.DecodeOperator({&in}), 0); RuleMatchBatch matches; - matches.emplace_back( - 0, 0, - RuleMatchItem(1, "TEST_CAT", "测试", "{\"intent\":\"TEST_CAT\"}", 0.9f)); + matches.emplace_back(0, 0, RuleMatchItem(1, "TEST_CAT", "测试", 0.5f)); harness.Publish("rule_matches", std::move(matches)); KeywordOutputFixture kw_fix; @@ -231,7 +228,9 @@ TEST_F(AdapterPurityTest, KeywordMatchAdapterPurity) { EXPECT_EQ(outputs[0].request_id, 1002u); EXPECT_EQ(outputs[0].is_hit, 1); ASSERT_NE(outputs[0].match_result_json, nullptr); - EXPECT_STREQ(outputs[0].match_result_json->data, "{\"intent\":\"TEST_CAT\"}"); + EXPECT_STREQ(outputs[0].match_result_json->data, + "{\"confidence\":0.5,\"intent\":\"TEST_CAT\",\"matched_word\":" + "\"测试\",\"matches\":[],\"slots\":{}}"); } // 1.3 EntityExtractConverter Purity (Biz 3) @@ -410,8 +409,7 @@ TEST_F(AdapterPurityTest, AudioAsrIntentAdapterPurity) { harness.Publish("transcripts", std::move(transcripts)); RuleMatchBatch intent_slots; - intent_slots.emplace_back( - 0, 0, RuleMatchItem(1, "NAV", "", "{\"intent\":\"NAV\"}", 0.99f)); + intent_slots.emplace_back(0, 0, RuleMatchItem(1, "NAV", "", 0.5f)); harness.Publish("intent_slots", std::move(intent_slots)); AudioOutputFixture audio_fix; @@ -422,7 +420,9 @@ TEST_F(AdapterPurityTest, AudioAsrIntentAdapterPurity) { ASSERT_NE(outputs[0].transcribed_text, nullptr); EXPECT_STREQ(outputs[0].transcribed_text->data, "turn left"); ASSERT_NE(outputs[0].intent_slot_json, nullptr); - EXPECT_STREQ(outputs[0].intent_slot_json->data, "{\"intent\":\"NAV\"}"); + EXPECT_STREQ(outputs[0].intent_slot_json->data, + "{\"confidence\":0.5,\"intent\":\"NAV\",\"matched_word\":\"\"," + "\"matches\":[],\"slots\":{}}"); } // 1.7 CrossRerankConverter Purity (Biz 7) @@ -554,7 +554,7 @@ TEST_F(AdapterPurityTest, DocQaAdapter_FailClosedWhenMissingOutputs) { answers.emplace_back(0, 0, "Some answer"); harness.Publish("llm_answers", std::move(answers)); RuleMatchBatch intents; - intents.emplace_back(0, 0, RuleMatchItem(1, "QA", "", "{}", 0.9f)); + intents.emplace_back(0, 0, RuleMatchItem(1, "QA", "", 0.9f)); harness.Publish("intent_matches", std::move(intents)); DocOutputFixture fix; std::vector outputs = {fix.out}; @@ -569,7 +569,7 @@ TEST_F(AdapterPurityTest, DocQaAdapter_FailClosedWhenMissingOutputs) { answers.emplace_back(0, 0, "Some answer"); harness.Publish("llm_answers", std::move(answers)); RuleMatchBatch intents; - intents.emplace_back(0, 0, RuleMatchItem(1, "QA", "", "{}", 0.9f)); + intents.emplace_back(0, 0, RuleMatchItem(1, "QA", "", 0.9f)); harness.Publish("intent_matches", std::move(intents)); Int32Batch chunk_counts; chunk_counts.emplace_back(0, 0, 1); @@ -730,7 +730,7 @@ TEST_F(AdapterPurityTest, const std::vector request_ids{10}; ctx.Publish("llm_answers", TextBatch{{0, 0, long_answer}}); ctx.Publish("intent_matches", - RuleMatchBatch{{0, 0, RuleMatchItem(1, "QA", "", "{}", 0.9f)}}); + RuleMatchBatch{{0, 0, RuleMatchItem(1, "QA", "", 0.9f)}}); ctx.Publish("doc_chunk_counts", Int32Batch{{0, 0, 1}}); const auto* op_conv = IoConverterRegistry::Instance().FindOutputConverter( @@ -814,9 +814,8 @@ TEST_F(AdapterPurityTest, DocAnswerExactCapacityAndOneByteOverflow) { AlgContext context; context.Publish(kLlmAnswers, TextBatch{{0, 0, overflow ? "12345" : "1234"}}); - context.Publish( - kIntentMatches, - RuleMatchBatch{{0, 0, RuleMatchItem(1, "QA", "", "{}", 0.9f)}}); + context.Publish(kIntentMatches, + RuleMatchBatch{{0, 0, RuleMatchItem(1, "QA", "", 0.9f)}}); context.Publish(kDocChunkCounts, Int32Batch{{0, 0, 3}}); // Capacities count payload bytes; storage also reserves the terminator. @@ -960,8 +959,8 @@ TEST_F(AdapterPurityTest, DocQaAdapter_MultiWayResultsReorderedAndPerturbed) { // Perturbed order: index 1 published before index 0 TextBatch answers{{1, 0, "Answer 1"}, {0, 0, "Answer 0"}}; - RuleMatchBatch intents{{0, 0, RuleMatchItem(1, "INTENT_0", "", "{}", 0.9f)}, - {1, 0, RuleMatchItem(2, "INTENT_1", "", "{}", 0.8f)}}; + RuleMatchBatch intents{{0, 0, RuleMatchItem(1, "INTENT_0", "", 0.9f)}, + {1, 0, RuleMatchItem(2, "INTENT_1", "", 0.8f)}}; Int32Batch chunks{{0, 0, 3}, {1, 0, 5}}; harness.Publish("llm_answers", std::move(answers)); @@ -1242,16 +1241,16 @@ TEST_F(AdapterPurityTest, ReuseProof_4_IndependentlySwitchOutputFormat) { test::AdapterHarness harness( out_b, OutputPortBindings({{"rule_matches", "rule_matches"}})); harness.SetRequestIds(std::vector{5001}); - harness.Publish( - "rule_matches", - RuleMatchBatch{{0, 0, - RuleMatchItem(1, "URGENT", "急", - "{\"flag\":\"urgent\"}", 0.99f)}}); + RuleMatchItem urgent(1, "URGENT", "急", 1.0f); + urgent.slots["flag"] = "urgent"; + harness.Publish("rule_matches", RuleMatchBatch{{0, 0, urgent}}); ASSERT_EQ(harness.EncodeOperator(&outputs, fix.Capacities()), 0); EXPECT_EQ(outputs[0].request_id, 5001U); EXPECT_EQ(outputs[0].is_hit, 1); ASSERT_NE(outputs[0].match_result_json, nullptr); - EXPECT_STREQ(outputs[0].match_result_json->data, "{\"flag\":\"urgent\"}"); + EXPECT_STREQ(outputs[0].match_result_json->data, + "{\"confidence\":1.0,\"intent\":\"URGENT\",\"matched_word\":" + "\"急\",\"matches\":[],\"slots\":{\"flag\":\"urgent\"}}"); } } diff --git a/tests/unit/adapter/test_complex_converters.cpp b/tests/unit/adapter/test_complex_converters.cpp index 712036a4..a469b10a 100644 --- a/tests/unit/adapter/test_complex_converters.cpp +++ b/tests/unit/adapter/test_complex_converters.cpp @@ -350,10 +350,10 @@ TEST_F(ComplexConvertersTest, AudioAsrIntentOperatorInputAndOutput) { ctx.Publish("transcripts", std::move(transcripts)); RuleMatchBatch slots; - RuleMatchItem m; - m.status_code = 0; - m.match_result_json = - "{\"intent\":\"open_door\",\"slot\":{\"target\":\"front\"}}"; + RuleMatchItem m(1, "open_door", "open", 1.0f, "r1"); + m.matches.push_back( + {RuleMatchSource::kRule, "open_door", "r1", "open", 0.5f}); + m.slots["target"] = "front"; slots.emplace_back(0, 0, m); ctx.Publish("intent_slots", std::move(slots)); @@ -389,7 +389,10 @@ TEST_F(ComplexConvertersTest, AudioAsrIntentOperatorInputAndOutput) { EXPECT_STREQ(audio_out.transcribed_text->data, "open the front door"); ASSERT_NE(audio_out.intent_slot_json, nullptr); EXPECT_STREQ(audio_out.intent_slot_json->data, - "{\"intent\":\"open_door\",\"slot\":{\"target\":\"front\"}}"); + "{\"confidence\":1.0,\"intent\":\"open_door\"," + "\"matched_word\":\"open\",\"matches\":[{\"category\":" + "\"open_door\",\"pattern\":\"open\",\"rule_id\":\"r1\"," + "\"score\":0.5}],\"slots\":{\"target\":\"front\"}}"); } // ==================== OcrInvoiceQa ==================== diff --git a/tests/unit/nodes/test_common_nodes.cpp b/tests/unit/nodes/test_common_nodes.cpp index f09d6ef2..d64b0977 100644 --- a/tests/unit/nodes/test_common_nodes.cpp +++ b/tests/unit/nodes/test_common_nodes.cpp @@ -232,10 +232,18 @@ TEST_F(CommonNodesTest, TextRuleMatchNodeComprehensive) { ASSERT_EQ(out->size(), 2u); // Sample 1 hits both GREETING and NAVIGATION - EXPECT_EQ((*out)[0].data.is_hit, 1); - EXPECT_EQ((*out)[0].data.captures.at("destination"), "北京天安门"); - EXPECT_EQ((*out)[0].data.constants.at("avoid_toll"), "false"); - EXPECT_EQ((*out)[0].data.details["slots"]["destination"], "北京天安门"); + const auto& first = (*out)[0].data; + EXPECT_EQ(first.is_hit, 1); + EXPECT_EQ(first.category, "GREETING"); + EXPECT_EQ(first.matched_word, "你好"); + EXPECT_EQ(first.slots["destination"], "北京天安门"); + EXPECT_EQ(first.slots["avoid_toll"], "false"); + ASSERT_EQ(first.matches.size(), 2u); + EXPECT_EQ(first.matches[0].source, RuleMatchSource::kKeyword); + EXPECT_EQ(first.matches[0].pattern, "你好"); + EXPECT_EQ(first.matches[1].source, RuleMatchSource::kRule); + EXPECT_EQ(first.matches[1].rule_id, "nav_dest"); + EXPECT_EQ(first.matches[1].category, "NAVIGATION"); // Sample 2 no hit EXPECT_EQ((*out)[1].data.is_hit, 0); @@ -259,7 +267,7 @@ TEST_F(CommonNodesTest, TextRuleMatchNodeComprehensive) { const auto* out2 = ctx2.Read("matches"); ASSERT_NE(out2, nullptr); EXPECT_EQ((*out2)[0].data.is_hit, 1); - EXPECT_EQ((*out2)[0].data.captures.at("city"), "北京"); + EXPECT_EQ((*out2)[0].data.slots["city"], "北京"); } // 4. StructuredJsonParseNode: direct, markdown block, truncated input, failure diff --git a/tests/unit/nodes/test_text_rule_match_node.cpp b/tests/unit/nodes/test_text_rule_match_node.cpp index e3528746..da30fccb 100644 --- a/tests/unit/nodes/test_text_rule_match_node.cpp +++ b/tests/unit/nodes/test_text_rule_match_node.cpp @@ -196,7 +196,6 @@ TEST_F(TextRuleMatchNodeTest, ScoreBoundsAndDefaultsRemainUsable) { EXPECT_FLOAT_EQ(matches->at(3).data.score, 0.25f); for (const auto& match : *matches) { EXPECT_TRUE(std::isfinite(match.data.score)); - EXPECT_TRUE(match.data.details.at("confidence").is_number()); } } @@ -363,10 +362,10 @@ TEST_F(TextRuleMatchNodeTest, SupportsLookbehindAndNamedCaptures) { EXPECT_EQ((*matches)[0].req_id, 11u); EXPECT_EQ((*matches)[0].sub_id, 1u); EXPECT_EQ((*matches)[0].data.category, "AMOUNT"); - EXPECT_EQ((*matches)[0].data.captures.at("amount"), "123"); + EXPECT_EQ((*matches)[0].data.slots["amount"], "123"); EXPECT_EQ((*matches)[1].data.is_hit, 0); EXPECT_EQ((*matches)[2].data.category, "RISK"); - EXPECT_EQ((*matches)[2].data.captures.at("word"), "risk"); + EXPECT_EQ((*matches)[2].data.slots["word"], "risk"); } TEST_F(TextRuleMatchNodeTest, PreservesLookbehindWhenLaterGreaterThanExists) { @@ -392,7 +391,7 @@ TEST_F(TextRuleMatchNodeTest, PreservesLookbehindWhenLaterGreaterThanExists) { ASSERT_EQ(matches->size(), 2u); EXPECT_EQ((*matches)[0].data.is_hit, 0); EXPECT_EQ((*matches)[1].data.is_hit, 1); - EXPECT_EQ((*matches)[1].data.captures.at("value"), "请帮我>联系"); + EXPECT_EQ((*matches)[1].data.slots["value"], "请帮我>联系"); } TEST_F(TextRuleMatchNodeTest, RegexErrorsFailClosed) { @@ -501,14 +500,9 @@ TEST_F(TextRuleMatchNodeTest, DirectConcurrentProcessAndControl) { EXPECT_EQ((*output)[i].data.category, version); if (inputs[i].data == "world") { EXPECT_EQ((*output)[i].data.rule_id, version + "_rule"); - EXPECT_EQ((*output)[i].data.captures.count(version), 1u); - const auto capture = (*output)[i].data.captures.find(version); - if (capture != (*output)[i].data.captures.end()) { - EXPECT_EQ(capture->second, "world"); - } - EXPECT_EQ( - (*output)[i].data.captures.count(version == "OLD" ? "NEW" : "OLD"), - 0u); + EXPECT_EQ((*output)[i].data.slots.value(version, ""), "world"); + EXPECT_FALSE( + (*output)[i].data.slots.contains(version == "OLD" ? "NEW" : "OLD")); } } }; From f9e151692d0e04529464d4d6fb795243bac103a0 Mon Sep 17 00:00:00 2001 From: chamsechan Date: Sun, 4 Oct 2026 20:02:09 +0800 Subject: [PATCH 05/29] feat(operator): map failures below the facade to stage-based public codes Internal Pipeline, Node and Model codes no longer reach the host. Create preparation failures (configuration, deployment, model or backend loading) return -2 while unsupported biz and registry conflicts keep -5 and -6; Node or Model execution failures in Process return -100; Control returns -2 for rejected requests, -7 for unsupported commands and -100 when a Node rejects or fails to apply an update. Pipeline::Control now reports the failure stage so the Adapter does not classify by integer. GetOperatorLastError() keeps the stage, internal code and node detail. Co-Authored-By: Claude Opus 5.5 --- doc/CHANGELOG.md | 6 + doc/dev_guide/custom_node_concepts.md | 5 +- doc/dev_guide/operator_output_allocation.md | 21 +++- include/core/pipeline.h | 13 ++- include/platform_mock/error_codes.h | 6 +- plans/ARCHITECTURE_REVIEW.md | 2 +- src/adapter/CMakeLists.txt | 1 + src/adapter/operator/operator_adapter.cpp | 36 ++++-- .../operator/operator_error_mapping.cpp | 79 +++++++++++++ src/adapter/operator/operator_error_mapping.h | 30 +++++ src/adapter/shared_algorithm_runtime.cpp | 11 +- src/adapter/shared_algorithm_runtime.h | 3 +- src/core/pipeline.cpp | 29 +++-- .../abi/test_adapter_contract_security.cpp | 109 +++++++++++++++++- .../operator/test_operator_api.cpp | 27 ++++- .../test_runtime_control_and_hot_swap.cpp | 16 ++- 16 files changed, 354 insertions(+), 40 deletions(-) create mode 100644 src/adapter/operator/operator_error_mapping.cpp create mode 100644 src/adapter/operator/operator_error_mapping.h diff --git a/doc/CHANGELOG.md b/doc/CHANGELOG.md index a319e4b4..03da58dd 100644 --- a/doc/CHANGELOG.md +++ b/doc/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +公开错误码:Operator 门面按失败阶段映射返回码,内部的 Pipeline、Node、Model 错误码不再直接返回给宿主。 +Create 阶段的配置、部署与模型/Backend 加载失败返回 `-2`(此前部分为 `-3`);Process 中节点或模型 +执行失败返回 `-100`;Control 请求不合法返回 `-2`(此前为 `-1`),节点拒绝或未能应用更新返回 `-100`。 +`GetOperatorLastError()` 以 `<阶段> failed with internal code <内部码>: ...` 保留原始码与节点诊断, +见[宿主调用与生命周期](dev_guide/operator_output_allocation.md#宿主调用与生命周期)。 + 规则匹配结果:`TextRuleMatchNode` 只输出中性结果,`RuleMatchItem` 以 `matches` 列出全部命中、 以 `slots` 保存带 JSON 类型的捕获与常量,删除 `match_result_json`、`details`、`captures` 和 `constants`。关键词响应的 `match_result_json` 与音频响应的 `intent_slot_json` 改由输出转换器序列化, diff --git a/doc/dev_guide/custom_node_concepts.md b/doc/dev_guide/custom_node_concepts.md index dc952bbf..10ba7245 100644 --- a/doc/dev_guide/custom_node_concepts.md +++ b/doc/dev_guide/custom_node_concepts.md @@ -82,7 +82,8 @@ Spec 包装读取只读输入,必需值缺失或已连接输入类型不符时 `ModelsOf` 中的 `Model` 声明取得调用门面。成员类型决定能力:`LlmCall`、`EmbeddingCall`、 `AsrCall`、`OcrCall`、`RerankCall` 分别提供 `Generate`、`Embed`、`Transcribe`、`Recognize`、`Score`。 它们处理空批次、模型错误诊断及返回数量和来源检查,结果统一为 `NodeResult`。 -模型失败直接传播,不为旧节点错误码再做一层映射。Node 不加载模型文件或创建厂商运行时。 +模型失败直接传播,不为旧节点错误码再做一层映射;宿主收到的返回码由接入适配层按失败阶段映射, +内部码保留在诊断中。Node 不加载模型文件或创建厂商运行时。 下面几个字段承担不同职责: @@ -311,7 +312,7 @@ Node 套件;命令见[本地快速验证](../../src/custom_nodes/README.md#本 | Catalog 找不到新节点 | 文件是否位于 `src/custom_nodes/`、是否重新构建、执行的是否是刚构建的工具 | | 未知参数或缺失 `bind_model` | 当前 Definition、节点 config、`models[].model_id` | | 输入类型或生产者不匹配 | `inputs` / `outputs` 两端的类型、实际数据名和唯一生产者 | -| 模型调用返回错误 | 节点报告的错误码、所绑定模型的日志和资产配置 | +| 模型调用返回错误 | `GetOperatorLastError()` 中的内部错误码、所绑定模型的日志和资产配置 | | 输出数量或来源不匹配 | 前后处理是否删项/换序/改编号,模型是否正确保留来源 | | 并行计划被拆层 | 节点声明以及同层所使用模型的并发能力 | diff --git a/doc/dev_guide/operator_output_allocation.md b/doc/dev_guide/operator_output_allocation.md index eb0a3051..437cf77c 100644 --- a/doc/dev_guide/operator_output_allocation.md +++ b/doc/dev_guide/operator_output_allocation.md @@ -230,9 +230,24 @@ binding.normalize_parameters = 局部清理。调用前须停止所有实例的新调用、等待在途调用返回并释放全部输出;不支持与 Create、Process、 Control 或 Destroy 并发使用。存在未归还输出时它返回错误,但已清理的 handle 和旧输出仍失效。 -**错误与诊断。** 常见门面错误包括无效 handle `-1`、非法创建参数/配置 `-2`、非法输入或批次 `-3`、 -输出槽/容量错误 `-4`。公开错误码见 [`error_codes.h`](../../include/platform_mock/error_codes.h); -Pipeline、Node 或 Model 的失败码也会向上传递,不能只按数值判断故障层。 +**错误与诊断。** 门面自身的检查返回无效 handle `-1`、非法创建参数/配置 `-2`、非法输入或批次 `-3`、 +输出槽/容量错误 `-4` 和不支持的 Control 命令 `-7`。门面之下的失败按阶段映射为公开返回码, +内部的 Pipeline、Node、Model 错误码不直接返回给宿主: + +| 失败阶段 | 返回码 | +| --- | --- | +| Create:配置与部署校验、模型或 Backend 加载 | `-2`;不支持的业务 `-5`、注册冲突 `-6` 保持原类别 | +| Process:节点或模型执行失败 | `-100` | +| Control:信封、目标节点或载荷 schema 不合法 | `-2` | +| Control:没有节点声明或处理该命令 | `-7` | +| Control:节点拒绝或未能应用更新 | `-100` | +| 异常屏障捕获异常 | `-99`(`std::exception`)或 `-100`(未知异常) | + +`GetOperatorLastError()` 以 `<阶段> failed with internal code <内部码>: <诊断>` 保留原始码; +执行失败的诊断还包含节点 ID 与类型,模型调用失败时包含模型操作和模型返回码。`-100` 不区分 +执行失败与未知异常,需结合诊断判断故障层。公开错误码见 +[`error_codes.h`](../../include/platform_mock/error_codes.h),它是外网环境的替身,真实 SDK 的 +目标码须在授权内网核验。 `Process` 失败后在当前线程读取 `GetOperatorLastError()` 并及时复制诊断;它返回的指针由库持有, 后续调用可能更新内容。成功返回不代表已完成真实模型效果或生产验收。 diff --git a/include/core/pipeline.h b/include/core/pipeline.h index 353a1c7d..7d71967d 100644 --- a/include/core/pipeline.h +++ b/include/core/pipeline.h @@ -17,6 +17,16 @@ namespace llm_edgeflow { class ThreadPool; +/** + * @brief Control 失败发生的阶段,供接入适配层映射公开返回码 + */ +enum class ControlFailureStage { + kNone, ///< 成功,或 Pipeline 未就绪 + kRequest, ///< 信封、目标节点或载荷 schema 被拒绝,未更新任何节点 + kUnsupported, ///< 没有节点声明或处理该命令 + kNode, ///< 节点拒绝或未能应用更新 +}; + /** * @brief 算法管线调度核心引擎 (Pipeline) * @@ -61,7 +71,8 @@ class Pipeline { // {"$edgeflow_control":1,"node_id":"id","payload":{...}} targets one // instance. int Control(int cmd, const std::string& json_param, - std::string* error = nullptr); + std::string* error = nullptr, + ControlFailureStage* failure_stage = nullptr); State GetState() const { return state_; } bool IsReady() const { return state_ == State::kReady; } diff --git a/include/platform_mock/error_codes.h b/include/platform_mock/error_codes.h index 061d67f4..6d72da98 100644 --- a/include/platform_mock/error_codes.h +++ b/include/platform_mock/error_codes.h @@ -7,7 +7,8 @@ #define COMPANY_ALG_SUCCESS (0) // 成功 #define COMPANY_ALG_ERR_INVALID_HANDLE (-1) // 无效句柄 (nullptr 或野指针) #define COMPANY_ALG_ERR_INVALID_PARAM \ - (-2) // 参数非法 (如空字符串、缺少必填参数) + (-2) // 参数非法 (空字符串、缺少必填参数;创建时配置、部署或模型加载失败; + // Control 请求不合法) #define COMPANY_ALG_ERR_INVALID_INPUT \ (-3) // 输入数据非法 (空指针槽位、批大小超限或字段语义错误) #define COMPANY_ALG_ERR_BUFFER_TOO_SMALL \ @@ -17,6 +18,7 @@ #define COMPANY_ALG_ERR_REGISTRY_CONFLICT (-6) // 注册表冲突 (fail-closed 拦截) #define COMPANY_ALG_ERR_UNSUPPORTED_CONTROL (-7) // 不支持或未声明的控制命令 #define COMPANY_ALG_ERR_EXCEPTION (-99) // 运行时捕获到 std::exception 异常 -#define COMPANY_ALG_ERR_UNKNOWN (-100) // 运行时捕获到未知异常 +#define COMPANY_ALG_ERR_UNKNOWN \ + (-100) // 未分类的运行时失败 (节点/模型执行失败或捕获到未知异常) #endif // EDGEFLOW_PLATFORM_MOCK_ERROR_CODES_H_ diff --git a/plans/ARCHITECTURE_REVIEW.md b/plans/ARCHITECTURE_REVIEW.md index 7bcad146..7618bf90 100644 --- a/plans/ARCHITECTURE_REVIEW.md +++ b/plans/ARCHITECTURE_REVIEW.md @@ -315,7 +315,7 @@ Definition、Catalog、Validator、节点运行时及工具的解释须一致。 | --- | --- | --- | --- | | A1 | 端口数量关系改为形状推导 | 第一节 2 | 已完成:Validator 推导形状;`TextEmbeddingNode` 改为 `1:1`;规则见[数量关系声明](../doc/dev_guide/custom_node_concepts.md#数量关系声明与-validator-检查) | | A2 | RuleMatch 外部响应序列化移到 Adapter | 第一节 3 | 已完成:`RuleMatchItem` 改为中性的全部命中与带类型槽位;两个转换器共用 Adapter 序列化,字节级契约测试覆盖 | -| A3 | 错误码按阶段映射 | 第一节 1 | 待实施 | +| A3 | 错误码按阶段映射 | 第一节 1 | 已完成:Adapter 集中映射,`Pipeline::Control` 报告失败阶段;规则见[宿主调用与生命周期](../doc/dev_guide/operator_output_allocation.md#宿主调用与生命周期),真实 SDK 目标码仍待内网核验 | | A4 | 致命错误与单条无效结果写入业务契约 | 第一节 4 | 待实施 | | B1 | 清理未使用的结果结构 | 第三节·接入适配层 | 待实施 | | B2 | 输入/输出端口绑定共用实现 | 第三节·接入适配层 | 待实施 | diff --git a/src/adapter/CMakeLists.txt b/src/adapter/CMakeLists.txt index bbfa3c37..3dc89257 100644 --- a/src/adapter/CMakeLists.txt +++ b/src/adapter/CMakeLists.txt @@ -19,6 +19,7 @@ target_sources(edgeflow_integration_objects PRIVATE operator/operator_config_resolver.cpp operator/operator_adapter.cpp operator/operator_control_registry.cpp + operator/operator_error_mapping.cpp operator/operator_output_pool.cpp operator/operator_process_binding.cpp operator/operator_value_type.cpp diff --git a/src/adapter/operator/operator_adapter.cpp b/src/adapter/operator/operator_adapter.cpp index 01a54e04..0f2e79c9 100644 --- a/src/adapter/operator/operator_adapter.cpp +++ b/src/adapter/operator/operator_adapter.cpp @@ -7,6 +7,7 @@ #include "adapter/operator/operator_config_resolver.h" #include "adapter/operator/operator_control_registry.h" +#include "adapter/operator/operator_error_mapping.h" #include "adapter/operator/operator_output_pool.h" #include "adapter/operator/operator_process_binding.h" #include "adapter/operator/operator_value_type_registry.h" @@ -184,8 +185,11 @@ int Operator_Create(void** handle, const CreateParam* param) noexcept { param->model_path, param->cfg_file_name, &resolved_conf, &resolve_err, effective_depth); if (res_code != 0) { - SetLastError("OperatorConfigResolver failed: " + resolve_err); - return res_code; + SetLastError(llm_edgeflow::DescribeOperatorFailure( + llm_edgeflow::OperatorFailureStage::kCreatePreparation, res_code, + "OperatorConfigResolver failed: " + resolve_err)); + return llm_edgeflow::PublicFailureCode( + llm_edgeflow::OperatorFailureStage::kCreatePreparation, res_code); } // 2. 组装运行时参数 @@ -207,9 +211,11 @@ int Operator_Create(void** handle, const CreateParam* param) noexcept { std::move(resolved_conf.io_plan), param->device_id, &runtime_options, &runtime, &create_err); if (create_ret != 0) { - SetLastError("SharedAlgorithmRuntime::CreateFromIoPlan failed: " + - create_err); - return create_ret; + SetLastError(llm_edgeflow::DescribeOperatorFailure( + llm_edgeflow::OperatorFailureStage::kCreatePreparation, create_ret, + "SharedAlgorithmRuntime::CreateFromIoPlan failed: " + create_err)); + return llm_edgeflow::PublicFailureCode( + llm_edgeflow::OperatorFailureStage::kCreatePreparation, create_ret); } // 4. 预分配输出内存池 @@ -387,9 +393,11 @@ int Operator_Process(void* handle, const NamedIoBatch& inputs, // 5. 执行 Pipeline 计算 int exec_ret = h->runtime->GetPipeline()->Execute(&req_ctx); if (exec_ret != 0) { - SetLastError("Pipeline::Execute failed with code " + - std::to_string(exec_ret) + ": " + req_ctx.GetErrorMessage()); - return exec_ret; + SetLastError(llm_edgeflow::DescribeOperatorFailure( + llm_edgeflow::OperatorFailureStage::kProcessExecution, exec_ret, + req_ctx.GetErrorMessage())); + return llm_edgeflow::PublicFailureCode( + llm_edgeflow::OperatorFailureStage::kProcessExecution, exec_ret); } // 6. 执行统一输出编码 (将结果写入已租用的外部结构块) @@ -476,10 +484,16 @@ int Operator_Control(void* handle, ControlCommand command, } std::string exec_err; - int exec_ret = h->runtime->ExecuteControl(cmd_id, json_str, &exec_err); + llm_edgeflow::ControlFailureStage control_stage = + llm_edgeflow::ControlFailureStage::kNone; + int exec_ret = + h->runtime->ExecuteControl(cmd_id, json_str, &exec_err, &control_stage); if (exec_ret != 0) { - SetLastError("ExecuteControl failed: " + exec_err); - return exec_ret; + const auto stage = + llm_edgeflow::ControlFailureToOperatorStage(control_stage); + SetLastError( + llm_edgeflow::DescribeOperatorFailure(stage, exec_ret, exec_err)); + return llm_edgeflow::PublicFailureCode(stage, exec_ret); } return 0; diff --git a/src/adapter/operator/operator_error_mapping.cpp b/src/adapter/operator/operator_error_mapping.cpp new file mode 100644 index 00000000..0450575c --- /dev/null +++ b/src/adapter/operator/operator_error_mapping.cpp @@ -0,0 +1,79 @@ +#include "adapter/operator/operator_error_mapping.h" + +#include "platform_mock/error_codes.h" + +namespace llm_edgeflow { + +namespace { + +const char* StageName(OperatorFailureStage stage) noexcept { + switch (stage) { + case OperatorFailureStage::kCreatePreparation: + return "Create preparation"; + case OperatorFailureStage::kProcessExecution: + return "Pipeline execution"; + case OperatorFailureStage::kControlRequest: + return "Control request"; + case OperatorFailureStage::kControlUnsupported: + return "Control command"; + case OperatorFailureStage::kControlExecution: + return "Control node update"; + case OperatorFailureStage::kControlRuntime: + break; + } + return "Control runtime"; +} + +} // namespace + +OperatorFailureStage ControlFailureToOperatorStage( + ControlFailureStage stage) noexcept { + switch (stage) { + case ControlFailureStage::kRequest: + return OperatorFailureStage::kControlRequest; + case ControlFailureStage::kUnsupported: + return OperatorFailureStage::kControlUnsupported; + case ControlFailureStage::kNode: + return OperatorFailureStage::kControlExecution; + case ControlFailureStage::kNone: + break; + } + return OperatorFailureStage::kControlRuntime; +} + +int PublicFailureCode(OperatorFailureStage stage, int internal_code) noexcept { + switch (stage) { + case OperatorFailureStage::kCreatePreparation: + // Adapter-owned categories keep their meaning; every other preparation + // failure is an invalid creation parameter or configuration. + switch (internal_code) { + case COMPANY_ALG_ERR_UNSUPPORTED_BIZ: + case COMPANY_ALG_ERR_REGISTRY_CONFLICT: + case COMPANY_ALG_ERR_EXCEPTION: + case COMPANY_ALG_ERR_UNKNOWN: + return internal_code; + default: + return COMPANY_ALG_ERR_INVALID_PARAM; + } + case OperatorFailureStage::kProcessExecution: + case OperatorFailureStage::kControlExecution: + return COMPANY_ALG_ERR_UNKNOWN; + case OperatorFailureStage::kControlRequest: + return COMPANY_ALG_ERR_INVALID_PARAM; + case OperatorFailureStage::kControlUnsupported: + return COMPANY_ALG_ERR_UNSUPPORTED_CONTROL; + case OperatorFailureStage::kControlRuntime: + break; + } + // Runtime guards and exception barriers already return public codes. + return internal_code; +} + +std::string DescribeOperatorFailure(OperatorFailureStage stage, + int internal_code, + std::string_view detail) { + return std::string(StageName(stage)) + " failed with internal code " + + std::to_string(internal_code) + ": " + std::string(detail); +} + +} // namespace llm_edgeflow diff --git a/src/adapter/operator/operator_error_mapping.h b/src/adapter/operator/operator_error_mapping.h new file mode 100644 index 00000000..2d3e82d0 --- /dev/null +++ b/src/adapter/operator/operator_error_mapping.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +#include "core/pipeline.h" + +namespace llm_edgeflow { + +// Failures that originate below the Operator facade. Their internal Pipeline, +// Node and Model codes are reported through GetOperatorLastError() only; the +// host receives the public code of the failure stage. +enum class OperatorFailureStage { + kCreatePreparation, // configuration, deployment, model and backend loading + kProcessExecution, // a Node or Model failed in Pipeline::Execute + kControlRequest, // Control envelope, target or payload was rejected + kControlUnsupported, // no Node declares or handles the command + kControlExecution, // a Node rejected or failed to apply the update + kControlRuntime, // no ready runtime, or an exception barrier fired +}; + +OperatorFailureStage ControlFailureToOperatorStage( + ControlFailureStage stage) noexcept; + +int PublicFailureCode(OperatorFailureStage stage, int internal_code) noexcept; + +std::string DescribeOperatorFailure(OperatorFailureStage stage, + int internal_code, std::string_view detail); + +} // namespace llm_edgeflow diff --git a/src/adapter/shared_algorithm_runtime.cpp b/src/adapter/shared_algorithm_runtime.cpp index 6959a1ef..2738c917 100644 --- a/src/adapter/shared_algorithm_runtime.cpp +++ b/src/adapter/shared_algorithm_runtime.cpp @@ -146,19 +146,22 @@ int SharedAlgorithmRuntime::CreateFromIoPlan( } } -int SharedAlgorithmRuntime::ExecuteControl(int cmd, - const std::string& json_param_str, - std::string* out_error) noexcept { +int SharedAlgorithmRuntime::ExecuteControl( + int cmd, const std::string& json_param_str, std::string* out_error, + ControlFailureStage* failure_stage) noexcept { + if (failure_stage) *failure_stage = ControlFailureStage::kNone; try { if (!pipeline_) { if (out_error) *out_error = "Null pipeline in runtime instance"; return COMPANY_ALG_ERR_INVALID_HANDLE; // -1 } - return pipeline_->Control(cmd, json_param_str, out_error); + return pipeline_->Control(cmd, json_param_str, out_error, failure_stage); } catch (const std::exception& e) { + if (failure_stage) *failure_stage = ControlFailureStage::kNone; SetDiagnosticNoexcept(out_error, e.what()); return COMPANY_ALG_ERR_EXCEPTION; } catch (...) { + if (failure_stage) *failure_stage = ControlFailureStage::kNone; SetDiagnosticNoexcept(out_error, "Unknown exception"); return COMPANY_ALG_ERR_UNKNOWN; } diff --git a/src/adapter/shared_algorithm_runtime.h b/src/adapter/shared_algorithm_runtime.h index 1b105c3c..9e87478c 100644 --- a/src/adapter/shared_algorithm_runtime.h +++ b/src/adapter/shared_algorithm_runtime.h @@ -41,7 +41,8 @@ class SharedAlgorithmRuntime { * @brief 运行时动态控制指令下发 */ int ExecuteControl(int cmd, const std::string& json_param_str, - std::string* out_error = nullptr) noexcept; + std::string* out_error = nullptr, + ControlFailureStage* failure_stage = nullptr) noexcept; // Getters Pipeline* GetPipeline() { return pipeline_.get(); } diff --git a/src/core/pipeline.cpp b/src/core/pipeline.cpp index baca51b5..1ba3640e 100644 --- a/src/core/pipeline.cpp +++ b/src/core/pipeline.cpp @@ -518,15 +518,22 @@ int Pipeline::Execute(AlgContext* req_ctx) { } int Pipeline::Control(int cmd, const std::string& json_param, - std::string* error) { + std::string* error, ControlFailureStage* failure_stage) { if (error) error->clear(); - const auto fail = [&](int code, const std::string& message) { + if (failure_stage) *failure_stage = ControlFailureStage::kNone; + const auto fail_at = [&](ControlFailureStage stage, int code, + const std::string& message) { if (error) *error = message; + if (failure_stage) *failure_stage = stage; ALG_LOG_ERROR("[Pipeline] %s\n", message.c_str()); return code; }; + const auto fail = [&](int code, const std::string& message) { + return fail_at(ControlFailureStage::kRequest, code, message); + }; if (state_ != State::kReady) { - return fail(-1, "Control requires a Ready Pipeline"); + return fail_at(ControlFailureStage::kNone, -1, + "Control requires a Ready Pipeline"); } struct Target { @@ -593,9 +600,10 @@ int Pipeline::Control(int cmd, const std::string& json_param, return fail(-1, "Unknown Control target node_id: '" + target_id + "'"); } if (targets.empty()) { - return fail(-7, - "Unsupported control command: " + std::to_string(cmd) + - (target_id.empty() ? "" : " for node '" + target_id + "'")); + return fail_at( + ControlFailureStage::kUnsupported, -7, + "Unsupported control command: " + std::to_string(cmd) + + (target_id.empty() ? "" : " for node '" + target_id + "'")); } // Broadcast remains best-effort: a later semantic failure does not undo an @@ -615,10 +623,13 @@ int Pipeline::Control(int cmd, const std::string& json_param, handled = true; } } - if (first_failure != 0) return fail(first_failure, failures); + if (first_failure != 0) { + return fail_at(ControlFailureStage::kNode, first_failure, failures); + } if (handled) return 0; - return fail( - -7, "Declared control command was not handled: " + std::to_string(cmd)); + return fail_at( + ControlFailureStage::kUnsupported, -7, + "Declared control command was not handled: " + std::to_string(cmd)); } } // namespace llm_edgeflow diff --git a/tests/contract/abi/test_adapter_contract_security.cpp b/tests/contract/abi/test_adapter_contract_security.cpp index da6a43fd..d120b3fa 100644 --- a/tests/contract/abi/test_adapter_contract_security.cpp +++ b/tests/contract/abi/test_adapter_contract_security.cpp @@ -100,6 +100,27 @@ class TranslationProbeModel final : public ILlmModel { int failure = 0; }; +// Fails during model creation so Operator Create reaches the loading stage. +class FailingCreateModel { + public: + inline static constexpr char kModelType[] = "test_failing_create_model"; + static std::shared_ptr Create(const ModelCreateContext&, + std::string* error) { + if (error) *error = "probe model refused to load"; + return nullptr; + } +}; + +REGISTER_MODEL_WITH_DEFINITION(FailingCreateModel, [] { + ModelDefinition definition; + definition.model_type = FailingCreateModel::kModelType; + definition.capability = "llm"; + definition.description = "Test-only model whose creation fails"; + definition.required_protocol = ExecutionProtocol::kTextGeneration; + definition.concurrency = InferenceConcurrency::kSerialized; + return definition; +}()); + REGISTER_MODEL_WITH_DEFINITION(TranslationProbeModel, [] { ModelDefinition definition; definition.model_type = TranslationProbeModel::kModelType; @@ -259,8 +280,14 @@ TEST_F(AdapterContractSecurityTest, model->response.assign(2200, 'x'); EXPECT_EQ(process("{\"query\":\"hello\"}"), COMPANY_ALG_ERR_BUFFER_TOO_SMALL); EXPECT_EQ(model->calls.size(), before_invalid + 1); + // Model codes stay in diagnostics; the host sees the execution category. model->failure = -731; - EXPECT_EQ(process("{\"query\":\"hello\"}"), -731); + EXPECT_EQ(process("{\"query\":\"hello\"}"), COMPANY_ALG_ERR_UNKNOWN); + const std::string model_error = operator_api::GetOperatorLastError(); + EXPECT_NE(model_error.find("Pipeline execution failed with internal code " + "-731: Node '"), + std::string::npos) + << model_error; EXPECT_EQ(model->calls.size(), before_invalid + 2); model->failure = 0; model->response = "你好"; @@ -1393,4 +1420,84 @@ TEST_F(AdapterContractSecurityTest, R"j("matches":[],"slots":{"raw_query":"nothing"}})j"); } +TEST_F(AdapterContractSecurityTest, CreateAndExecutionFailuresUseStageCodes) { + const auto directory = + std::filesystem::temp_directory_path() / + ("edgeflow-stage-codes-" + + std::to_string( + std::chrono::steady_clock::now().time_since_epoch().count())); + std::filesystem::create_directory(directory); + struct Cleanup { + std::filesystem::path directory; + ~Cleanup() { + std::error_code error; + std::filesystem::remove_all(directory, error); + } + } cleanup{directory}; + nlohmann::json pipeline = nlohmann::json::parse(R"j({ + "deployment": {"io": {"io_binding": "entity_extract.operator.v1"}}, + "models": [{"model_id": "llm", "model_type": "qwen_causal_lm", + "backend": "test_causal_lm_backend", + "model_path": "neutral-llm.fixture", + "model_config": {}, "backend_config": {}}], + "pipeline": [ + {"id": "gen", "node_type": "LlmGenerateNode", + "config": {"bind_model": "llm"}, + "inputs": {"prompt": "input_sentences"}, "outputs": {"text": "raw"}}, + {"id": "parse", "node_type": "StructuredJsonParseNode", + "config": {"failure_policy": "fail"}, + "inputs": {"text": "raw"}, + "outputs": {"document": "extracted_entities"}}]})j"); + std::ofstream(directory / "pipeline.conf") + << "{\"pipe_path\":\"pipeline.json\"}"; + auto op = operator_api::Get_LLM_EDGEFLOW_OperatorTable(); + auto create = [&](const nlohmann::json& document, void** handle) { + std::ofstream(directory / "pipeline.json") << document.dump(); + operator_api::CreateParam param{}; + param.model_path = directory.c_str(); + param.cfg_file_name = "pipeline.conf"; + param.compute_platform = operator_api::ComputePlatform::kCpu; + return op.Create(handle, ¶m); + }; + + // Validation and model loading failures are creation parameter errors. + void* handle = nullptr; + auto invalid = pipeline; + invalid["pipeline"][1]["node_type"] = "MissingNodeType"; + EXPECT_EQ(create(invalid, &handle), COMPANY_ALG_ERR_INVALID_PARAM); + EXPECT_NE(std::string(operator_api::GetOperatorLastError()) + .find("Create preparation failed with internal code -3: "), + std::string::npos) + << operator_api::GetOperatorLastError(); + auto unloadable = pipeline; + unloadable["models"][0]["model_type"] = FailingCreateModel::kModelType; + EXPECT_EQ(create(unloadable, &handle), COMPANY_ALG_ERR_INVALID_PARAM); + const std::string load_error = operator_api::GetOperatorLastError(); + EXPECT_NE(load_error.find("Create preparation failed with internal code -3"), + std::string::npos) + << load_error; + EXPECT_NE(load_error.find("MODEL_MATERIALIZATION_FAILED"), std::string::npos) + << load_error; + EXPECT_EQ(handle, nullptr); + + ASSERT_EQ(create(pipeline, &handle), 0) + << operator_api::GetOperatorLastError(); + std::unique_ptr owner(handle, op.Destroy); + + // The test backend returns plain text, so the fail policy rejects it. + std::string text = "张三在北京"; + CompanyString sentence{static_cast(text.size()), text.data()}; + CompanyOperatorEntityInput input{11, &sentence}; + operator_api::NamedIoBatch inputs(1), outputs(1); + inputs[0]["e.entity_in"] = operator_api::MakeBorrowedOperatorInput(&input); + outputs[0]["e.entity_out"] = nullptr; + EXPECT_EQ(op.Process(handle, inputs, outputs), COMPANY_ALG_ERR_UNKNOWN); + const std::string error = operator_api::GetOperatorLastError(); + EXPECT_NE(error.find("Pipeline execution failed with internal code -6102: " + "Node 'parse' (StructuredJsonParseNode)"), + std::string::npos) + << error; + EXPECT_EQ(outputs[0]["e.entity_out"], nullptr); +} + } // namespace llm_edgeflow diff --git a/tests/integration/operator/test_operator_api.cpp b/tests/integration/operator/test_operator_api.cpp index c53f2836..301a320a 100644 --- a/tests/integration/operator/test_operator_api.cpp +++ b/tests/integration/operator/test_operator_api.cpp @@ -357,24 +357,45 @@ TEST_F(OperatorApiTest, GenericJsonControlReachesCustomNodeAndReportsFailures) { ASSERT_EQ(ops_.Control(handle, ControlCommand::kJson, &command), 0); check(1); // Updating the prefix preserves the matcher's new rules. + // Rejected requests are invalid parameters; the internal Core code stays in + // the diagnostic instead of colliding with the invalid-handle code. command.json_param_str = R"({"$edgeflow_control":1,"node_id":"missing","payload":{"prefix":"BAD:"}})"; - EXPECT_NE(ops_.Control(handle, ControlCommand::kJson, &command), 0); + EXPECT_EQ(ops_.Control(handle, ControlCommand::kJson, &command), + COMPANY_ALG_ERR_INVALID_PARAM); EXPECT_NE(std::string(GetOperatorLastError()).find("missing"), std::string::npos); + EXPECT_NE(std::string(GetOperatorLastError()) + .find("Control request failed with internal code -1"), + std::string::npos); check(1); command.json_param_str = R"({"prefix":1})"; - EXPECT_NE(ops_.Control(handle, ControlCommand::kJson, &command), 0); + EXPECT_EQ(ops_.Control(handle, ControlCommand::kJson, &command), + COMPANY_ALG_ERR_INVALID_PARAM); EXPECT_NE(std::string(GetOperatorLastError()).find("node 'prefix'"), std::string::npos); EXPECT_NE(std::string(GetOperatorLastError()).find("prefix"), std::string::npos); payload = nlohmann::json{{"prefix", std::string(65, 'x')}}.dump(); command.json_param_str = payload.c_str(); - EXPECT_NE(ops_.Control(handle, ControlCommand::kJson, &command), 0); + EXPECT_EQ(ops_.Control(handle, ControlCommand::kJson, &command), + COMPANY_ALG_ERR_UNKNOWN); EXPECT_NE(std::string(GetOperatorLastError()).find("64 UTF-8 bytes"), std::string::npos); + EXPECT_NE(std::string(GetOperatorLastError()) + .find("Control node update failed with internal code"), + std::string::npos); + check(1); + + // A Node that rejects a well-formed payload reports an execution failure. + ControlJsonParam bad_regex{ + llm_edgeflow::kControlCmdUpdateRules, + R"({"rules":[{"pattern":"(","strategy":"regex"}]})"}; + EXPECT_EQ(ops_.Control(handle, ControlCommand::kJson, &bad_regex), + COMPANY_ALG_ERR_UNKNOWN); + EXPECT_NE(std::string(GetOperatorLastError()).find("node 'matcher'"), + std::string::npos); check(1); for (const char* invalid : diff --git a/tests/integration/runtime/test_runtime_control_and_hot_swap.cpp b/tests/integration/runtime/test_runtime_control_and_hot_swap.cpp index 88dd7e37..5727fd4c 100644 --- a/tests/integration/runtime/test_runtime_control_and_hot_swap.cpp +++ b/tests/integration/runtime/test_runtime_control_and_hot_swap.cpp @@ -156,18 +156,30 @@ TEST_F(RuntimeControlAndHotSwapTest, for (const auto& [payload, field] : invalid) { SCOPED_TRACE(payload.dump()); std::string error; - EXPECT_NE(pipeline.Control(kControlCmdUpdateRules, payload.dump(), &error), + ControlFailureStage stage = ControlFailureStage::kNone; + EXPECT_NE(pipeline.Control(kControlCmdUpdateRules, payload.dump(), &error, + &stage), 0); EXPECT_NE(error.find(field), std::string::npos) << error; + // Only the regex compile happens inside the Node; schema checks run first. + EXPECT_EQ(stage, field == "rules_a" ? ControlFailureStage::kNode + : ControlFailureStage::kRequest); ExpectRuleCategories(&pipeline, "INITIAL_A", "INITIAL_B"); } std::string error; + ControlFailureStage stage = ControlFailureStage::kNone; EXPECT_EQ(pipeline.Control(kControlCmdUpdateRules, TargetedRules("template", "WRONG_TARGET").dump(), - &error), + &error, &stage), -7); + EXPECT_EQ(stage, ControlFailureStage::kUnsupported); EXPECT_NE(error.find("template"), std::string::npos) << error; ExpectRuleCategories(&pipeline, "INITIAL_A", "INITIAL_B"); + EXPECT_EQ(pipeline.Control(kControlCmdUpdateRules, + TargetedRules("rules_a", "UPDATED").dump(), &error, + &stage), + 0); + EXPECT_EQ(stage, ControlFailureStage::kNone); } // 1. 关键词库运行时动态热更新与立即生效测试 From ec1119a3b33e98c1ba5ad8a7f23d0256b30ed02e Mon Sep 17 00:00:00 2001 From: chamsechan Date: Sun, 4 Oct 2026 20:04:09 +0800 Subject: [PATCH 06/29] docs(adapter): define whole-batch failures and per-biz single-item results Fatal errors keep whole-batch atomicity. Each existing biz contract now states what its output converter writes to status_code and which invalid single results (failed or fallback documents, out-of-enum risk levels, invalid rankings) reject the batch, so partial success remains a per-contract design decision rather than a framework channel. Co-Authored-By: Claude Opus 5.5 --- doc/dev_guide/business_onboarding.md | 26 ++++++++++++++++++++++++++ plans/ARCHITECTURE_REVIEW.md | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/doc/dev_guide/business_onboarding.md b/doc/dev_guide/business_onboarding.md index 89a3a863..4f16f4da 100644 --- a/doc/dev_guide/business_onboarding.md +++ b/doc/dev_guide/business_onboarding.md @@ -169,6 +169,32 @@ Operator 的宿主输入校验会拒绝 `CompanyString` 中的原始嵌入 NUL | 多个外部槽 | [图像问题输入](../../src/adapter/input/image_query_input.cpp) / [票据输出](../../src/adapter/output/invoice_result_output.cpp) | frame 的请求 ID、票据与 OCR boxes | | 候选展开与排名 | [重排输入](../../src/adapter/input/rerank_input.cpp) / [重排输出](../../src/adapter/output/rerank_result_output.cpp) | sub_id、排名和原始索引恢复 | +### 整批失败与单条结果 + +致命错误使整批不发布新输出:输入转换失败、任一 Node 返回非零、输出转换器拒绝结果或容量不足时, +本批已租用的输出全部归还,宿主按阶段收到公开返回码,见 +[宿主调用与生命周期](operator_output_allocation.md#宿主调用与生命周期)。框架不提供部分成功通道。 + +单条结果是否"无效但仍可返回"由各业务的输出契约决定。Node 可以按自身契约产出逐项状态,例如 +`StructuredJsonParseNode` 的 `is_valid`、`parse_status`、`diagnostic`,以及 +`emit_diagnostic` / `configured_fallback` 策略下不触发节点失败的坏项;输出转换器决定接受这些项、 +用 `status_code` 表达,还是拒绝整批。`status_code` 是宿主结构中的业务状态,不恒为 0。 +新契约在转换器中写明这三项含义,并在契约测试中覆盖。现有业务的约定: + +| 业务(输出转换器) | `status_code` | 单条结果不满足契约时 | +| --- | --- | --- | +| `keyword_match`(`keyword.result.operator.v1`) | 复制规则结果的 `status_code`;`TextRuleMatchNode` 写 0 | 未命中是正常结果(`is_hit=0`),没有无效项 | +| `audio_asr_intent`(`audio_result.plain.operator.v1`) | 复制意图结果的 `status_code` | 缺少转写或意图结果时整批失败 | +| `doc_qa`(`doc_answer.plain.operator.v1`) | 复制意图结果的 `status_code` | 缺少回答、意图或片段数时整批失败 | +| `entity_extract`(`document.structured.operator.v1`) | 0 | 解析失败或使用 fallback 的文档使整批失败 | +| `ocr_invoice_qa`(`invoice_result.plain.operator.v1`) | 0 | 同上 | +| `dialogue_audit`(`audit_result.plain.operator.v1`) | 0 | 判定解析失败或使用 fallback、首要策略排名不为 1、`risk_level` 不在 `SAFE`/`LOW_RISK`/`MEDIUM_RISK`/`HIGH_RISK`,或 `risk_score` 不在 [0, 1] 时整批失败 | +| `cross_rerank`(`rerank_result.plain.operator.v1`) | 0 | 排名不连续、超过 8 项或候选编号越界时整批失败;无候选时 `count=0` | +| `translate`(`translate.json.operator.v1`) | 0 | 译文无法序列化为 JSON(如非法 UTF-8)时整批失败 | + +需要让某个业务在单条无效时仍返回其他请求的结果,应先设计该业务的外部表达 +(例如非零 `status_code` 与诊断字段)和测试,再修改对应转换器;不要在框架中跳过失败项。 + ## 4. 需要新的宿主类型时 多数业务复用已注册的宿主类型和 ValueType,载荷协议变化只需修改转换器。确需新的平台结构或 diff --git a/plans/ARCHITECTURE_REVIEW.md b/plans/ARCHITECTURE_REVIEW.md index 7618bf90..8c02729a 100644 --- a/plans/ARCHITECTURE_REVIEW.md +++ b/plans/ARCHITECTURE_REVIEW.md @@ -316,7 +316,7 @@ Definition、Catalog、Validator、节点运行时及工具的解释须一致。 | A1 | 端口数量关系改为形状推导 | 第一节 2 | 已完成:Validator 推导形状;`TextEmbeddingNode` 改为 `1:1`;规则见[数量关系声明](../doc/dev_guide/custom_node_concepts.md#数量关系声明与-validator-检查) | | A2 | RuleMatch 外部响应序列化移到 Adapter | 第一节 3 | 已完成:`RuleMatchItem` 改为中性的全部命中与带类型槽位;两个转换器共用 Adapter 序列化,字节级契约测试覆盖 | | A3 | 错误码按阶段映射 | 第一节 1 | 已完成:Adapter 集中映射,`Pipeline::Control` 报告失败阶段;规则见[宿主调用与生命周期](../doc/dev_guide/operator_output_allocation.md#宿主调用与生命周期),真实 SDK 目标码仍待内网核验 | -| A4 | 致命错误与单条无效结果写入业务契约 | 第一节 4 | 待实施 | +| A4 | 致命错误与单条无效结果写入业务契约 | 第一节 4 | 已完成:保留整批回滚,各业务约定见[整批失败与单条结果](../doc/dev_guide/business_onboarding.md#整批失败与单条结果) | | B1 | 清理未使用的结果结构 | 第三节·接入适配层 | 待实施 | | B2 | 输入/输出端口绑定共用实现 | 第三节·接入适配层 | 待实施 | | B3 | 输入限额统一规则来源 | 第三节·接入适配层 | 待实施 | From 0f7b351a30a512cb4f1defc1ce25f7d14af6e7cb Mon Sep 17 00:00:00 2001 From: chamsechan Date: Sun, 4 Oct 2026 20:07:46 +0800 Subject: [PATCH 07/29] refactor(adapter): remove unused integration result structs Six structs in include/adapter/biz_results.h had no type references and converters never used the header. The only consumer, the nested output allocator fixture, now owns a minimal NestedOutputSource, so the header is deleted and onboarding points at the neutral result contracts. Co-Authored-By: Claude Opus 5.5 --- doc/dev_guide/business_onboarding.md | 2 +- include/adapter/biz_results.h | 71 ------------------- include/platform_mock/README.md | 2 +- plans/ARCHITECTURE_REVIEW.md | 6 +- plans/DEVELOPER_EXPERIENCE_DESIGN.md | 1 - src/adapter/output/keyword_result_output.cpp | 1 - .../output/structured_document_output.cpp | 1 - .../abi/test_adapter_contract_security.cpp | 1 - tests/contract/abi/test_operator_safety.cpp | 1 - .../operator/test_operator_api.cpp | 2 +- .../support/operator_nested_output_fixture.h | 9 ++- .../operator/test_operator_output_pool.cpp | 2 +- 12 files changed, 14 insertions(+), 85 deletions(-) delete mode 100644 include/adapter/biz_results.h diff --git a/doc/dev_guide/business_onboarding.md b/doc/dev_guide/business_onboarding.md index 4f16f4da..6f69305a 100644 --- a/doc/dev_guide/business_onboarding.md +++ b/doc/dev_guide/business_onboarding.md @@ -83,7 +83,7 @@ JSON 请求是不同的输入约定。已有 Nodes 能完成算法,也不代 | 环节 | 样例文件 | 需要补齐时落实的内容 | | --- | --- | --- | | 本地模拟平台结构 | [Operator 数据结构](../../include/platform_mock/operator_data_types.h)、[平台交互类型](../../include/platform_mock/operator_types.h) | 已有载体不足时才新增结构,明确字段、长度和所有权;本目录只保存模拟约定,真实公司定义在授权内网接入 | -| 内部数据边界 | [业务 key](../../include/adapter/biz_blackboard_keys.h)、[业务 Result](../../include/adapter/biz_results.h) | ingress/egress typed key 与接入适配层持有的结果值;已有类型可复用 | +| 内部数据边界 | [业务 key](../../include/adapter/biz_blackboard_keys.h)、[中性结果类型](../../include/core/common_contracts.h) | ingress/egress typed key 与 Pipeline 产出的中性结果;已有类型可复用,外部响应由输出转换器组装 | | 输入转换器 | [text_input.cpp](../../src/adapter/input/text_input.cpp) | 外部输入校验、中性数据封装及 `REGISTER_INPUT_CONVERTER` | | 输出转换器 | [keyword_result_output.cpp](../../src/adapter/output/keyword_result_output.cpp) | 内部结果关联、写入已分配的输出结构及 `REGISTER_OUTPUT_CONVERTER` | | 业务契约与绑定 | [keyword_match_bindings.cpp](../../src/adapter/biz/keyword_match_bindings.cpp) | 声明 `BizDefinition`、转换器组合和非同名端口映射;默认批次上限为 64,用 `REGISTER_IO_BINDING` 注册 | diff --git a/include/adapter/biz_results.h b/include/adapter/biz_results.h deleted file mode 100644 index 1b5fcd03..00000000 --- a/include/adapter/biz_results.h +++ /dev/null @@ -1,71 +0,0 @@ -#pragma once - -#include -#include - -namespace llm_edgeflow { -// Results owned by Integration. Strings have no fixed array limit; the -// configured Operator output pool remains the authoritative external capacity -// boundary. - -struct AuditResult { - inline static constexpr char kTypeName[] = "AuditResult"; - uint64_t request_id{}; - std::string risk_level; - float risk_score{}; - std::string matched_policy_clause; - std::string audit_verdict_json; - int status_code{}; -}; - -struct KeywordResult { - inline static constexpr char kTypeName[] = "KeywordResult"; - uint64_t request_id{}; - int is_hit{}; - std::string match_result_json; - int status_code{}; -}; - -struct EntityResult { - inline static constexpr char kTypeName[] = "EntityResult"; - uint64_t request_id{}; - std::string entities_json; - int status_code{}; -}; - -struct DocResult { - inline static constexpr char kTypeName[] = "DocResult"; - uint64_t request_id{}; - std::string intent_name; - float confidence{}; - std::string answer_text; - int chunk_count{}; - int status_code{}; -}; - -struct OcrDocResult { - inline static constexpr char kTypeName[] = "OcrDocResult"; - uint64_t request_id{}; - int detected_box_count{}; - std::string extracted_invoice_json; - int status_code{}; -}; - -struct AudioResult { - inline static constexpr char kTypeName[] = "AudioResult"; - uint64_t request_id{}; - std::string transcribed_text; - std::string intent_slot_json; - int status_code{}; -}; - -struct RerankResult { - inline static constexpr char kTypeName[] = "RerankResult"; - uint64_t request_id{}; - float scores[8]{}; - int sorted_indices[8]{}; - int count{}; - int status_code{}; -}; - -} // namespace llm_edgeflow diff --git a/include/platform_mock/README.md b/include/platform_mock/README.md index 679e2c6d..1dd66275 100644 --- a/include/platform_mock/README.md +++ b/include/platform_mock/README.md @@ -25,7 +25,7 @@ SDK 调用方包含 `edgeflow/operator/interface.h`,获得函数入口以及 ` 以下内容有各自的真实实现职责,不属于平台公共定义模拟: - `edgeflow/log.h`、`export.h`、生成的版本头:本项目日志、符号导出和版本接口。 -- `adapter/biz_results.h`、业务 Blackboard keys、`operator_io_contracts.h`:框架内部结果、业务端口和池容量契约。 +- 业务 Blackboard keys、`operator_io_contracts.h`:业务端口和池容量契约。 - `contracts/`、`core/`、`engine/` 中的中性类型:框架与模型执行协议。 - `demo/common/dataset_reader.h`、`result_writer.h`:Demo 数据集读取、输出记录和统计结构。 diff --git a/plans/ARCHITECTURE_REVIEW.md b/plans/ARCHITECTURE_REVIEW.md index 8c02729a..025899b1 100644 --- a/plans/ARCHITECTURE_REVIEW.md +++ b/plans/ARCHITECTURE_REVIEW.md @@ -178,8 +178,8 @@ Definition、Catalog、Validator、节点运行时及工具的解释须一致。 数组容量及分配/释放。`allocator`、`params` 也是现有 Pipeline 配置字段,见 [输出分配方案](../doc/dev_guide/operator_output_allocation.md#选择参数)。测试正在证明这条接入路径。 删除属于撤销能力,并非死代码清理;本文不建议删除。 -- **未使用结果结构可清理,但保留测试扩展所需类型**:[biz_results.h](../include/adapter/biz_results.h) 中 - 6 个结构没有类型引用,可核实后删除;`KeywordResult` 用于输出池与自定义 allocator 测试,须保留或移到测试夹具。 +- **未使用结果结构已清理(B1)**:原 `include/adapter/biz_results.h` 中 6 个结构没有类型引用,已随文件删除; + `KeywordResult` 只用于输出池与自定义 allocator 测试,已改为测试夹具中的 `NestedOutputSource`。 - **宿主类型集中登记可研究分散注册**:类型清单写在框架头里([io_converter.h:35](../include/adapter/io_converter.h#L35)), 内置值类型集中在 [operator_builtin_value_types.cpp](../src/adapter/operator/operator_builtin_value_types.cpp), 新增宿主结构要改这两个集中文件。收敛注册步骤须保留类型与布局核验,不把宿主类型带入下层; @@ -317,7 +317,7 @@ Definition、Catalog、Validator、节点运行时及工具的解释须一致。 | A2 | RuleMatch 外部响应序列化移到 Adapter | 第一节 3 | 已完成:`RuleMatchItem` 改为中性的全部命中与带类型槽位;两个转换器共用 Adapter 序列化,字节级契约测试覆盖 | | A3 | 错误码按阶段映射 | 第一节 1 | 已完成:Adapter 集中映射,`Pipeline::Control` 报告失败阶段;规则见[宿主调用与生命周期](../doc/dev_guide/operator_output_allocation.md#宿主调用与生命周期),真实 SDK 目标码仍待内网核验 | | A4 | 致命错误与单条无效结果写入业务契约 | 第一节 4 | 已完成:保留整批回滚,各业务约定见[整批失败与单条结果](../doc/dev_guide/business_onboarding.md#整批失败与单条结果) | -| B1 | 清理未使用的结果结构 | 第三节·接入适配层 | 待实施 | +| B1 | 清理未使用的结果结构 | 第三节·接入适配层 | 已完成:删除 `biz_results.h`;自定义 allocator 测试改用夹具内的 `NestedOutputSource` | | B2 | 输入/输出端口绑定共用实现 | 第三节·接入适配层 | 待实施 | | B3 | 输入限额统一规则来源 | 第三节·接入适配层 | 待实施 | | B4 | `RuntimeOptions` 死字段与测试便利接口 | 第三节·流程编排层 | 待实施 | diff --git a/plans/DEVELOPER_EXPERIENCE_DESIGN.md b/plans/DEVELOPER_EXPERIENCE_DESIGN.md index b59da1e0..04051d6e 100644 --- a/plans/DEVELOPER_EXPERIENCE_DESIGN.md +++ b/plans/DEVELOPER_EXPERIENCE_DESIGN.md @@ -1097,7 +1097,6 @@ f"Compiled test '{test}' in '{exe.name}' is not covered by any CTest filter. " /src/adapter/output/ @chamsechan /src/adapter/biz/ @chamsechan /include/adapter/biz_blackboard_keys.h @chamsechan -/include/adapter/biz_results.h @chamsechan /demo/biz/ @chamsechan /demo/profiles*.json @chamsechan /configs/ @chamsechan diff --git a/src/adapter/output/keyword_result_output.cpp b/src/adapter/output/keyword_result_output.cpp index f3136eb0..2473b542 100644 --- a/src/adapter/output/keyword_result_output.cpp +++ b/src/adapter/output/keyword_result_output.cpp @@ -5,7 +5,6 @@ #include "adapter/adapter_status.h" #include "adapter/adapter_validation_helper.h" #include "adapter/biz_blackboard_keys.h" -#include "adapter/biz_results.h" #include "adapter/converter_authoring.h" #include "adapter/io_converter.h" #include "adapter/output/rule_match_response.h" diff --git a/src/adapter/output/structured_document_output.cpp b/src/adapter/output/structured_document_output.cpp index b02b4b92..715c47de 100644 --- a/src/adapter/output/structured_document_output.cpp +++ b/src/adapter/output/structured_document_output.cpp @@ -5,7 +5,6 @@ #include "adapter/adapter_status.h" #include "adapter/adapter_validation_helper.h" #include "adapter/biz_blackboard_keys.h" -#include "adapter/biz_results.h" #include "adapter/converter_authoring.h" #include "adapter/io_converter.h" #include "adapter/result_validation.h" diff --git a/tests/contract/abi/test_adapter_contract_security.cpp b/tests/contract/abi/test_adapter_contract_security.cpp index d120b3fa..64164b6b 100644 --- a/tests/contract/abi/test_adapter_contract_security.cpp +++ b/tests/contract/abi/test_adapter_contract_security.cpp @@ -11,7 +11,6 @@ #include "adapter/adapter_status.h" #include "adapter/adapter_validation_helper.h" #include "adapter/biz_blackboard_keys.h" -#include "adapter/biz_results.h" #include "adapter/deployment_model_resolver.h" #include "adapter/io_binding_registry.h" #include "adapter/io_binding_resolver.h" diff --git a/tests/contract/abi/test_operator_safety.cpp b/tests/contract/abi/test_operator_safety.cpp index 23090a6e..d1615bd4 100644 --- a/tests/contract/abi/test_operator_safety.cpp +++ b/tests/contract/abi/test_operator_safety.cpp @@ -13,7 +13,6 @@ #include #include "adapter/biz_blackboard_keys.h" -#include "adapter/biz_results.h" #include "adapter/io_binding_registry.h" #include "adapter/io_converter_registry.h" #include "edgeflow/operator/interface.h" diff --git a/tests/integration/operator/test_operator_api.cpp b/tests/integration/operator/test_operator_api.cpp index 301a320a..0f55f241 100644 --- a/tests/integration/operator/test_operator_api.cpp +++ b/tests/integration/operator/test_operator_api.cpp @@ -2394,7 +2394,7 @@ int EncodeNestedOutput(AlgContext* context, const OutputPortBindings& bindings, size_t count = req_ids->size(); for (size_t i = 0; i < count; ++i) { - KeywordResult result; + NestedOutputSource result; result.request_id = (*req_ids)[i]; result.is_hit = 0; for (const auto& m : *matches) { diff --git a/tests/support/operator_nested_output_fixture.h b/tests/support/operator_nested_output_fixture.h index d8922ac7..190375c9 100644 --- a/tests/support/operator_nested_output_fixture.h +++ b/tests/support/operator_nested_output_fixture.h @@ -6,12 +6,17 @@ #include #include -#include "adapter/biz_results.h" #include "adapter/operator_value_type.h" #include "nlohmann/json.hpp" namespace llm_edgeflow::test_support { +// Internal result that the nested-output fixture converts. +struct NestedOutputSource { + uint64_t request_id = 0; + int is_hit = 0; +}; + struct NestedOutputParameters { int32_t kind = 1; uint32_t capacity = 3; @@ -123,7 +128,7 @@ inline OperatorValueTypeBinding MakeNestedOutputBinding(int32_t tag = 1) { inline int ConvertNestedOutput(const void* internal, void* external, const ResolvedOutputPoolSpec& spec, std::string* error) { - const auto& result = *static_cast(internal); + const auto& result = *static_cast(internal); const auto& parameters = spec.Parameters(); auto& root = *static_cast(external); auto& payload = *static_cast(root.payload); diff --git a/tests/unit/operator/test_operator_output_pool.cpp b/tests/unit/operator/test_operator_output_pool.cpp index 98c1aaf2..b118c66d 100644 --- a/tests/unit/operator/test_operator_output_pool.cpp +++ b/tests/unit/operator/test_operator_output_pool.cpp @@ -822,7 +822,7 @@ TEST_F(OperatorOutputPoolTest, ASSERT_NE(payload, nullptr); ASSERT_EQ(payload->capacity, 3u); void* original_values = payload->values; - KeywordResult result; + NestedOutputSource result; result.request_id = 42; result.is_hit = 1; ASSERT_EQ(ConvertNestedOutput(&result, block, pool->Spec(), &error), 0) From 0df922112777f18e8c83e2176cdeeddbb97f0c47 Mon Sep 17 00:00:00 2001 From: chamsechan Date: Sun, 4 Oct 2026 20:11:44 +0800 Subject: [PATCH 08/29] refactor(adapter): share one port binding implementation per direction InputPortBindings and OutputPortBindings duplicated the same lookup and storage. Both are now aliases of PortBindings, which keeps them distinct, non-convertible types so decoders and encoders still receive only their own direction. Co-Authored-By: Claude Opus 5.5 --- include/adapter/io_converter.h | 63 ++++------------------- plans/ARCHITECTURE_REVIEW.md | 2 +- tests/unit/adapter/test_io_converters.cpp | 8 +++ 3 files changed, 20 insertions(+), 53 deletions(-) diff --git a/include/adapter/io_converter.h b/include/adapter/io_converter.h index 36152b19..24b35283 100644 --- a/include/adapter/io_converter.h +++ b/include/adapter/io_converter.h @@ -146,12 +146,15 @@ struct OutputEncodeOptions { /** * @brief 逻辑端口到实际 Blackboard Key 的映射助手 + * + * 输入与输出共用查找实现;方向参数使两者保持不同类型,解码只能拿到输入绑定, + * 编码只能拿到输出绑定。 */ -class InputPortBindings { +template +class PortBindings { public: - InputPortBindings() = default; - explicit InputPortBindings( - std::unordered_map mapping) + PortBindings() = default; + explicit PortBindings(std::unordered_map mapping) : mapping_(std::move(mapping)) {} bool HasKey(const std::string& logical_name) const { @@ -160,12 +163,8 @@ class InputPortBindings { template BlackboardKey Key(const std::string& logical_name) const { - auto it = mapping_.find(logical_name); - if (it != mapping_.end()) { - return BlackboardKey{it->second.c_str(), - BlackboardTypeTraits::TypeName()}; - } - return BlackboardKey{"", BlackboardTypeTraits::TypeName()}; + return BlackboardKey{GetActualKey(logical_name).c_str(), + BlackboardTypeTraits::TypeName()}; } // The token names a logical port; always resolve the actual key via bindings. @@ -191,48 +190,8 @@ class InputPortBindings { std::unordered_map mapping_; }; -class OutputPortBindings { - public: - OutputPortBindings() = default; - explicit OutputPortBindings( - std::unordered_map mapping) - : mapping_(std::move(mapping)) {} - - bool HasKey(const std::string& logical_name) const { - return mapping_.find(logical_name) != mapping_.end(); - } - - template - BlackboardKey Key(const std::string& logical_name) const { - auto it = mapping_.find(logical_name); - if (it != mapping_.end()) { - return BlackboardKey{it->second.c_str(), - BlackboardTypeTraits::TypeName()}; - } - return BlackboardKey{"", BlackboardTypeTraits::TypeName()}; - } - - template - BlackboardKey Key(const BlackboardKey& logical_port) const { - return Key(logical_port.name); - } - - const std::string& GetActualKey(const std::string& logical_name) const { - auto it = mapping_.find(logical_name); - if (it != mapping_.end()) { - return it->second; - } - static const std::string kEmpty; - return kEmpty; - } - - const std::unordered_map& All() const { - return mapping_; - } - - private: - std::unordered_map mapping_; -}; +using InputPortBindings = PortBindings; +using OutputPortBindings = PortBindings; /** * @brief 外部槽位定义 (Operator 槽位) diff --git a/plans/ARCHITECTURE_REVIEW.md b/plans/ARCHITECTURE_REVIEW.md index 025899b1..fd710b52 100644 --- a/plans/ARCHITECTURE_REVIEW.md +++ b/plans/ARCHITECTURE_REVIEW.md @@ -318,7 +318,7 @@ Definition、Catalog、Validator、节点运行时及工具的解释须一致。 | A3 | 错误码按阶段映射 | 第一节 1 | 已完成:Adapter 集中映射,`Pipeline::Control` 报告失败阶段;规则见[宿主调用与生命周期](../doc/dev_guide/operator_output_allocation.md#宿主调用与生命周期),真实 SDK 目标码仍待内网核验 | | A4 | 致命错误与单条无效结果写入业务契约 | 第一节 4 | 已完成:保留整批回滚,各业务约定见[整批失败与单条结果](../doc/dev_guide/business_onboarding.md#整批失败与单条结果) | | B1 | 清理未使用的结果结构 | 第三节·接入适配层 | 已完成:删除 `biz_results.h`;自定义 allocator 测试改用夹具内的 `NestedOutputSource` | -| B2 | 输入/输出端口绑定共用实现 | 第三节·接入适配层 | 待实施 | +| B2 | 输入/输出端口绑定共用实现 | 第三节·接入适配层 | 已完成:`PortBindings<方向>` 共用查找,输入/输出仍是不可互换的类型 | | B3 | 输入限额统一规则来源 | 第三节·接入适配层 | 待实施 | | B4 | `RuntimeOptions` 死字段与测试便利接口 | 第三节·流程编排层 | 待实施 | | B5 | `NodeBase` 旧写法辅助函数收窄 | 第三节·能力节点层 | 待评估 | diff --git a/tests/unit/adapter/test_io_converters.cpp b/tests/unit/adapter/test_io_converters.cpp index bbc32df4..279e08b5 100644 --- a/tests/unit/adapter/test_io_converters.cpp +++ b/tests/unit/adapter/test_io_converters.cpp @@ -1,6 +1,7 @@ #include #include +#include #include "adapter/converter_authoring.h" #include "adapter/io_converter.h" @@ -84,6 +85,13 @@ TEST(IoConverterTest, ViewAccessorsAndPortBindings) { EXPECT_TRUE(out_bindings.HasKey("answers")); EXPECT_EQ(out_bindings.GetActualKey("unknown"), ""); EXPECT_FALSE(out_bindings.HasKey("unknown")); + EXPECT_STREQ(out_bindings.Key("unknown").name, ""); + + // Shared lookup keeps the direction in the type: decoders cannot receive + // output bindings and encoders cannot receive input bindings. + static_assert(!std::is_same_v); + static_assert(!std::is_convertible_v); + static_assert(!std::is_convertible_v); } TEST(IoConverterTest, RegisterAndFindInputConverter) { From 6f88abdf195d797af656e95c35619ab2bc8af42f Mon Sep 17 00:00:00 2001 From: chamsechan Date: Sun, 4 Oct 2026 20:15:39 +0800 Subject: [PATCH 09/29] refactor(adapter): stop presenting input limits as resolved deployment config ResolvedInputLimits was never configured: the resolver always stored its defaults and the handle copied them per call. It is now InputLimits, the host-memory safety bounds that value types check before reading, passed as defaults by the facade and still injectable by tests and authored value types. Buffer and Any carrier bounds become named biz_input constants, so every limit has one definition. The converter checks stay as the business-semantics layer; read ranges and error order are unchanged. Co-Authored-By: Claude Opus 5.5 --- doc/dev_guide/operator_output_allocation.md | 2 +- include/adapter/biz_input_constraints.h | 3 +++ include/adapter/operator_value_type.h | 16 +++++++----- plans/ARCHITECTURE_REVIEW.md | 14 ++++------ src/adapter/operator/operator_adapter.cpp | 5 ++-- .../operator/operator_builtin_value_types.cpp | 26 +++++++++---------- .../operator/operator_config_resolver.cpp | 1 - .../operator/operator_config_resolver.h | 1 - .../operator/operator_process_binding.cpp | 2 +- .../operator/operator_process_binding.h | 2 +- .../operator/test_operator_api.cpp | 2 +- .../operator/test_operator_value_registry.cpp | 12 ++++----- 12 files changed, 42 insertions(+), 44 deletions(-) diff --git a/doc/dev_guide/operator_output_allocation.md b/doc/dev_guide/operator_output_allocation.md index 437cf77c..fd3fd101 100644 --- a/doc/dev_guide/operator_output_allocation.md +++ b/doc/dev_guide/operator_output_allocation.md @@ -139,7 +139,7 @@ RegisterOperatorValueType(binding); 容量结构的顺序是默认值、最大值。成员声明同时用于配置校验、预算、分配和 重置;标量回调必须 `noexcept`,只重置标量,不能覆盖嵌套指针。输入对应使用 -`MakeTypedInputBinding`,回调直接接收 `const T&` 和 `ResolvedInputLimits`。 +`MakeTypedInputBinding`,回调直接接收 `const T&` 和 `InputLimits`。 完整可执行示例见[输出池测试](../../tests/unit/operator/test_operator_output_pool.cpp)。 特殊嵌套布局的输出方案提供以下行为: diff --git a/include/adapter/biz_input_constraints.h b/include/adapter/biz_input_constraints.h index a57312a9..e9db8c5c 100644 --- a/include/adapter/biz_input_constraints.h +++ b/include/adapter/biz_input_constraints.h @@ -15,5 +15,8 @@ inline constexpr int32_t kMaxAudioPcmSamples = 16000 * 60; inline constexpr size_t kMaxAudioPcmBytes = 10 * 1024 * 1024; inline constexpr int32_t kMinSampleRate = 8000; inline constexpr int32_t kMaxSampleRate = 192000; +// Generic carriers without business field semantics. +inline constexpr size_t kMaxBufferBytes = 10 * 1024 * 1024; +inline constexpr size_t kMaxAnyBytes = 10 * 1024 * 1024; } // namespace llm_edgeflow::biz_input diff --git a/include/adapter/operator_value_type.h b/include/adapter/operator_value_type.h index bea87297..f5407f7e 100644 --- a/include/adapter/operator_value_type.h +++ b/include/adapter/operator_value_type.h @@ -18,9 +18,12 @@ namespace llm_edgeflow { /** - * @brief 输入限制配置 + * @brief 宿主输入的安全上限 + * + * 值类型在读取宿主内存前用这些上限检查指针与长度;默认值全部来自 + * biz_input 常量,Operator 不提供配置项。测试和自定义值类型可传入更小的上限。 */ -struct ResolvedInputLimits { +struct InputLimits { size_t max_text_bytes = biz_input::kMaxTextBytes; size_t max_doc_text_bytes = biz_input::kMaxDocTextBytes; size_t max_image_uri_bytes = biz_input::kMaxImageUriBytes; @@ -29,8 +32,8 @@ struct ResolvedInputLimits { size_t max_audio_pcm_bytes = biz_input::kMaxAudioPcmBytes; // 10 MiB int32_t min_sample_rate = biz_input::kMinSampleRate; int32_t max_sample_rate = biz_input::kMaxSampleRate; - size_t max_buffer_bytes = 10 * 1024 * 1024; // 10 MiB - size_t max_any_bytes = 10 * 1024 * 1024; // 10 MiB + size_t max_buffer_bytes = biz_input::kMaxBufferBytes; + size_t max_any_bytes = biz_input::kMaxAnyBytes; }; struct OutputCapacityFieldConfig { @@ -124,7 +127,7 @@ struct OwnedExternalBlock { }; using ValidateExternalFn = std::function; + const void* ptr, const InputLimits& limits, std::string* err)>; using AllocateExternalFn = std::function int { if (!ptr) { if (err) *err = std::string(type_name) + " pointer is null"; diff --git a/plans/ARCHITECTURE_REVIEW.md b/plans/ARCHITECTURE_REVIEW.md index fd710b52..ad8d8c4f 100644 --- a/plans/ARCHITECTURE_REVIEW.md +++ b/plans/ARCHITECTURE_REVIEW.md @@ -161,14 +161,10 @@ Definition、Catalog、Validator、节点运行时及工具的解释须一致。 - **端口绑定实现重复,可抽公共实现**:`InputPortBindings` 和 `OutputPortBindings` ([io_converter.h:150](../include/adapter/io_converter.h#L150)、[io_converter.h:194](../include/adapter/io_converter.h#L194)) 的查找与存储逻辑相同。保留输入/输出方向约束及错误诊断即可,不必扩展作者接口。 -- **输入限额检查重复,可统一规则来源**:值类型校验用 `ResolvedInputLimits` - ([operator_builtin_value_types.cpp:132](../src/adapter/operator/operator_builtin_value_types.cpp#L132)), - 转换器再用常量重查一遍([audit_input.cpp:45](../src/adapter/input/audit_input.cpp#L45))。而 `ResolvedInputLimits` - 从未被配置,始终是默认值([operator_config_resolver.cpp:348](../src/adapter/operator/operator_config_resolver.cpp#L348)); - 当前两处限额均来自同一组默认常量,没有实际数值不一致的证据。 - 业务字段语义归 Converter,但值类型扫描宿主内存前必须保留安全字节上限、长度与指针校验,见 - [operator_value_type_registry.cpp:281](../src/adapter/operator/operator_value_type_registry.cpp#L281)。 - 统一校验不能扩大读取范围,也不能无意改变错误优先级。 +- **输入限额检查(B3 已处理)**:值类型校验与转换器各查一次限额,但两处都取自 `biz_input` 常量, + 分别负责读取宿主内存前的安全上限和业务字段语义,保留两层检查。原 `ResolvedInputLimits` 从未被配置, + 已改名为 `InputLimits` 并移出部署配置与 handle;通用载体的 10 MiB 上限也改为具名常量, + 读取范围和错误优先级不变。 - **契约元数据应保留,可减少重复填写**:`schema_id`、`schema_version`、`external_type`、槽位 `value_type` 和 `capacity_policy` 实际参与同一业务外部协议的一致性检查,不一致会拒绝接入,见 [io_binding_registry.cpp:14](../src/adapter/io_binding_registry.cpp#L14)。其中部分已有默认值,并非每项都要手写。 @@ -319,7 +315,7 @@ Definition、Catalog、Validator、节点运行时及工具的解释须一致。 | A4 | 致命错误与单条无效结果写入业务契约 | 第一节 4 | 已完成:保留整批回滚,各业务约定见[整批失败与单条结果](../doc/dev_guide/business_onboarding.md#整批失败与单条结果) | | B1 | 清理未使用的结果结构 | 第三节·接入适配层 | 已完成:删除 `biz_results.h`;自定义 allocator 测试改用夹具内的 `NestedOutputSource` | | B2 | 输入/输出端口绑定共用实现 | 第三节·接入适配层 | 已完成:`PortBindings<方向>` 共用查找,输入/输出仍是不可互换的类型 | -| B3 | 输入限额统一规则来源 | 第三节·接入适配层 | 待实施 | +| B3 | 输入限额统一规则来源 | 第三节·接入适配层 | 已完成:限额全部来自 `biz_input` 常量;`InputLimits` 不再伪装成部署配置,两层检查保留 | | B4 | `RuntimeOptions` 死字段与测试便利接口 | 第三节·流程编排层 | 待实施 | | B5 | `NodeBase` 旧写法辅助函数收窄 | 第三节·能力节点层 | 待评估 | | C1 | Map/Batch 生命周期合并 | 第三节·能力节点层 | 待评估 | diff --git a/src/adapter/operator/operator_adapter.cpp b/src/adapter/operator/operator_adapter.cpp index 0f2e79c9..6f3dba49 100644 --- a/src/adapter/operator/operator_adapter.cpp +++ b/src/adapter/operator/operator_adapter.cpp @@ -30,7 +30,6 @@ struct OperatorHandle { uint32_t effective_process_batch_limit = 25; const llm_edgeflow::InputConverterDefinition* input_converter = nullptr; const llm_edgeflow::OutputConverterDefinition* output_converter = nullptr; - llm_edgeflow::ResolvedInputLimits input_limits; std::unordered_map> output_pools; @@ -262,7 +261,6 @@ int Operator_Create(void** handle, const CreateParam* param) noexcept { handle_instance->effective_process_batch_limit = effective_batch_limit; handle_instance->input_converter = runtime->GetIoPlan()->input_converter; handle_instance->output_converter = runtime->GetIoPlan()->output_converter; - handle_instance->input_limits = resolved_conf.input_limits; handle_instance->output_pools = std::move(pools); handle_instance->runtime = std::move(runtime); @@ -333,7 +331,8 @@ int Operator_Process(void* handle, const NamedIoBatch& inputs, llm_edgeflow::ExternalInputBatchView in_view; std::string in_err; int in_ret = llm_edgeflow::ValidateAndExtractOperatorInputs( - inputs, *h->input_converter, h->input_limits, &in_view, &in_err); + inputs, *h->input_converter, llm_edgeflow::InputLimits{}, &in_view, + &in_err); if (in_ret != 0) { SetLastError(in_err); return in_ret; diff --git a/src/adapter/operator/operator_builtin_value_types.cpp b/src/adapter/operator/operator_builtin_value_types.cpp index 61a81e73..35b08140 100644 --- a/src/adapter/operator/operator_builtin_value_types.cpp +++ b/src/adapter/operator/operator_builtin_value_types.cpp @@ -6,7 +6,7 @@ void OperatorValueTypeRegistry::RegisterBuiltinBindings() { // 1. string -> CompanyString RegisterBinding(MakeTypedInputBinding( "string", "CompanyString", - [](const CompanyString& in, const ResolvedInputLimits& limits, + [](const CompanyString& in, const InputLimits& limits, std::string* err) -> int { return ValidateCompanyString(&in, limits.max_text_bytes, "string", err); })); @@ -14,7 +14,7 @@ void OperatorValueTypeRegistry::RegisterBuiltinBindings() { // 2. buffer -> CompanyBuffer RegisterBinding(MakeTypedInputBinding( "buffer", "CompanyBuffer", - [](const CompanyBuffer& in, const ResolvedInputLimits& limits, + [](const CompanyBuffer& in, const InputLimits& limits, std::string* err) -> int { return ValidateCompanyBuffer(&in, limits.max_buffer_bytes, "buffer", err); @@ -23,7 +23,7 @@ void OperatorValueTypeRegistry::RegisterBuiltinBindings() { // 3. any -> CompanyAny RegisterBinding(MakeTypedInputBinding( "any", "CompanyAny", - [](const CompanyAny& in, const ResolvedInputLimits& limits, + [](const CompanyAny& in, const InputLimits& limits, std::string* err) -> int { return ValidateCompanyAnyPayload(&in, limits.max_any_bytes, "any", err); })); @@ -31,7 +31,7 @@ void OperatorValueTypeRegistry::RegisterBuiltinBindings() { // 4. frame -> CompanyFrame RegisterBinding(MakeTypedInputBinding( "frame", "CompanyFrame", - [](const CompanyFrame& in, const ResolvedInputLimits& limits, + [](const CompanyFrame& in, const InputLimits& limits, std::string* err) -> int { if (!in.image_uri) { if (err) *err = "CompanyFrame.image_uri is null"; @@ -63,8 +63,8 @@ void OperatorValueTypeRegistry::RegisterBuiltinBindings() { // 6. keyword_in -> CompanyOperatorKeywordInput RegisterBinding(MakeTypedInputBinding( "keyword_in", "CompanyOperatorKeywordInput", - [](const CompanyOperatorKeywordInput& in, - const ResolvedInputLimits& limits, std::string* err) -> int { + [](const CompanyOperatorKeywordInput& in, const InputLimits& limits, + std::string* err) -> int { return ValidateCompanyString(in.sentence_text, limits.max_text_bytes, "sentence_text", err); })); @@ -84,8 +84,8 @@ void OperatorValueTypeRegistry::RegisterBuiltinBindings() { // 8. entity_in -> CompanyOperatorEntityInput RegisterBinding(MakeTypedInputBinding( "entity_in", "CompanyOperatorEntityInput", - [](const CompanyOperatorEntityInput& in, - const ResolvedInputLimits& limits, std::string* err) -> int { + [](const CompanyOperatorEntityInput& in, const InputLimits& limits, + std::string* err) -> int { return ValidateCompanyString(in.sentence_text, limits.max_text_bytes, "sentence_text", err); })); @@ -104,7 +104,7 @@ void OperatorValueTypeRegistry::RegisterBuiltinBindings() { // 10. doc_in -> CompanyOperatorDocInput RegisterBinding(MakeTypedInputBinding( "doc_in", "CompanyOperatorDocInput", - [](const CompanyOperatorDocInput& in, const ResolvedInputLimits& limits, + [](const CompanyOperatorDocInput& in, const InputLimits& limits, std::string* err) -> int { int ret = ValidateCompanyString(in.query_text, limits.max_text_bytes, "query_text", err); @@ -132,7 +132,7 @@ void OperatorValueTypeRegistry::RegisterBuiltinBindings() { // 12. audit_in -> CompanyOperatorAuditInput RegisterBinding(MakeTypedInputBinding( "audit_in", "CompanyOperatorAuditInput", - [](const CompanyOperatorAuditInput& in, const ResolvedInputLimits& limits, + [](const CompanyOperatorAuditInput& in, const InputLimits& limits, std::string* err) -> int { int ret = ValidateCompanyString(in.user_text, limits.max_text_bytes, "user_text", err); @@ -165,7 +165,7 @@ void OperatorValueTypeRegistry::RegisterBuiltinBindings() { // 14. audio_in -> CompanyOperatorAudioInput RegisterBinding(MakeTypedInputBinding( "audio_in", "CompanyOperatorAudioInput", - [](const CompanyOperatorAudioInput& in, const ResolvedInputLimits& limits, + [](const CompanyOperatorAudioInput& in, const InputLimits& limits, std::string* err) -> int { if (in.sample_rate < limits.min_sample_rate || in.sample_rate > limits.max_sample_rate) { @@ -210,8 +210,8 @@ void OperatorValueTypeRegistry::RegisterBuiltinBindings() { // 16. rerank_in -> CompanyOperatorRerankInput RegisterBinding(MakeTypedInputBinding( "rerank_in", "CompanyOperatorRerankInput", - [](const CompanyOperatorRerankInput& in, - const ResolvedInputLimits& limits, std::string* err) -> int { + [](const CompanyOperatorRerankInput& in, const InputLimits& limits, + std::string* err) -> int { int ret = ValidateCompanyString(in.query_text, limits.max_text_bytes, "query_text", err); if (ret != 0) return ret; diff --git a/src/adapter/operator/operator_config_resolver.cpp b/src/adapter/operator/operator_config_resolver.cpp index 65989c61..a66fa8b4 100644 --- a/src/adapter/operator/operator_config_resolver.cpp +++ b/src/adapter/operator/operator_config_resolver.cpp @@ -345,7 +345,6 @@ int OperatorConfigResolver::Resolve( result->effective_process_batch_limit = static_cast( std::min(effective_depth, io_plan->effective_max_batch_size)); - result->input_limits = ResolvedInputLimits{}; result->io_plan = std::move(io_plan); return 0; diff --git a/src/adapter/operator/operator_config_resolver.h b/src/adapter/operator/operator_config_resolver.h index 074cabd8..0c0db3ca 100644 --- a/src/adapter/operator/operator_config_resolver.h +++ b/src/adapter/operator/operator_config_resolver.h @@ -22,7 +22,6 @@ struct ResolvedOperatorConfig { std::string biz_name; std::string io_binding; std::unique_ptr io_plan; - ResolvedInputLimits input_limits; uint32_t effective_frame_depth = 0; // normalized output pool depth uint32_t effective_process_batch_limit = 0; // min(pool depth, binding limit) }; diff --git a/src/adapter/operator/operator_process_binding.cpp b/src/adapter/operator/operator_process_binding.cpp index 9044d3f9..7cd0a923 100644 --- a/src/adapter/operator/operator_process_binding.cpp +++ b/src/adapter/operator/operator_process_binding.cpp @@ -7,7 +7,7 @@ namespace llm_edgeflow { int ValidateAndExtractOperatorInputs( const llm_edgeflow::operator_api::NamedIoBatch& inputs, - const InputConverterDefinition& in_conv, const ResolvedInputLimits& limits, + const InputConverterDefinition& in_conv, const InputLimits& limits, ExternalInputBatchView* out_view, std::string* error) { if (!out_view) { if (error) *error = "Null out_view pointer"; diff --git a/src/adapter/operator/operator_process_binding.h b/src/adapter/operator/operator_process_binding.h index 1f77b58a..704e1065 100644 --- a/src/adapter/operator/operator_process_binding.h +++ b/src/adapter/operator/operator_process_binding.h @@ -28,7 +28,7 @@ struct AcquiredOutputBlock { int ValidateAndExtractOperatorInputs( const llm_edgeflow::operator_api::NamedIoBatch& inputs, - const InputConverterDefinition& in_conv, const ResolvedInputLimits& limits, + const InputConverterDefinition& in_conv, const InputLimits& limits, ExternalInputBatchView* out_view, std::string* error); int ResolveOperatorOutputs( diff --git a/tests/integration/operator/test_operator_api.cpp b/tests/integration/operator/test_operator_api.cpp index 0f55f241..bc50d3fe 100644 --- a/tests/integration/operator/test_operator_api.cpp +++ b/tests/integration/operator/test_operator_api.cpp @@ -1039,7 +1039,7 @@ TEST_F(OperatorApiTest, CompanyBufferAndAnyValidation) { ASSERT_NE(buf_binding, nullptr); ASSERT_TRUE(buf_binding->validate_external); - ResolvedInputLimits limits; + InputLimits limits; std::string err; // CompanyBuffer: null pointer diff --git a/tests/unit/operator/test_operator_value_registry.cpp b/tests/unit/operator/test_operator_value_registry.cpp index e4e4475a..36543e36 100644 --- a/tests/unit/operator/test_operator_value_registry.cpp +++ b/tests/unit/operator/test_operator_value_registry.cpp @@ -318,7 +318,7 @@ TEST(OperatorValueRegistryTest, BuiltinInputsPreserveNullDiagnostics) { const char* suffixes[] = {"string", "buffer", "any", "frame", "keyword_in", "entity_in", "doc_in", "audit_in", "audio_in", "rerank_in"}; - ResolvedInputLimits limits; + InputLimits limits; for (const char* suffix : suffixes) { SCOPED_TRACE(suffix); const auto* binding = @@ -965,7 +965,7 @@ TEST(OperatorValueRegistryTest, DirectionDoesNotDependOnSuffixNaming) { binding.canonical_suffix = "opaque_payload"; binding.external_c_type_name = "OpaquePayload"; binding.direction = IoDirection::kInput; - binding.validate_external = [](const void*, const ResolvedInputLimits&, + binding.validate_external = [](const void*, const InputLimits&, std::string*) { return 0; }; ASSERT_TRUE(reg.RegisterBinding(binding)); @@ -1082,7 +1082,7 @@ TEST(OperatorValueRegistryTest, ASSERT_NE(binding, nullptr); ASSERT_TRUE(binding->validate_external); - ResolvedInputLimits limits; + InputLimits limits; limits.min_sample_rate = 8000; limits.max_sample_rate = 48000; limits.max_audio_pcm_samples = 160000; @@ -1204,7 +1204,7 @@ TEST(OperatorValueRegistryTest, OperatorAgreesOnPcmBoundaries) { EXPECT_EQ(binding->validate_external(&op_input, {}, nullptr) == 0, test.valid); } - ResolvedInputLimits limits; + InputLimits limits; limits.max_audio_pcm_bytes = sizeof(float); CompanyOperatorAudioInput input{7, samples.data(), 2, 16000}; EXPECT_NE(binding->validate_external(&input, limits, nullptr), 0); @@ -1216,7 +1216,7 @@ TEST(OperatorValueRegistryTest, std::string type_name = "BusinessInput"; auto result = MakeTypedInputBinding( "test_business_input", type_name.c_str(), - [](const BusinessInput& input, const ResolvedInputLimits& limits, + [](const BusinessInput& input, const InputLimits& limits, std::string* error) -> int { if (input.text_bytes <= limits.max_text_bytes) return 0; if (error) *error = "business text exceeds configured limit"; @@ -1237,7 +1237,7 @@ TEST(OperatorValueRegistryTest, view.slot_types["input"] = "DifferentInput"; EXPECT_EQ(view.GetSlot("input", 0), nullptr); view.slot_types["input"] = binding.external_c_type_name; - ResolvedInputLimits limits; + InputLimits limits; limits.max_text_bytes = 8; std::string error; EXPECT_EQ(binding.validate_external(decoded, limits, &error), -3); From bfd41c193b2ff637a30cd6963398f4e1444f9a77 Mon Sep 17 00:00:00 2001 From: chamsechan Date: Sun, 4 Oct 2026 20:24:03 +0800 Subject: [PATCH 10/29] refactor(core): drop write-only runtime options and single-model registration RuntimeOptions::biz_type, depth_num and biz_name were written by the Adapter but never read; Pipeline only forwards the device and chip target. ModelManager::RegisterModel was a test convenience over the atomic batch path that production uses, so tests and the authoring benchmark now register through RegisterTestModel or RegisterBatch. Read accessors, UpdateModelRevision and SetResource/GetResource stay because tests rely on them to prove atomic registration, revision-keyed caching and resource type checks. Co-Authored-By: Claude Opus 5.5 --- .../node_authoring/benchmark/probe.cpp | 7 +- include/core/session_context.h | 21 ----- plans/ARCHITECTURE_REVIEW.md | 2 +- src/adapter/operator/operator_adapter.cpp | 2 - src/adapter/shared_algorithm_runtime.cpp | 1 - tests/support/model_registration.h | 30 ++++++++ tests/support/node_harness.h | 7 +- tests/unit/core/test_framework_core.cpp | 13 ++-- tests/unit/core/test_node_base_contracts.cpp | 5 +- .../core/test_node_ownership_and_reuse.cpp | 6 +- tests/unit/core/test_pipeline_config.cpp | 5 +- .../engine/test_onnx_and_embedding_model.cpp | 9 ++- tests/unit/nodes/test_asr_transcribe_node.cpp | 7 +- tests/unit/nodes/test_common_nodes.cpp | 76 ++++++++++--------- tests/unit/nodes/test_llm_generate_node.cpp | 5 +- tests/unit/nodes/test_ocr_detect_node.cpp | 7 +- tests/unit/nodes/test_text_embedding_node.cpp | 6 +- tests/unit/nodes/test_text_rerank_node.cpp | 11 +-- 18 files changed, 122 insertions(+), 98 deletions(-) create mode 100644 tests/support/model_registration.h diff --git a/dev_support/node_authoring/benchmark/probe.cpp b/dev_support/node_authoring/benchmark/probe.cpp index eef29dda..64b5cfc2 100644 --- a/dev_support/node_authoring/benchmark/probe.cpp +++ b/dev_support/node_authoring/benchmark/probe.cpp @@ -137,8 +137,11 @@ int main(int argc, char** argv) { const int batch_size = 32, length = 1024; SessionContext session; auto mock = std::make_shared(); - if (!session.GetModelManager().RegisterModel("echo", mock, "probe-v1")) - return 2; + ModelRegistration registration; + registration.model_id = "echo"; + registration.revision = "probe-v1"; + registration.model = mock; + if (!session.GetModelManager().RegisterBatch({registration})) return 2; ValidatedNodePlan plan; std::unique_ptr node; if (mode == "old_map") diff --git a/include/core/session_context.h b/include/core/session_context.h index 62f6f552..aba0d976 100644 --- a/include/core/session_context.h +++ b/include/core/session_context.h @@ -39,10 +39,7 @@ class SessionResourceKey { struct RuntimeOptions { int device_id = -1; // -1 表示未指定/默认,>=0 表示物理设备 ID bool has_device_id = false; - int biz_type = 0; - std::string biz_name; std::string chip_type = "UNKNOWN"; - uint32_t depth_num = 1; }; /** @@ -123,24 +120,6 @@ class ModelManager { return true; } - /** - * @brief 注册单个模型实例 - */ - bool RegisterModel(const std::string& model_id, std::shared_ptr model, - std::string revision = {}, std::string model_type = {}, - std::string capability = {}, - std::string backend_type = {}) { - if (model_id.empty() || !model) return false; - ModelRegistration reg; - reg.model_id = model_id; - reg.model_type = std::move(model_type); - reg.capability = std::move(capability); - reg.backend_type = std::move(backend_type); - reg.revision = std::move(revision); - reg.model = std::move(model); - return RegisterBatch({std::move(reg)}); - } - template std::shared_ptr GetModel(const std::string& model_id) const { std::lock_guard lock(mutex_); diff --git a/plans/ARCHITECTURE_REVIEW.md b/plans/ARCHITECTURE_REVIEW.md index ad8d8c4f..787fe4d8 100644 --- a/plans/ARCHITECTURE_REVIEW.md +++ b/plans/ARCHITECTURE_REVIEW.md @@ -316,7 +316,7 @@ Definition、Catalog、Validator、节点运行时及工具的解释须一致。 | B1 | 清理未使用的结果结构 | 第三节·接入适配层 | 已完成:删除 `biz_results.h`;自定义 allocator 测试改用夹具内的 `NestedOutputSource` | | B2 | 输入/输出端口绑定共用实现 | 第三节·接入适配层 | 已完成:`PortBindings<方向>` 共用查找,输入/输出仍是不可互换的类型 | | B3 | 输入限额统一规则来源 | 第三节·接入适配层 | 已完成:限额全部来自 `biz_input` 常量;`InputLimits` 不再伪装成部署配置,两层检查保留 | -| B4 | `RuntimeOptions` 死字段与测试便利接口 | 第三节·流程编排层 | 待实施 | +| B4 | `RuntimeOptions` 死字段与测试便利接口 | 第三节·流程编排层 | 已完成:删除只写不读的 `biz_type`、`depth_num`、`biz_name`;单模型注册移到测试支持 `RegisterTestModel`。读取接口、`UpdateModelRevision` 与 `SetResource`/`GetResource` 保留,它们证明原子注册、按版本区分缓存和资源类型检查 | | B5 | `NodeBase` 旧写法辅助函数收窄 | 第三节·能力节点层 | 待评估 | | C1 | Map/Batch 生命周期合并 | 第三节·能力节点层 | 待评估 | | C2 | 端口声明与实例状态分离 | 第三节·能力节点层 | 待评估 | diff --git a/src/adapter/operator/operator_adapter.cpp b/src/adapter/operator/operator_adapter.cpp index 6f3dba49..b04179ba 100644 --- a/src/adapter/operator/operator_adapter.cpp +++ b/src/adapter/operator/operator_adapter.cpp @@ -198,10 +198,8 @@ int Operator_Create(void** handle, const CreateParam* param) noexcept { llm_edgeflow::RuntimeOptions runtime_options; runtime_options.chip_type = ComputePlatformToString(param->compute_platform); - runtime_options.depth_num = effective_depth; runtime_options.device_id = param->device_id; runtime_options.has_device_id = (param->device_id >= 0); - runtime_options.biz_name = resolved_conf.biz_name; // 3. 构建内部共享运行时 (通过已验证的 IoPlan) std::unique_ptr runtime; diff --git a/src/adapter/shared_algorithm_runtime.cpp b/src/adapter/shared_algorithm_runtime.cpp index 2738c917..8c84e2ee 100644 --- a/src/adapter/shared_algorithm_runtime.cpp +++ b/src/adapter/shared_algorithm_runtime.cpp @@ -112,7 +112,6 @@ int SharedAlgorithmRuntime::CreateFromIoPlan( } options.device_id = device_id; options.has_device_id = (device_id >= 0); - options.biz_name = io_plan->binding.biz_name; pipeline->GetSessionContext().SetRuntimeOptions(options); diff --git a/tests/support/model_registration.h b/tests/support/model_registration.h new file mode 100644 index 00000000..babc5887 --- /dev/null +++ b/tests/support/model_registration.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include +#include + +#include "core/session_context.h" + +namespace llm_edgeflow { + +// Registers one test model through the same atomic batch path that Pipeline +// uses for a deployment's models. +inline bool RegisterTestModel(ModelManager& manager, std::string model_id, + std::shared_ptr model, + std::string revision = {}, + std::string model_type = {}, + std::string capability = {}, + std::string backend_type = {}) { + if (model_id.empty() || !model) return false; + ModelRegistration registration; + registration.model_id = std::move(model_id); + registration.model_type = std::move(model_type); + registration.capability = std::move(capability); + registration.backend_type = std::move(backend_type); + registration.revision = std::move(revision); + registration.model = std::move(model); + return manager.RegisterBatch({std::move(registration)}); +} + +} // namespace llm_edgeflow diff --git a/tests/support/node_harness.h b/tests/support/node_harness.h index 0da75bdd..3cd4d4ee 100644 --- a/tests/support/node_harness.h +++ b/tests/support/node_harness.h @@ -21,6 +21,7 @@ #include "engine/backend_registry.h" #include "engine/model_interface.h" #include "engine/model_registry.h" +#include "tests/support/model_registration.h" #include "tests/support/node_plan_fixture.h" namespace llm_edgeflow { @@ -187,9 +188,9 @@ class NodeHarness { session_ctx_ = std::make_unique(); for (const auto& [mid, model] : models_) { - session_ctx_->GetModelManager().RegisterModel( - mid, model, "harness_rev", model ? model->ModelType() : "mock", - model ? model->Capability() : "llm", "mock"); + RegisterTestModel(session_ctx_->GetModelManager(), mid, model, + "harness_rev", model ? model->ModelType() : "mock", + model ? model->Capability() : "llm", "mock"); } input_keys_.clear(); diff --git a/tests/unit/core/test_framework_core.cpp b/tests/unit/core/test_framework_core.cpp index c9ee637d..ae18c2de 100644 --- a/tests/unit/core/test_framework_core.cpp +++ b/tests/unit/core/test_framework_core.cpp @@ -18,6 +18,7 @@ #include "dev_support/inference/test_biz_models.h" #include "engine/fixed_batch_executor.h" #include "engine/model_interface.h" +#include "tests/support/model_registration.h" #include "tests/support/pipeline_test_utils.h" #include "tests/support/scoped_allocation_failure.h" @@ -102,12 +103,12 @@ TEST(TraceableItemTest, ProvenanceTracking) { // 3. 测试 ModelManager 的强类型多模型管理机制 TEST(ModelManagerTest, TypedModels) { ModelManager manager; - ASSERT_TRUE(manager.RegisterModel( - "my_embed_v1", std::make_shared(128, 4), - "test-v1")); - ASSERT_TRUE(manager.RegisterModel( - "my_rerank_v1", std::make_shared(4), - "test-v1")); + ASSERT_TRUE(RegisterTestModel( + manager, "my_embed_v1", + std::make_shared(128, 4), "test-v1")); + ASSERT_TRUE(RegisterTestModel(manager, "my_rerank_v1", + std::make_shared(4), + "test-v1")); EXPECT_TRUE(manager.HasModel("my_embed_v1")); EXPECT_TRUE(manager.HasModel("my_rerank_v1")); diff --git a/tests/unit/core/test_node_base_contracts.cpp b/tests/unit/core/test_node_base_contracts.cpp index 593d8317..20ab2f63 100644 --- a/tests/unit/core/test_node_base_contracts.cpp +++ b/tests/unit/core/test_node_base_contracts.cpp @@ -17,6 +17,7 @@ #include "nodes/node_config_parser.h" #include "nodes/node_error_codes.h" #include "nodes/traceable_batch_validation.h" +#include "tests/support/model_registration.h" #include "tests/support/node_test_utils.h" #include "tests/support/registry_test_access.h" @@ -561,8 +562,8 @@ TEST(NodeBaseContractsTest, FunctionAsrWorkflow) { SessionContext session_ctx; auto model = std::make_shared(); - session_ctx.GetModelManager().RegisterModel("test_asr_model", model, - "test-v1"); + RegisterTestModel(session_ctx.GetModelManager(), "test_asr_model", model, + "test-v1"); MockAsrNode node("MockAsrNode", MockAsrSpec()); ASSERT_TRUE( diff --git a/tests/unit/core/test_node_ownership_and_reuse.cpp b/tests/unit/core/test_node_ownership_and_reuse.cpp index 8b81cc79..3c38c40a 100644 --- a/tests/unit/core/test_node_ownership_and_reuse.cpp +++ b/tests/unit/core/test_node_ownership_and_reuse.cpp @@ -12,6 +12,7 @@ #include "core/pipeline_catalog.h" #include "core/session_context.h" #include "engine/model_interface.h" +#include "tests/support/model_registration.h" #include "tests/support/node_test_utils.h" namespace llm_edgeflow { @@ -120,9 +121,8 @@ class DistinctMockEmbeddingModel : public IEmbeddingModel { TEST(NodeOwnershipAndReuseTest, CommonEmbeddingAndVectorTopKExecution) { SessionContext session_ctx; - session_ctx.GetModelManager().RegisterModel( - "embed_model_v2", std::make_shared(), - "test-v1"); + RegisterTestModel(session_ctx.GetModelManager(), "embed_model_v2", + std::make_shared(), "test-v1"); auto embed_node = NodeRegistry::Instance().Create("TextEmbeddingNode"); ASSERT_NE(embed_node, nullptr); diff --git a/tests/unit/core/test_pipeline_config.cpp b/tests/unit/core/test_pipeline_config.cpp index dee1e61b..cc5110b0 100644 --- a/tests/unit/core/test_pipeline_config.cpp +++ b/tests/unit/core/test_pipeline_config.cpp @@ -18,6 +18,7 @@ #include "engine/backend_registry.h" #include "engine/model_interface.h" #include "engine/model_registry.h" +#include "tests/support/model_registration.h" #include "tests/support/pipeline_test_utils.h" namespace llm_edgeflow { @@ -1292,8 +1293,8 @@ TEST_F(PipelineConfigTest, ModelManagerDuplicateRejection) { auto model1 = std::make_shared(); auto model2 = std::make_shared(); - EXPECT_TRUE(manager.RegisterModel("model_x", model1, "test-v1")); - EXPECT_FALSE(manager.RegisterModel("model_x", model2, "test-v2")) + EXPECT_TRUE(RegisterTestModel(manager, "model_x", model1, "test-v1")); + EXPECT_FALSE(RegisterTestModel(manager, "model_x", model2, "test-v2")) << "Duplicate model_id registration must return false without " "overwriting"; EXPECT_EQ(manager.GetModel("model_x"), model1); diff --git a/tests/unit/engine/test_onnx_and_embedding_model.cpp b/tests/unit/engine/test_onnx_and_embedding_model.cpp index 2c274409..b0099739 100644 --- a/tests/unit/engine/test_onnx_and_embedding_model.cpp +++ b/tests/unit/engine/test_onnx_and_embedding_model.cpp @@ -31,6 +31,7 @@ #include "engine/models/bge_common/bert_wordpiece_tokenizer.h" #include "engine/models/bge_embedding/bge_embedding_model.h" #include "engine/models/common/embedding_numeric_support.h" +#include "tests/support/model_registration.h" #include "tests/support/node_test_utils.h" #include "tests/support/pipeline_test_utils.h" @@ -498,8 +499,8 @@ TEST_F(OnnxAndEmbeddingModelTest, {"[PAD]", "[UNK]", "[CLS]", "[SEP]", "hello"}, true)); auto model = std::make_shared( fake_session, tokenizer, 16, "cls", "last_hidden_state", 4, 2); - ASSERT_TRUE( - session_ctx.GetModelManager().RegisterModel("shared_bge", model, "v1")); + ASSERT_TRUE(RegisterTestModel(session_ctx.GetModelManager(), "shared_bge", + model, "v1")); for (const std::string lifetime : {"request", "session"}) { for (const bool normalize : {true, false, true}) { SCOPED_TRACE(lifetime + (normalize ? ":normalized" : ":raw")); @@ -846,8 +847,8 @@ TEST_F(OnnxAndEmbeddingModelTest, TextEmbeddingNodeBoundToModel) { fake_session, tokenizer, /*max_length=*/16, "mean", "last_hidden_state", /*embedding_dim=*/4, /*max_batch_size=*/2); - session_ctx.GetModelManager().RegisterModel( - "test_bge", model, "v1", "bge_embedding", "embedding", "fake_ort"); + RegisterTestModel(session_ctx.GetModelManager(), "test_bge", model, "v1", + "bge_embedding", "embedding", "fake_ort"); auto node = NodeRegistry::Instance().Create("TextEmbeddingNode"); ASSERT_NE(node, nullptr); diff --git a/tests/unit/nodes/test_asr_transcribe_node.cpp b/tests/unit/nodes/test_asr_transcribe_node.cpp index b84d9170..56a58e14 100644 --- a/tests/unit/nodes/test_asr_transcribe_node.cpp +++ b/tests/unit/nodes/test_asr_transcribe_node.cpp @@ -11,6 +11,7 @@ #include "core/session_context.h" #include "dev_support/inference/test_capability_models.h" #include "nodes/node_error_codes.h" +#include "tests/support/model_registration.h" #include "tests/support/node_test_utils.h" namespace llm_edgeflow { @@ -21,9 +22,9 @@ class AsrTranscribeNodeTest : public ::testing::Test { session_ctx_ = std::make_unique(); asr_model_ = std::make_shared(); - ASSERT_TRUE(session_ctx_->GetModelManager().RegisterModel( - "asr_model_v1", asr_model_, "test-revision", "test_asr_model", "asr", - "test_tensor_backend")); + ASSERT_TRUE(RegisterTestModel( + session_ctx_->GetModelManager(), "asr_model_v1", asr_model_, + "test-revision", "test_asr_model", "asr", "test_tensor_backend")); } std::unique_ptr session_ctx_; std::shared_ptr asr_model_; diff --git a/tests/unit/nodes/test_common_nodes.cpp b/tests/unit/nodes/test_common_nodes.cpp index d64b0977..a97b76b1 100644 --- a/tests/unit/nodes/test_common_nodes.cpp +++ b/tests/unit/nodes/test_common_nodes.cpp @@ -19,6 +19,7 @@ #include "dev_support/inference/test_capability_models.h" #include "engine/model_interface.h" #include "nodes/node_error_codes.h" +#include "tests/support/model_registration.h" #include "tests/support/node_harness.h" #include "tests/support/node_test_utils.h" #include "tests/support/pipeline_test_utils.h" @@ -33,26 +34,27 @@ class CommonNodesTest : public ::testing::Test { options.device_id = 0; session_ctx_->SetRuntimeOptions(options); - ASSERT_TRUE(session_ctx_->GetModelManager().RegisterModel( - "embed_model_v1", std::make_shared(384, 4), - "test-v1")); + ASSERT_TRUE(RegisterTestModel( + session_ctx_->GetModelManager(), "embed_model_v1", + std::make_shared(384, 4), "test-v1")); - ASSERT_TRUE(session_ctx_->GetModelManager().RegisterModel( - "rerank_model_v1", std::make_shared(4), - "test-v1")); + ASSERT_TRUE(RegisterTestModel( + session_ctx_->GetModelManager(), "rerank_model_v1", + std::make_shared(4), "test-v1")); - ASSERT_TRUE(session_ctx_->GetModelManager().RegisterModel( - "llm_model_v1", std::make_shared(2), "test-v1")); + ASSERT_TRUE(RegisterTestModel( + session_ctx_->GetModelManager(), "llm_model_v1", + std::make_shared(2), "test-v1")); auto asr_model = std::make_shared(); - ASSERT_TRUE(session_ctx_->GetModelManager().RegisterModel( - "asr_model_v1", std::move(asr_model), "test-revision", "test_asr_model", - "asr", "test_tensor_backend")); + ASSERT_TRUE(RegisterTestModel( + session_ctx_->GetModelManager(), "asr_model_v1", std::move(asr_model), + "test-revision", "test_asr_model", "asr", "test_tensor_backend")); auto ocr_model = std::make_shared(); - ASSERT_TRUE(session_ctx_->GetModelManager().RegisterModel( - "ocr_model_v1", std::move(ocr_model), "test-revision", "test_ocr_model", - "ocr", "test_tensor_backend")); + ASSERT_TRUE(RegisterTestModel( + session_ctx_->GetModelManager(), "ocr_model_v1", std::move(ocr_model), + "test-revision", "test_ocr_model", "ocr", "test_tensor_backend")); } std::unique_ptr session_ctx_; @@ -588,8 +590,9 @@ class CountingEmbeddingModel final : public IEmbeddingModel { // 7.2 TextEmbeddingNode single-flight session caching concurrency test TEST_F(CommonNodesTest, TextEmbeddingNodeSingleFlightSessionCaching) { auto counting_model = std::make_shared(); - ASSERT_TRUE(session_ctx_->GetModelManager().RegisterModel( - "counting_embed_model", counting_model, "test-v1")); + ASSERT_TRUE(RegisterTestModel(session_ctx_->GetModelManager(), + "counting_embed_model", counting_model, + "test-v1")); auto node = NodeRegistry::Instance().Create("TextEmbeddingNode"); ASSERT_NE(node, nullptr); @@ -904,8 +907,8 @@ void CheckScaffoldExecution(const std::string& name, const std::string& model, TEST_F(CommonNodesTest, LlmGeneratePreservesBatchOptionsAndEmptyInputContract) { auto model = std::make_shared(); - ASSERT_TRUE(session_ctx_->GetModelManager().RegisterModel("generate_contract", - model, "v1")); + ASSERT_TRUE(RegisterTestModel(session_ctx_->GetModelManager(), + "generate_contract", model, "v1")); for (bool custom_options : {false, true}) { SCOPED_TRACE(custom_options); auto node = NodeRegistry::Instance().Create("LlmGenerateNode"); @@ -959,8 +962,8 @@ TEST_F(CommonNodesTest, LlmGeneratePreservesBatchOptionsAndEmptyInputContract) { TEST_F(CommonNodesTest, LlmGeneratePreservesFailureCodesWithoutPublishing) { auto model = std::make_shared(); - ASSERT_TRUE(session_ctx_->GetModelManager().RegisterModel("generate_contract", - model, "v1")); + ASSERT_TRUE(RegisterTestModel(session_ctx_->GetModelManager(), + "generate_contract", model, "v1")); auto node = NodeRegistry::Instance().Create("LlmGenerateNode"); ASSERT_NE(node, nullptr); ASSERT_TRUE(InitNodeForTest(*node, {{"bind_model", "generate_contract"}}, @@ -1067,8 +1070,8 @@ TEST_F(CommonNodesTest, TEST_F(CommonNodesTest, PromptRendersOriginalTemplateAndIsolatesRequests) { auto model = std::make_shared(); - ASSERT_TRUE(session_ctx_->GetModelManager().RegisterModel("prompt_contract", - model, "v1")); + ASSERT_TRUE(RegisterTestModel(session_ctx_->GetModelManager(), + "prompt_contract", model, "v1")); auto node = NodeRegistry::Instance().Create("PromptGuidedLlmNode"); const nlohmann::json config = { {"bind_model", "prompt_contract"}, @@ -1113,8 +1116,8 @@ TEST_F(CommonNodesTest, PromptRendersOriginalTemplateAndIsolatesRequests) { TEST_F(CommonNodesTest, PromptDefaultsMatchDirectInitializationAndNativePlan) { auto model = std::make_shared(); - ASSERT_TRUE( - session_ctx_->GetModelManager().RegisterModel("entity_llm", model, "v1")); + ASSERT_TRUE(RegisterTestModel(session_ctx_->GetModelManager(), "entity_llm", + model, "v1")); const nlohmann::json config = {{"bind_model", "entity_llm"}}; auto document = CustomPipeline("entity_extract", "entity_extract"); document["pipeline"][0]["config"] = config; @@ -1154,8 +1157,8 @@ TEST_F(CommonNodesTest, PromptDefaultsMatchDirectInitializationAndNativePlan) { TEST_F(CommonNodesTest, PromptStandardSyntaxMatchesTextTemplateNode) { auto model = std::make_shared(); - ASSERT_TRUE(session_ctx_->GetModelManager().RegisterModel("prompt_contract", - model, "v1")); + ASSERT_TRUE(RegisterTestModel(session_ctx_->GetModelManager(), + "prompt_contract", model, "v1")); const std::string value = "opaque {{input}} {context}"; const std::vector> cases = { {"{{context}}|{{ context }}", value + "|" + value}, @@ -1190,8 +1193,8 @@ TEST_F(CommonNodesTest, PromptStandardSyntaxMatchesTextTemplateNode) { TEST_F(CommonNodesTest, PromptTemplateUnificationAcceptsJsonAndRejectsMalformedPlaceholders) { auto model = std::make_shared(); - ASSERT_TRUE(session_ctx_->GetModelManager().RegisterModel("prompt_contract", - model, "v1")); + ASSERT_TRUE(RegisterTestModel(session_ctx_->GetModelManager(), + "prompt_contract", model, "v1")); // 1. Malformed templates are rejected at validation and Init for (const std::string pattern : {"{{unclosed", "{{unknown}}", "{{}}", "{{invalid name}}"}) { @@ -1227,8 +1230,8 @@ TEST_F(CommonNodesTest, TEST_F(CommonNodesTest, PromptAndGeneratedLlmNodesFailWithoutPublishing) { auto model = std::make_shared(); - ASSERT_TRUE(session_ctx_->GetModelManager().RegisterModel("prompt_contract", - model, "v1")); + ASSERT_TRUE(RegisterTestModel(session_ctx_->GetModelManager(), + "prompt_contract", model, "v1")); for (const char* name : {"PromptGuidedLlmNode", "ScaffoldModelLlmNode", "ScaffoldTutorialLlmNode"}) { SCOPED_TRACE(name); @@ -1263,8 +1266,8 @@ TEST_F(CommonNodesTest, PromptAndGeneratedLlmNodesFailWithoutPublishing) { TEST_F(CommonNodesTest, PromptContextIsExplicitAndRequiredWhenUsed) { auto node = NodeRegistry::Instance().Create("PromptGuidedLlmNode"); auto model = std::make_shared(); - ASSERT_TRUE(session_ctx_->GetModelManager().RegisterModel("prompt_contract", - model, "v1")); + ASSERT_TRUE(RegisterTestModel(session_ctx_->GetModelManager(), + "prompt_contract", model, "v1")); ASSERT_TRUE(InitNodeForTest(*node, {{"bind_model", "prompt_contract"}}, session_ctx_.get())); AlgContext implicit; @@ -1346,8 +1349,9 @@ TEST_F(CommonNodesTest, CustomAndGeneratedNodesUseStrictNativePlans) { CustomPipeline(fixture, expected_biz)); ASSERT_TRUE(plan.report.ok) << plan.report.ToJson().dump(2); } - ASSERT_TRUE(session_ctx_->GetModelManager().RegisterModel( - "entity_llm", std::make_shared(2), "v1")); + ASSERT_TRUE(RegisterTestModel(session_ctx_->GetModelManager(), "entity_llm", + std::make_shared(2), + "v1")); for (const char* name : {"ScaffoldComputeNode", "ScaffoldModelLlmNode"}) { auto doc = CustomPipeline("entity_extract", "entity_extract"); doc["pipeline"][0]["node_type"] = name; @@ -1373,8 +1377,8 @@ TEST_F(CommonNodesTest, StarterTextFunctionsFollowTheDocumentedExercise) { auto model = std::make_shared(); model->response_prefix.clear(); model->response_suffix = "\n\n"; - ASSERT_TRUE( - session_ctx_->GetModelManager().RegisterModel("entity_llm", model, "v1")); + ASSERT_TRUE(RegisterTestModel(session_ctx_->GetModelManager(), "entity_llm", + model, "v1")); auto document = CustomPipeline("entity_extract", "entity_extract"); document["pipeline"][0]["node_type"] = "ScaffoldTutorialLlmNode"; document["pipeline"][0]["config"] = {{"bind_model", "entity_llm"}}; diff --git a/tests/unit/nodes/test_llm_generate_node.cpp b/tests/unit/nodes/test_llm_generate_node.cpp index 55ed6e60..4104263a 100644 --- a/tests/unit/nodes/test_llm_generate_node.cpp +++ b/tests/unit/nodes/test_llm_generate_node.cpp @@ -16,6 +16,7 @@ #include "engine/model_interface.h" #include "nodes/model_calls.h" #include "nodes/node_error_codes.h" +#include "tests/support/model_registration.h" #include "tests/support/node_test_utils.h" namespace llm_edgeflow { @@ -78,8 +79,8 @@ class LlmGenerateNodeTest : public ::testing::Test { session_ctx_ = std::make_unique(); model_ = std::make_shared(); - ASSERT_TRUE(session_ctx_->GetModelManager().RegisterModel( - "llm_model_v1", model_, "test-v1")); + ASSERT_TRUE(RegisterTestModel(session_ctx_->GetModelManager(), + "llm_model_v1", model_, "test-v1")); } std::unique_ptr session_ctx_; std::shared_ptr model_; diff --git a/tests/unit/nodes/test_ocr_detect_node.cpp b/tests/unit/nodes/test_ocr_detect_node.cpp index ebbf0e04..184b33da 100644 --- a/tests/unit/nodes/test_ocr_detect_node.cpp +++ b/tests/unit/nodes/test_ocr_detect_node.cpp @@ -11,6 +11,7 @@ #include "core/session_context.h" #include "dev_support/inference/test_capability_models.h" #include "nodes/node_error_codes.h" +#include "tests/support/model_registration.h" #include "tests/support/node_test_utils.h" namespace llm_edgeflow { @@ -21,9 +22,9 @@ class OcrDetectNodeTest : public ::testing::Test { session_ctx_ = std::make_unique(); ocr_model_ = std::make_shared(); - ASSERT_TRUE(session_ctx_->GetModelManager().RegisterModel( - "ocr_model_v1", ocr_model_, "test-revision", "test_ocr_model", "ocr", - "test_tensor_backend")); + ASSERT_TRUE(RegisterTestModel( + session_ctx_->GetModelManager(), "ocr_model_v1", ocr_model_, + "test-revision", "test_ocr_model", "ocr", "test_tensor_backend")); } std::unique_ptr session_ctx_; std::shared_ptr ocr_model_; diff --git a/tests/unit/nodes/test_text_embedding_node.cpp b/tests/unit/nodes/test_text_embedding_node.cpp index 67be0996..7ec75b00 100644 --- a/tests/unit/nodes/test_text_embedding_node.cpp +++ b/tests/unit/nodes/test_text_embedding_node.cpp @@ -16,6 +16,7 @@ #include "core/session_context.h" #include "engine/model_interface.h" #include "nodes/node_error_codes.h" +#include "tests/support/model_registration.h" #include "tests/support/node_test_utils.h" namespace llm_edgeflow { @@ -70,8 +71,9 @@ class TextEmbeddingNodeTest : public ::testing::Test { void SetUp() override { session_ctx_ = std::make_unique(); counting_model_ = std::make_shared(); - ASSERT_TRUE(session_ctx_->GetModelManager().RegisterModel( - "embed_model_v1", counting_model_, "revision-1")); + ASSERT_TRUE(RegisterTestModel(session_ctx_->GetModelManager(), + "embed_model_v1", counting_model_, + "revision-1")); } std::unique_ptr session_ctx_; std::shared_ptr counting_model_; diff --git a/tests/unit/nodes/test_text_rerank_node.cpp b/tests/unit/nodes/test_text_rerank_node.cpp index 86fb8d45..51536dd6 100644 --- a/tests/unit/nodes/test_text_rerank_node.cpp +++ b/tests/unit/nodes/test_text_rerank_node.cpp @@ -14,6 +14,7 @@ #include "edgeflow/operator/interface.h" #include "engine/model_interface.h" #include "nodes/node_error_codes.h" +#include "tests/support/model_registration.h" #include "tests/support/node_test_utils.h" #include "tests/support/pipeline_test_utils.h" @@ -83,9 +84,9 @@ class TextRerankNodeTest : public ::testing::Test { // 注册 Fake IRerankModel fake_model_ = std::make_shared(); - ASSERT_TRUE(session_ctx_->GetModelManager().RegisterModel( - "fake_rerank_model", fake_model_, "v1", "fake_reranker", "rerank", - "mock")); + ASSERT_TRUE(RegisterTestModel(session_ctx_->GetModelManager(), + "fake_rerank_model", fake_model_, "v1", + "fake_reranker", "rerank", "mock")); } std::shared_ptr fake_model_; @@ -409,8 +410,8 @@ class TextRerankRankingTest : public ::testing::Test { void SetUp() override { operator_api::Get_LLM_EDGEFLOW_OperatorTable().Init(); mock_model_ = std::make_shared(); - session_ctx_.GetModelManager().RegisterModel("test_rerank_model", - mock_model_, "test-v1"); + RegisterTestModel(session_ctx_.GetModelManager(), "test_rerank_model", + mock_model_, "test-v1"); node_ = NodeRegistry::Instance().Create("TextRerankNode"); ASSERT_NE(node_, nullptr); } From a1fba64b92e43e90cc8ff9c873f0d90ac3651254 Mon Sep 17 00:00:00 2001 From: chamsechan Date: Sun, 4 Oct 2026 20:30:47 +0800 Subject: [PATCH 11/29] refactor(nodes): move class-style port helpers out of NodeBase BindPort, BindPorts, BindInput, BindOutput, Require and Publish had no runtime callers: AuthorNode binds through its Spec bindings. They now live in dev_support's LegacyNodeBase, used by tests that drive the NodeBase runtime directly and by the authoring benchmark, whose historical ModelBoundNode baseline is rewritten to inherit it. NodeBase keeps only the lifecycle and exception barriers. Co-Authored-By: Claude Opus 5.5 --- .../node_authoring/benchmark/README.md | 4 +- .../node_authoring/benchmark/probe.cpp | 5 +- dev_support/node_authoring/benchmark/run.py | 6 + dev_support/node_authoring/legacy_node_base.h | 103 ++++++++++++++++++ include/nodes/node_base.h | 82 -------------- plans/ARCHITECTURE_REVIEW.md | 2 +- tests/integration/demo/test_demo_runner.cpp | 5 +- tests/unit/core/test_node_base_contracts.cpp | 14 ++- 8 files changed, 127 insertions(+), 94 deletions(-) create mode 100644 dev_support/node_authoring/legacy_node_base.h diff --git a/dev_support/node_authoring/benchmark/README.md b/dev_support/node_authoring/benchmark/README.md index 38485864..b2ce5c82 100644 --- a/dev_support/node_authoring/benchmark/README.md +++ b/dev_support/node_authoring/benchmark/README.md @@ -12,7 +12,9 @@ python3 dev_support/node_authoring/benchmark/run.py \ - 旧 starter 所需的两个已退役辅助头文件固定从 Git `87a28b7` 提取到工作目录的 `baseline_include/nodes`,仅作为探针私有比较基线;当前 SDK 不恢复旧作者接口。 `summary.json` 的 `baseline_helper_revision` 记录该版本。 -- 旧 Map 为等价显式 `NodeBase` 透传实现的重建,不冒充历史生产 Node。 +- 旧基类依赖的类写法端口辅助函数已移出 `NodeBase`,由 + [legacy_node_base.h](../legacy_node_base.h) 的 `LegacyNodeBase` 提供;脚本提取旧头文件时改为继承它。 +- 旧 Map 为等价显式 `LegacyNodeBase` 透传实现的重建,不冒充历史生产 Node。 - 每批 32 条、每条 1024 字节,固定 Echo mock;预热 100 次,每个进程测 10000 次,独立重复 5 次。 - 测量 `Process` 的线程 CPU 时间及 C++ `new/new[]` 请求次数、字节数;不包含输入发布和输出断言, 也不代表全部 malloc、存活堆或峰值 RSS。每次校验文本及 req/sub,记录实际模型调用次数。 diff --git a/dev_support/node_authoring/benchmark/probe.cpp b/dev_support/node_authoring/benchmark/probe.cpp index 64b5cfc2..10ce74b5 100644 --- a/dev_support/node_authoring/benchmark/probe.cpp +++ b/dev_support/node_authoring/benchmark/probe.cpp @@ -8,6 +8,7 @@ #include "baseline_starter.cpp" #include "contracts/config_schema_validation.h" #include "current_starter.cpp" +#include "dev_support/node_authoring/legacy_node_base.h" #include "nodes/authoring.h" namespace { @@ -54,9 +55,9 @@ class EchoModel final : public ILlmModel { return 0; } }; -class ExplicitMap final : public NodeBase { +class ExplicitMap final : public LegacyNodeBase { public: - ExplicitMap() : NodeBase("ExplicitMap") {} + ExplicitMap() : LegacyNodeBase("ExplicitMap") {} protected: bool InitNode(const NodeInitContext& init, const nlohmann::json&, diff --git a/dev_support/node_authoring/benchmark/run.py b/dev_support/node_authoring/benchmark/run.py index a785538c..9b24c6fe 100644 --- a/dev_support/node_authoring/benchmark/run.py +++ b/dev_support/node_authoring/benchmark/run.py @@ -49,6 +49,12 @@ def run(command, **kwargs): header = run(["git", "-C", str(root), "show", BASELINE_HELPER_REVISION + ":include/nodes/" + name], capture_output=True).stdout + # The retired base relied on class-style port helpers that NodeBase no + # longer carries; LegacyNodeBase keeps them for this comparison only. + header = header.replace('#include "nodes/node_base.h"', + '#include "dev_support/node_authoring/legacy_node_base.h"') + header = header.replace("public NodeBase", "public LegacyNodeBase") + header = header.replace(": NodeBase(", ": LegacyNodeBase(") (baseline_headers / name).write_text(header) old = run(["git", "-C", str(root), "show", diff --git a/dev_support/node_authoring/legacy_node_base.h b/dev_support/node_authoring/legacy_node_base.h new file mode 100644 index 00000000..e7e6752e --- /dev/null +++ b/dev_support/node_authoring/legacy_node_base.h @@ -0,0 +1,103 @@ +#pragma once + +#include +#include +#include +#include + +#include "nodes/node_base.h" + +namespace llm_edgeflow { + +// Retired class-style port helpers. Tests that drive the NodeBase runtime +// directly and the authoring benchmark's historical baseline use them; +// production Nodes use Specs, whose AuthorNode owns port binding. +class LegacyNodeBase : public NodeBase { + public: + using NodeBase::NodeBase; + + protected: + template + void BindPort(const NodeInitContext& init_ctx, BoundInput& in_port) const { + const auto result = detail::ResolvePortBinding( + *init_ctx.plan, PortDirection::kInput, in_port); + if (result.status != detail::PortBindingStatus::kUnbound) { + if (result.status == detail::PortBindingStatus::kTypeMismatch) { + throw std::invalid_argument( + "Input port TypeId mismatch for " + in_port.LogicalName() + + " (expected: " + in_port.TypeId() + + ", bound: " + result.binding->type_id + ")"); + } + } else { + in_port.Unbind(); + } + } + + template + void BindPort(const NodeInitContext& init_ctx, + BoundOutput& out_port) const { + const auto result = detail::ResolvePortBinding( + *init_ctx.plan, PortDirection::kOutput, out_port); + if (result.status == detail::PortBindingStatus::kUnbound) { + throw std::invalid_argument("Output port is unbound in plan: " + + out_port.LogicalName()); + } + if (result.status == detail::PortBindingStatus::kTypeMismatch) { + throw std::invalid_argument("Output port TypeId mismatch for " + + out_port.LogicalName() + + " (expected: " + out_port.TypeId() + + ", bound: " + result.binding->type_id + ")"); + } + } + + template + BoundInput BindInput(const NodeInitContext& init_ctx, + std::string logical_name) const { + BoundInput port(std::move(logical_name)); + BindPort(init_ctx, port); + return port; + } + + // Preserve BindPort's validation and left-to-right error ordering. + template + void BindPorts(const NodeInitContext& init_ctx, Ports&... ports) const { + (BindPort(init_ctx, ports), ...); + } + + template + BoundOutput BindOutput(const NodeInitContext& init_ctx, + std::string logical_name) const { + BoundOutput port(std::move(logical_name)); + BindPort(init_ctx, port); + return port; + } + + template + const T* Require(AlgContext& ctx, const BlackboardKey& key, int error_code, + std::string_view semantic = {}) const { + const bool key_exists = ctx.Has(key); + const T* val = ctx.Read(key); + if (!val) { + std::string msg = Name() + + (key_exists ? ": type mismatch for input key '" + : ": missing required input key '") + + key.name + "' (expected type: " + key.type_id + ")"; + if (!semantic.empty()) { + msg += " for " + std::string(semantic); + } + ctx.SetError(error_code, std::move(msg)); + return nullptr; + } + return val; + } + + template + void Publish(AlgContext& ctx, const BlackboardKey& key, T value) const { + if (!ctx.Publish(key, std::move(value))) { + throw std::logic_error("Duplicate output publication for key '" + + std::string(key.name) + "'"); + } + } +}; + +} // namespace llm_edgeflow diff --git a/include/nodes/node_base.h b/include/nodes/node_base.h index dbc8c850..08575e7a 100644 --- a/include/nodes/node_base.h +++ b/include/nodes/node_base.h @@ -242,88 +242,6 @@ class NodeBase : public INode { return NodeControlResult::Unsupported(); } - template - void BindPort(const NodeInitContext& init_ctx, BoundInput& in_port) const { - const auto result = detail::ResolvePortBinding( - *init_ctx.plan, PortDirection::kInput, in_port); - if (result.status != detail::PortBindingStatus::kUnbound) { - if (result.status == detail::PortBindingStatus::kTypeMismatch) { - throw std::invalid_argument( - "Input port TypeId mismatch for " + in_port.LogicalName() + - " (expected: " + in_port.TypeId() + - ", bound: " + result.binding->type_id + ")"); - } - } else { - in_port.Unbind(); - } - } - - template - void BindPort(const NodeInitContext& init_ctx, - BoundOutput& out_port) const { - const auto result = detail::ResolvePortBinding( - *init_ctx.plan, PortDirection::kOutput, out_port); - if (result.status == detail::PortBindingStatus::kUnbound) { - throw std::invalid_argument("Output port is unbound in plan: " + - out_port.LogicalName()); - } - if (result.status == detail::PortBindingStatus::kTypeMismatch) { - throw std::invalid_argument("Output port TypeId mismatch for " + - out_port.LogicalName() + - " (expected: " + out_port.TypeId() + - ", bound: " + result.binding->type_id + ")"); - } - } - - template - BoundInput BindInput(const NodeInitContext& init_ctx, - std::string logical_name) const { - BoundInput port(std::move(logical_name)); - BindPort(init_ctx, port); - return port; - } - - // Preserve BindPort's validation and left-to-right error ordering. - template - void BindPorts(const NodeInitContext& init_ctx, Ports&... ports) const { - (BindPort(init_ctx, ports), ...); - } - - template - BoundOutput BindOutput(const NodeInitContext& init_ctx, - std::string logical_name) const { - BoundOutput port(std::move(logical_name)); - BindPort(init_ctx, port); - return port; - } - - template - const T* Require(AlgContext& ctx, const BlackboardKey& key, int error_code, - std::string_view semantic = {}) const { - const bool key_exists = ctx.Has(key); - const T* val = ctx.Read(key); - if (!val) { - std::string msg = node_name_ + - (key_exists ? ": type mismatch for input key '" - : ": missing required input key '") + - key.name + "' (expected type: " + key.type_id + ")"; - if (!semantic.empty()) { - msg += " for " + std::string(semantic); - } - ctx.SetError(error_code, std::move(msg)); - return nullptr; - } - return val; - } - - template - void Publish(AlgContext& ctx, const BlackboardKey& key, T value) const { - if (!ctx.Publish(key, std::move(value))) { - throw std::logic_error("Duplicate output publication for key '" + - std::string(key.name) + "'"); - } - } - int Fail(AlgContext& ctx, int error_code, std::string_view message) const noexcept { try { diff --git a/plans/ARCHITECTURE_REVIEW.md b/plans/ARCHITECTURE_REVIEW.md index 787fe4d8..cf5a01a8 100644 --- a/plans/ARCHITECTURE_REVIEW.md +++ b/plans/ARCHITECTURE_REVIEW.md @@ -317,7 +317,7 @@ Definition、Catalog、Validator、节点运行时及工具的解释须一致。 | B2 | 输入/输出端口绑定共用实现 | 第三节·接入适配层 | 已完成:`PortBindings<方向>` 共用查找,输入/输出仍是不可互换的类型 | | B3 | 输入限额统一规则来源 | 第三节·接入适配层 | 已完成:限额全部来自 `biz_input` 常量;`InputLimits` 不再伪装成部署配置,两层检查保留 | | B4 | `RuntimeOptions` 死字段与测试便利接口 | 第三节·流程编排层 | 已完成:删除只写不读的 `biz_type`、`depth_num`、`biz_name`;单模型注册移到测试支持 `RegisterTestModel`。读取接口、`UpdateModelRevision` 与 `SetResource`/`GetResource` 保留,它们证明原子注册、按版本区分缓存和资源类型检查 | -| B5 | `NodeBase` 旧写法辅助函数收窄 | 第三节·能力节点层 | 待评估 | +| B5 | `NodeBase` 旧写法辅助函数收窄 | 第三节·能力节点层 | 已完成:`BindPort`/`Require`/`Publish` 等移到 `dev_support` 的 `LegacyNodeBase`,供直接驱动 NodeBase 的测试与作者基准的历史基线使用;`NodeBase` 只保留生命周期与异常屏障 | | C1 | Map/Batch 生命周期合并 | 第三节·能力节点层 | 待评估 | | C2 | 端口声明与实例状态分离 | 第三节·能力节点层 | 待评估 | | C3 | 模型调用类共用持有实现 | 第三节·能力节点层 | 待评估 | diff --git a/tests/integration/demo/test_demo_runner.cpp b/tests/integration/demo/test_demo_runner.cpp index b7dcafad..d36c3732 100644 --- a/tests/integration/demo/test_demo_runner.cpp +++ b/tests/integration/demo/test_demo_runner.cpp @@ -14,6 +14,7 @@ #include "demo/common/demo_registry.h" #include "demo/common/operator_runner.h" #include "demo/common/result_writer.h" +#include "dev_support/node_authoring/legacy_node_base.h" #include "edgeflow/log.h" #include "edgeflow/operator/interface.h" #include "engine/backend_registry.h" @@ -27,11 +28,11 @@ using namespace llm_edgeflow::operator_api; namespace llm_edgeflow::test { namespace { -class TestDemoStatusNode final : public NodeBase { +class TestDemoStatusNode final : public LegacyNodeBase { public: inline static constexpr char kNodeType[] = "TestDemoStatusNode"; TestDemoStatusNode() - : NodeBase(kNodeType), input_("text"), output_("matches") {} + : LegacyNodeBase(kNodeType), input_("text"), output_("matches") {} protected: bool InitNode(const NodeInitContext& init, const nlohmann::json&, diff --git a/tests/unit/core/test_node_base_contracts.cpp b/tests/unit/core/test_node_base_contracts.cpp index 20ab2f63..4ca08484 100644 --- a/tests/unit/core/test_node_base_contracts.cpp +++ b/tests/unit/core/test_node_base_contracts.cpp @@ -11,6 +11,7 @@ #include "core/node_registry.h" #include "core/pipeline_catalog.h" #include "core/session_context.h" +#include "dev_support/node_authoring/legacy_node_base.h" #include "engine/model_interface.h" #include "nodes/authoring.h" #include "nodes/node_base.h" @@ -243,10 +244,10 @@ inline constexpr BlackboardKey kTestInputKey{"test_input_key", inline constexpr BlackboardKey kTestOutputKey{"test_output_key", "string"}; -class HelperTestNode : public NodeBase { +class HelperTestNode : public LegacyNodeBase { public: inline static constexpr char kNodeType[] = "HelperTestNode"; - HelperTestNode() : NodeBase(kNodeType) {} + HelperTestNode() : LegacyNodeBase(kNodeType) {} protected: int ProcessNode(AlgContext& ctx) override { @@ -307,9 +308,10 @@ TEST(NodeBaseContractsTest, RequireAndPublishHelpers) { } } -class BoundPortProbeNode : public NodeBase { +class BoundPortProbeNode : public LegacyNodeBase { public: - BoundPortProbeNode() : NodeBase("BoundPortProbeNode"), input_("input") {} + BoundPortProbeNode() + : LegacyNodeBase("BoundPortProbeNode"), input_("input") {} protected: bool InitNode(const NodeInitContext& init_ctx, const nlohmann::json&, @@ -337,7 +339,7 @@ TEST(NodeBaseContractsTest, BindingRejectsDefinitionRuntimeTypeDrift) { EXPECT_FALSE(node.Init(init_ctx)); } -class MultiPortProbeNode : public NodeBase { +class MultiPortProbeNode : public LegacyNodeBase { public: inline static constexpr auto kFirst = MakeBlackboardKey("first"); inline static constexpr auto kOptional = @@ -346,7 +348,7 @@ class MultiPortProbeNode : public NodeBase { MakeBlackboardKey("combined"); inline static constexpr auto kEcho = MakeBlackboardKey("echo"); - MultiPortProbeNode() : NodeBase("MultiPortProbeNode") {} + MultiPortProbeNode() : LegacyNodeBase("MultiPortProbeNode") {} bool OptionalIsBound() const { return optional_.IsBound(); } bool CombinedIsBound() const { return combined_.IsBound(); } bool EchoIsBound() const { return echo_.IsBound(); } From 3a7c07c5dcc45135e8020eda7e48a04102e03c09 Mon Sep 17 00:00:00 2001 From: chamsechan Date: Sun, 4 Oct 2026 20:42:37 +0800 Subject: [PATCH 12/29] refactor(nodes): run Map nodes on the Batch AuthorNode runtime AuthorNode duplicated port binding, parameter parsing, snapshots and Control dispatch. It now converts the MapSpec into a Batch runtime spec with one required anchor input, one preserved output and a per-item loop that names the node and failing item; the Definition still comes from MapSpec, so the author API and Catalog are unchanged. Batch binding diagnostics now report expected and bound types like Map did. A new test, passing on both implementations, pins the item failure, empty-message fallback and empty-batch behavior. Co-Authored-By: Claude Opus 5.5 --- include/nodes/function_node.h | 245 ++++++++---------------- plans/ARCHITECTURE_REVIEW.md | 2 +- tests/unit/nodes/test_function_node.cpp | 63 +++++- 3 files changed, 141 insertions(+), 169 deletions(-) diff --git a/include/nodes/function_node.h b/include/nodes/function_node.h index a6f84ee1..664fa9ff 100644 --- a/include/nodes/function_node.h +++ b/include/nodes/function_node.h @@ -462,12 +462,14 @@ class ConcreteInputPortBinding final : public InputPortBinding { *init_ctx.plan, PortDirection::kInput, in_port_); if (result.status != detail::PortBindingStatus::kUnbound) { if (result.status == detail::PortBindingStatus::kTypeMismatch) { - return init_ctx.Fail("Input port type mismatch for '" + name_ + "'"); + return init_ctx.Fail("Input port type mismatch for '" + name_ + + "' (expected: " + in_port_.TypeId() + + ", bound: " + result.binding->type_id + ")"); } } else { if (required_) { return init_ctx.Fail("Required input port '" + name_ + - "' is unbound in plan"); + "' has no binding in plan"); } in_port_.Unbind(); } @@ -728,7 +730,8 @@ class TypedOutputBinding final : public OutputBinding { "' has no binding in plan"); if (result.status == detail::PortBindingStatus::kTypeMismatch) return init.Fail("Output port type mismatch for '" + port_.LogicalName() + - "'"); + "' (expected: " + port_.TypeId() + + ", bound: " + result.binding->type_id + ")"); return true; } std::optional Validate( @@ -1373,166 +1376,6 @@ inline auto MakeBatchSpec(InputsOf inputs, template class AuthorNode; -// MapSpec Specialization -template -class AuthorNode> - : public NodeBase { - public: - using SpecType = MapSpec; - - AuthorNode(std::string node_name, SpecType spec) - : NodeBase(std::move(node_name)), - spec_(std::move(spec)), - in_port_(spec_.InputName()), - out_port_(spec_.OutputName()) {} - - protected: - bool InitNode(const NodeInitContext& init_ctx, const nlohmann::json& config, - SessionContext& session_ctx) override { - (void)session_ctx; - const auto in_result = detail::ResolvePortBinding( - *init_ctx.plan, PortDirection::kInput, in_port_); - if (in_result.status == detail::PortBindingStatus::kUnbound) { - return init_ctx.Fail("Required input port '" + spec_.InputName() + - "' has no binding in plan"); - } - if (in_result.status == detail::PortBindingStatus::kTypeMismatch) { - return init_ctx.Fail("Input port type mismatch for '" + - spec_.InputName() + - "' (expected: " + in_port_.TypeId() + - ", bound: " + in_result.binding->type_id + ")"); - } - - const auto out_result = detail::ResolvePortBinding( - *init_ctx.plan, PortDirection::kOutput, out_port_); - if (out_result.status == detail::PortBindingStatus::kUnbound) { - return init_ctx.Fail("Output port '" + spec_.OutputName() + - "' has no binding in plan"); - } - if (out_result.status == detail::PortBindingStatus::kTypeMismatch) { - return init_ctx.Fail("Output port type mismatch for '" + - spec_.OutputName() + - "' (expected: " + out_port_.TypeId() + - ", bound: " + out_result.binding->type_id + ")"); - } - - const auto& normalized = config; - - binding_facts_ = MakeBindingFacts(init_ctx); - - std::string err; - auto parsed = spec_.ParametersSpec().ParseNormalized(normalized, - binding_facts_, &err); - if (!parsed) { - return init_ctx.Fail(err.empty() ? "Invalid node configuration" : err); - } - if (spec_.HasControls()) { - snapshot_.Initialize(std::move(*parsed)); - } else { - parameters_ = std::move(*parsed); - } - return true; - } - - NodeControlResult ControlNode(int cmd, - const std::string& json_param) override { - if (!spec_.HasControls()) { - return NodeControlResult::Unsupported(); - } - if constexpr (std::is_copy_constructible_v< - typename SpecType::ParametersType>) { - for (const auto& command : spec_.ControlCommands()) { - if (command.Id() == cmd) { - return command.Execute(spec_.ParametersSpec(), json_param, - binding_facts_, snapshot_); - } - } - } - return NodeControlResult::Unsupported(); - } - - int ProcessNode(AlgContext& req_ctx) override { - using OutputBatch = typename SpecType::OutputBatch; - - const auto* inputs = - in_port_.Require(req_ctx, node_error::author_node::kMissingInput); - if (!inputs) { - return node_error::author_node::kMissingInput; - } - - if (inputs->empty()) { - out_port_.Set(req_ctx, OutputBatch{}); - return 0; - } - - std::shared_ptr snapshot_guard; - const typename SpecType::ParametersType* params_ptr = nullptr; - if (spec_.HasControls()) { - snapshot_guard = snapshot_.Read(); - if (!snapshot_guard) { - return this->Fail(req_ctx, node_error::author_node::kInternalError, - this->Name() + ": snapshot not initialized"); - } - params_ptr = snapshot_guard.get(); - } else { - params_ptr = ¶meters_; - } - const auto& params = *params_ptr; - - OutputBatch outputs; - outputs.reserve(inputs->size()); - - for (const auto& item : *inputs) { - if constexpr (SpecType::kReturnsNodeResult) { - auto res = detail::InvokeMapItem(spec_.Function(), item.data, params); - if (!res.ok()) { - auto failure = std::move(res).ExtractFailure(); - if (!failure.batch_detail.has_value()) { - failure.batch_detail = BatchFailureDetail{ - this->Name(), BatchFailureReason::kCallbackFailed, - TraceableItemKey{item.req_id, item.sub_id}}; - } - int code = failure.cause_code != 0 - ? failure.cause_code - : node_error::author_node::kBusinessError; - return this->Fail( - req_ctx, code, - failure.FormatDiagnostic(this->Name() + " map function failed")); - } - outputs.emplace_back(item.req_id, item.sub_id, std::move(res).value()); - } else { - outputs.emplace_back( - item.req_id, item.sub_id, - detail::InvokeMapItem(spec_.Function(), item.data, params)); - } - } - - const auto alignment = - ValidatePreservedTraceableAlignment(*inputs, outputs); - if (alignment.error == TraceableAlignmentError::kCountMismatch) { - return this->Fail(req_ctx, node_error::author_node::kOutputCountMismatch, - this->Name() + " output count mismatch"); - } - if (alignment.error == TraceableAlignmentError::kProvenanceMismatch) { - return this->Fail(req_ctx, - node_error::author_node::kOutputProvenanceMismatch, - this->Name() + " output provenance mismatch"); - } - - out_port_.Set(req_ctx, std::move(outputs)); - return 0; - } - - private: - SpecType spec_; - BoundInput in_port_; - BoundOutput out_port_; - typename SpecType::ParametersType parameters_{}; - ConfigurationSnapshot snapshot_; - BindingFacts binding_facts_; -}; - // BatchSpec Specialization template @@ -1667,6 +1510,82 @@ class AuthorNode> BindingFacts binding_facts_; }; +namespace detail { + +template +struct MapInputs { + const InputBatchT* items = nullptr; +}; + +// Runs a Map on the Batch runtime: one required anchor input, one preserved +// output and a per-item loop that names the failing item. +template +auto MakeMapRuntimeSpec(std::string node_name, const SpecT& map) { + using InputBatch = typename SpecT::InputBatch; + using OutputBatch = typename SpecT::OutputBatch; + using ParamsT = typename SpecT::ParametersType; + using Inputs = MapInputs; + auto run = [fn = map.Function(), name = std::move(node_name)]( + const Inputs& inputs, + const ParamsT& params) -> NodeResult { + OutputBatch outputs; + outputs.reserve(inputs.items->size()); + for (const auto& item : *inputs.items) { + if constexpr (SpecT::kReturnsNodeResult) { + auto res = InvokeMapItem(fn, item.data, params); + if (!res.ok()) { + auto failure = std::move(res).ExtractFailure(); + if (!failure.batch_detail.has_value()) { + failure.batch_detail = + BatchFailureDetail{name, BatchFailureReason::kCallbackFailed, + TraceableItemKey{item.req_id, item.sub_id}}; + } + if (failure.message.empty()) { + failure.message = name + " map function failed"; + } + return NodeResult::Failure(std::move(failure)); + } + outputs.emplace_back(item.req_id, item.sub_id, std::move(res).value()); + } else { + outputs.emplace_back(item.req_id, item.sub_id, + InvokeMapItem(fn, item.data, params)); + } + } + return NodeResult::Success(std::move(outputs)); + }; + auto batch = MakeBatchSpec( + InputsOf({Required(map.InputName(), &Inputs::items)}), + PreservedOutput(map.OutputName(), map.InputName()), + map.ParametersSpec(), std::move(run)); + if constexpr (std::is_copy_constructible_v) { + if (map.HasControls()) { + return std::move(batch).WithControls(map.ControlCommands()); + } + } + return batch; +} + +} // namespace detail + +// MapSpec Specialization: the Definition comes from MapSpec; execution, +// parameters and Control share the Batch runtime. +template +class AuthorNode> + : public AuthorNode(), + std::declval< + const MapSpec&>()))> { + public: + using SpecType = MapSpec; + using RuntimeSpec = decltype(detail::MakeMapRuntimeSpec( + std::declval(), std::declval())); + + AuthorNode(std::string node_name, const SpecType& spec) + : AuthorNode(node_name, + detail::MakeMapRuntimeSpec(node_name, spec)) {} +}; + // --------------------------------------------------------------------------- // LLM Text Shortcut Spec Factory // --------------------------------------------------------------------------- diff --git a/plans/ARCHITECTURE_REVIEW.md b/plans/ARCHITECTURE_REVIEW.md index cf5a01a8..3d18e4d4 100644 --- a/plans/ARCHITECTURE_REVIEW.md +++ b/plans/ARCHITECTURE_REVIEW.md @@ -318,7 +318,7 @@ Definition、Catalog、Validator、节点运行时及工具的解释须一致。 | B3 | 输入限额统一规则来源 | 第三节·接入适配层 | 已完成:限额全部来自 `biz_input` 常量;`InputLimits` 不再伪装成部署配置,两层检查保留 | | B4 | `RuntimeOptions` 死字段与测试便利接口 | 第三节·流程编排层 | 已完成:删除只写不读的 `biz_type`、`depth_num`、`biz_name`;单模型注册移到测试支持 `RegisterTestModel`。读取接口、`UpdateModelRevision` 与 `SetResource`/`GetResource` 保留,它们证明原子注册、按版本区分缓存和资源类型检查 | | B5 | `NodeBase` 旧写法辅助函数收窄 | 第三节·能力节点层 | 已完成:`BindPort`/`Require`/`Publish` 等移到 `dev_support` 的 `LegacyNodeBase`,供直接驱动 NodeBase 的测试与作者基准的历史基线使用;`NodeBase` 只保留生命周期与异常屏障 | -| C1 | Map/Batch 生命周期合并 | 第三节·能力节点层 | 待评估 | +| C1 | Map/Batch 生命周期合并 | 第三节·能力节点层 | 已完成:`AuthorNode` 改为 Batch 运行时的包装,作者写法与 Definition 不变;逐项失败诊断、空批和绑定诊断有测试覆盖,Batch 绑定诊断补充期望/实际类型 | | C2 | 端口声明与实例状态分离 | 第三节·能力节点层 | 待评估 | | C3 | 模型调用类共用持有实现 | 第三节·能力节点层 | 待评估 | | C4 | 修复建议生成移到工具层 | 第三节·流程编排层 | 待评估 | diff --git a/tests/unit/nodes/test_function_node.cpp b/tests/unit/nodes/test_function_node.cpp index 56cdd651..a883223c 100644 --- a/tests/unit/nodes/test_function_node.cpp +++ b/tests/unit/nodes/test_function_node.cpp @@ -159,6 +159,20 @@ auto FailableSpec() { REGISTER_FUNCTION_NODE(FailableMapNode, FailableSpec()); +// Fails without its own message, so the framework names the Map function. +auto SilentFailureSpec() { + return MakeMapSpec(Input("input"), Output("output"), + [](const std::string& in) { + if (in == "FAIL") { + return NodeResult::Failure( + NodeErrorKind::kBusinessError, ""); + } + return NodeResult::Success(in); + }); +} + +REGISTER_FUNCTION_NODE(SilentFailureMapNode, SilentFailureSpec()); + // Node without parameters returning plain string std::string UpperFn(const std::string& in) { std::string out = in; @@ -934,6 +948,40 @@ TEST(FunctionNodeTest, IntermediateFailureProducesNoOutput) { EXPECT_EQ(out, nullptr); } +TEST(FunctionNodeTest, MapItemFailureNamesNodeAndItem) { + NodeHarness harness("FailableMapNode"); + harness.TextInput("input", {"ok1", "FAIL", "ok3"}); + auto result = harness.Run(); + ASSERT_FALSE(result.ok()); + EXPECT_EQ(result.process_code(), -9999); + EXPECT_NE(result.diagnostic().find("Forced failure on keyword FAIL"), + std::string::npos) + << result.diagnostic(); + EXPECT_NE(result.diagnostic().find("FailableMapNode"), std::string::npos) + << result.diagnostic(); + EXPECT_NE(result.diagnostic().find("sub_id=0"), std::string::npos) + << result.diagnostic(); + + NodeHarness silent("SilentFailureMapNode"); + silent.TextInput("input", {"ok", "FAIL"}); + auto silent_result = silent.Run(); + ASSERT_FALSE(silent_result.ok()); + EXPECT_EQ(silent_result.process_code(), + node_error::author_node::kBusinessError); + EXPECT_NE(silent_result.diagnostic().find( + "SilentFailureMapNode map function failed"), + std::string::npos) + << silent_result.diagnostic(); + EXPECT_EQ(silent_result.Output("output"), nullptr); + + NodeHarness empty("SilentFailureMapNode"); + empty.TextInput("input", {}); + auto empty_result = empty.Run(); + ASSERT_TRUE(empty_result.ok()) << empty_result.diagnostic(); + ASSERT_NE(empty_result.Output("output"), nullptr); + EXPECT_TRUE(empty_result.TextValues("output").empty()); +} + // A5: 重复 output key 保留旧值且失败;最终业务失败不发布新输出 TEST(FunctionNodeTest, DuplicateOutputKeyFailsAndKeepsExistingValue) { auto node = NodeRegistry::Instance().Create("UpperMapNode"); @@ -1558,19 +1606,24 @@ TEST(FunctionNodeTest, PlannedPortBindingsPreserveAuthorDiagnosticsAndOrder) { "Output port type mismatch for 'output' (expected: TextBatch, bound: " "integer)"}, {"BindingTestNode", "texts", 0, - "Required input port 'texts' is unbound in plan"}, + "Required input port 'texts' has no binding in plan"}, {"BindingTestNode", "texts", 1, - "Required input port 'texts' is unbound in plan"}, - {"BindingTestNode", "texts", 2, "Input port type mismatch for 'texts'"}, + "Required input port 'texts' has no binding in plan"}, + {"BindingTestNode", "texts", 2, + "Input port type mismatch for 'texts' (expected: TextBatch, bound: " + "integer)"}, {"BindingTestNode", "mask", 0, ""}, {"BindingTestNode", "mask", 1, ""}, - {"BindingTestNode", "mask", 2, "Input port type mismatch for 'mask'"}, + {"BindingTestNode", "mask", 2, + "Input port type mismatch for 'mask' (expected: TextBatch, bound: " + "integer)"}, {"BindingTestNode", "output", 0, "Output port 'output' has no binding in plan"}, {"BindingTestNode", "output", 1, "Output port 'output' has no binding in plan"}, {"BindingTestNode", "output", 2, - "Output port type mismatch for 'output'"}, + "Output port type mismatch for 'output' (expected: TextBatch, bound: " + "integer)"}, }; for (const auto& test : cases) { SCOPED_TRACE(test.node); From 59f146fb433af7d1715cb7ee72f98440bec01658 Mon Sep 17 00:00:00 2001 From: chamsechan Date: Sun, 4 Oct 2026 20:43:08 +0800 Subject: [PATCH 13/29] docs(plans): record why port declaration/state separation is not adopted Each Node instance evaluates its Spec afresh, models and parameters already live on AuthorNode, and the only per-instance binding state is the resolved Blackboard key, so splitting declarations would add a parallel key table without removing type erasure or copies. Co-Authored-By: Claude Opus 5.5 --- plans/ARCHITECTURE_REVIEW.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plans/ARCHITECTURE_REVIEW.md b/plans/ARCHITECTURE_REVIEW.md index 3d18e4d4..0d8bc226 100644 --- a/plans/ARCHITECTURE_REVIEW.md +++ b/plans/ARCHITECTURE_REVIEW.md @@ -319,7 +319,7 @@ Definition、Catalog、Validator、节点运行时及工具的解释须一致。 | B4 | `RuntimeOptions` 死字段与测试便利接口 | 第三节·流程编排层 | 已完成:删除只写不读的 `biz_type`、`depth_num`、`biz_name`;单模型注册移到测试支持 `RegisterTestModel`。读取接口、`UpdateModelRevision` 与 `SetResource`/`GetResource` 保留,它们证明原子注册、按版本区分缓存和资源类型检查 | | B5 | `NodeBase` 旧写法辅助函数收窄 | 第三节·能力节点层 | 已完成:`BindPort`/`Require`/`Publish` 等移到 `dev_support` 的 `LegacyNodeBase`,供直接驱动 NodeBase 的测试与作者基准的历史基线使用;`NodeBase` 只保留生命周期与异常屏障 | | C1 | Map/Batch 生命周期合并 | 第三节·能力节点层 | 已完成:`AuthorNode` 改为 Batch 运行时的包装,作者写法与 Definition 不变;逐项失败诊断、空批和绑定诊断有测试覆盖,Batch 绑定诊断补充期望/实际类型 | -| C2 | 端口声明与实例状态分离 | 第三节·能力节点层 | 待评估 | +| C2 | 端口声明与实例状态分离 | 第三节·能力节点层 | 不采纳:`REGISTER_FUNCTION_NODE` 每次构造实例都重新求值 Spec,不存在跨实例复制;模型与参数已在 `AuthorNode` 上,绑定内的实例状态只剩解析后的 key。拆出 key 需要为每类绑定增加并行的实例表并改动全部绑定接口,类型擦除仍然需要。Spec 需要跨实例共享或创建成本成为瓶颈时再设计 | | C3 | 模型调用类共用持有实现 | 第三节·能力节点层 | 待评估 | | C4 | 修复建议生成移到工具层 | 第三节·流程编排层 | 待评估 | | C5 | 业务审计与图校验分离 | 第三节·流程编排层 | 待评估 | From 6b62002cf1b74559a4306f259502cdd8ff3a4737 Mon Sep 17 00:00:00 2001 From: chamsechan Date: Sun, 4 Oct 2026 20:49:29 +0800 Subject: [PATCH 14/29] refactor(nodes): share ownership of model capability calls The five capability calls repeated the same model ownership, move-only rule, slot identity and accessors. They now derive from detail::ModelCallBase and keep only their named capability method (Generate, Embed, Transcribe, Recognize, Score). A contract test pins the shared move-only semantics and default slot names. Co-Authored-By: Claude Opus 5.5 --- include/nodes/model_calls.h | 175 ++++++++++-------------- plans/ARCHITECTURE_REVIEW.md | 2 +- tests/unit/nodes/test_function_node.cpp | 24 ++++ 3 files changed, 96 insertions(+), 105 deletions(-) diff --git a/include/nodes/model_calls.h b/include/nodes/model_calls.h index 031317e8..68fbf25c 100644 --- a/include/nodes/model_calls.h +++ b/include/nodes/model_calls.h @@ -64,175 +64,142 @@ NodeResult InvokeAlignedModel( } // namespace detail -class LlmCall { +namespace detail { + +// Owns one bound model capability and its slot identity. Calls are move-only, +// so each Node instance keeps exclusive ownership of its bindings. +template +class ModelCallBase { + public: + using ModelType = ModelT; + + const std::string& SlotName() const noexcept { return slot_name_; } + const std::string& ModelId() const noexcept { return model_id_; } + bool IsBound() const noexcept { return model_ != nullptr; } + + protected: + ModelCallBase() = default; + ModelCallBase(std::shared_ptr model, std::string slot_name, + std::string model_id) + : model_(std::move(model)), + slot_name_(std::move(slot_name)), + model_id_(std::move(model_id)) {} + ModelCallBase(const ModelCallBase&) = delete; + ModelCallBase& operator=(const ModelCallBase&) = delete; + ModelCallBase(ModelCallBase&&) noexcept = default; + ModelCallBase& operator=(ModelCallBase&&) noexcept = default; + ~ModelCallBase() = default; + + template + NodeResult Invoke(const InputBatchT& inputs, + const char* model_name, const char* operation, + InvokeT invoke) const { + return InvokeAlignedModel( + inputs, model_, model_name, operation, slot_name_, std::move(invoke)); + } + + private: + std::shared_ptr model_; + std::string slot_name_; + std::string model_id_; +}; + +} // namespace detail + +class LlmCall : public detail::ModelCallBase { public: - using ModelType = ILlmModel; LlmCall() = default; explicit LlmCall(std::shared_ptr model, std::string slot_name = "generator", std::string model_id = {}) - : model_(std::move(model)), - slot_name_(std::move(slot_name)), - model_id_(std::move(model_id)) {} - LlmCall(const LlmCall&) = delete; - LlmCall& operator=(const LlmCall&) = delete; - LlmCall(LlmCall&&) noexcept = default; - LlmCall& operator=(LlmCall&&) noexcept = default; + : ModelCallBase(std::move(model), std::move(slot_name), + std::move(model_id)) {} NodeResult Generate( const TextBatch& inputs, const GenerateOptions& options = GenerateOptions{}) const { - return detail::InvokeAlignedModel( - inputs, model_, "LLM", "generate", slot_name_, + return Invoke( + inputs, "LLM", "generate", [&](ModelType& model, TextBatch* outputs, std::string* diagnostic) { return model.Generate(inputs, options, outputs, diagnostic); }); } - - const std::string& SlotName() const noexcept { return slot_name_; } - const std::string& ModelId() const noexcept { return model_id_; } - bool IsBound() const noexcept { return model_ != nullptr; } - - private: - std::shared_ptr model_; - std::string slot_name_; - std::string model_id_; }; -class EmbeddingCall { +class EmbeddingCall : public detail::ModelCallBase { public: - using ModelType = IEmbeddingModel; EmbeddingCall() = default; explicit EmbeddingCall(std::shared_ptr model, std::string slot_name = "encoder", std::string model_id = {}) - : model_(std::move(model)), - slot_name_(std::move(slot_name)), - model_id_(std::move(model_id)) {} - EmbeddingCall(const EmbeddingCall&) = delete; - EmbeddingCall& operator=(const EmbeddingCall&) = delete; - EmbeddingCall(EmbeddingCall&&) noexcept = default; - EmbeddingCall& operator=(EmbeddingCall&&) noexcept = default; + : ModelCallBase(std::move(model), std::move(slot_name), + std::move(model_id)) {} NodeResult Embed( const TextBatch& inputs, const EmbeddingOptions& options = EmbeddingOptions{}) const { - return detail::InvokeAlignedModel( - inputs, model_, "Embedding", "embed", slot_name_, - [&](ModelType& model, EmbeddingBatch* outputs, - std::string* diagnostic) { - return model.Embed(inputs, options, outputs, diagnostic); - }); + return Invoke(inputs, "Embedding", "embed", + [&](ModelType& model, EmbeddingBatch* outputs, + std::string* diagnostic) { + return model.Embed(inputs, options, outputs, + diagnostic); + }); } - - const std::string& SlotName() const noexcept { return slot_name_; } - const std::string& ModelId() const noexcept { return model_id_; } - bool IsBound() const noexcept { return model_ != nullptr; } - - private: - std::shared_ptr model_; - std::string slot_name_; - std::string model_id_; }; -class AsrCall { +class AsrCall : public detail::ModelCallBase { public: - using ModelType = IAsrModel; AsrCall() = default; explicit AsrCall(std::shared_ptr model, std::string slot_name = "transcriber", std::string model_id = {}) - : model_(std::move(model)), - slot_name_(std::move(slot_name)), - model_id_(std::move(model_id)) {} - AsrCall(const AsrCall&) = delete; - AsrCall& operator=(const AsrCall&) = delete; - AsrCall(AsrCall&&) noexcept = default; - AsrCall& operator=(AsrCall&&) noexcept = default; + : ModelCallBase(std::move(model), std::move(slot_name), + std::move(model_id)) {} NodeResult Transcribe(const AudioPcmBatch& inputs) const { - return detail::InvokeAlignedModel( - inputs, model_, "ASR", "transcribe", slot_name_, + return Invoke( + inputs, "ASR", "transcribe", [&](ModelType& model, TextBatch* outputs, std::string* diagnostic) { return model.Transcribe(inputs, outputs, diagnostic); }); } - - const std::string& SlotName() const noexcept { return slot_name_; } - const std::string& ModelId() const noexcept { return model_id_; } - bool IsBound() const noexcept { return model_ != nullptr; } - - private: - std::shared_ptr model_; - std::string slot_name_; - std::string model_id_; }; -class OcrCall { +class OcrCall : public detail::ModelCallBase { public: - using ModelType = IOcrModel; OcrCall() = default; explicit OcrCall(std::shared_ptr model, std::string slot_name = "detector", std::string model_id = {}) - : model_(std::move(model)), - slot_name_(std::move(slot_name)), - model_id_(std::move(model_id)) {} - OcrCall(const OcrCall&) = delete; - OcrCall& operator=(const OcrCall&) = delete; - OcrCall(OcrCall&&) noexcept = default; - OcrCall& operator=(OcrCall&&) noexcept = default; + : ModelCallBase(std::move(model), std::move(slot_name), + std::move(model_id)) {} NodeResult Recognize(const ImageRefBatch& inputs) const { - return detail::InvokeAlignedModel( - inputs, model_, "OCR", "recognize", slot_name_, + return Invoke( + inputs, "OCR", "recognize", [&](ModelType& model, OcrDocumentBatch* outputs, std::string* diagnostic) { return model.Recognize(inputs, outputs, diagnostic); }); } - - const std::string& SlotName() const noexcept { return slot_name_; } - const std::string& ModelId() const noexcept { return model_id_; } - bool IsBound() const noexcept { return model_ != nullptr; } - - private: - std::shared_ptr model_; - std::string slot_name_; - std::string model_id_; }; -class RerankCall { +class RerankCall : public detail::ModelCallBase { public: - using ModelType = IRerankModel; RerankCall() = default; explicit RerankCall(std::shared_ptr model, std::string slot_name = "reranker", std::string model_id = {}) - : model_(std::move(model)), - slot_name_(std::move(slot_name)), - model_id_(std::move(model_id)) {} - RerankCall(const RerankCall&) = delete; - RerankCall& operator=(const RerankCall&) = delete; - RerankCall(RerankCall&&) noexcept = default; - RerankCall& operator=(RerankCall&&) noexcept = default; + : ModelCallBase(std::move(model), std::move(slot_name), + std::move(model_id)) {} NodeResult Score(const QueryCandidatesBatch& inputs) const { - return detail::InvokeAlignedModel( - inputs, model_, "Rerank", "score", slot_name_, + return Invoke( + inputs, "Rerank", "score", [&](ModelType& model, ScoreBatch* outputs, std::string* diagnostic) { return model.Score(inputs, outputs, diagnostic); }); } - - const std::string& SlotName() const noexcept { return slot_name_; } - const std::string& ModelId() const noexcept { return model_id_; } - bool IsBound() const noexcept { return model_ != nullptr; } - - private: - std::shared_ptr model_; - std::string slot_name_; - std::string model_id_; }; struct NoModels {}; diff --git a/plans/ARCHITECTURE_REVIEW.md b/plans/ARCHITECTURE_REVIEW.md index 0d8bc226..07a09568 100644 --- a/plans/ARCHITECTURE_REVIEW.md +++ b/plans/ARCHITECTURE_REVIEW.md @@ -320,7 +320,7 @@ Definition、Catalog、Validator、节点运行时及工具的解释须一致。 | B5 | `NodeBase` 旧写法辅助函数收窄 | 第三节·能力节点层 | 已完成:`BindPort`/`Require`/`Publish` 等移到 `dev_support` 的 `LegacyNodeBase`,供直接驱动 NodeBase 的测试与作者基准的历史基线使用;`NodeBase` 只保留生命周期与异常屏障 | | C1 | Map/Batch 生命周期合并 | 第三节·能力节点层 | 已完成:`AuthorNode` 改为 Batch 运行时的包装,作者写法与 Definition 不变;逐项失败诊断、空批和绑定诊断有测试覆盖,Batch 绑定诊断补充期望/实际类型 | | C2 | 端口声明与实例状态分离 | 第三节·能力节点层 | 不采纳:`REGISTER_FUNCTION_NODE` 每次构造实例都重新求值 Spec,不存在跨实例复制;模型与参数已在 `AuthorNode` 上,绑定内的实例状态只剩解析后的 key。拆出 key 需要为每类绑定增加并行的实例表并改动全部绑定接口,类型擦除仍然需要。Spec 需要跨实例共享或创建成本成为瓶颈时再设计 | -| C3 | 模型调用类共用持有实现 | 第三节·能力节点层 | 待评估 | +| C3 | 模型调用类共用持有实现 | 第三节·能力节点层 | 已完成:5 个 `XxxCall` 继承 `detail::ModelCallBase`,共用持有、只移动语义和槽位标识;具名能力方法保留 | | C4 | 修复建议生成移到工具层 | 第三节·流程编排层 | 待评估 | | C5 | 业务审计与图校验分离 | 第三节·流程编排层 | 待评估 | | C6 | Control 载荷复用 | 第三节·流程编排层 | 待评估 | diff --git a/tests/unit/nodes/test_function_node.cpp b/tests/unit/nodes/test_function_node.cpp index a883223c..3ce0ba6c 100644 --- a/tests/unit/nodes/test_function_node.cpp +++ b/tests/unit/nodes/test_function_node.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include "core/alg_context.h" @@ -927,6 +928,29 @@ TEST(FunctionNodeTest, MapPreservesOrderingAndProvenanceAcrossRequests) { (std::vector{"PRE:first", "PRE:second", "PRE:third"})); } +// Every capability call shares one move-only ownership contract. +template +void ExpectModelCallContract(const char* default_slot) { + static_assert(std::is_same_v); + static_assert(!std::is_copy_constructible_v); + static_assert(!std::is_copy_assignable_v); + static_assert(std::is_nothrow_move_constructible_v); + static_assert(std::is_nothrow_move_assignable_v); + CallT unbound; + EXPECT_FALSE(unbound.IsBound()); + CallT named(nullptr); + EXPECT_EQ(named.SlotName(), default_slot); + EXPECT_TRUE(named.ModelId().empty()); +} + +TEST(FunctionNodeTest, ModelCallsShareOwnershipContract) { + ExpectModelCallContract("generator"); + ExpectModelCallContract("encoder"); + ExpectModelCallContract("transcriber"); + ExpectModelCallContract("detector"); + ExpectModelCallContract("reranker"); +} + TEST(FunctionNodeTest, EmptyBatchReturnsEmptyWithoutCallingFunction) { NodeHarness harness("CleanTextMapNode"); harness.TextInput("input", {}); From 731c2f552f81a5d0362452f3c34ca5f9e157d10e Mon Sep 17 00:00:00 2001 From: chamsechan Date: Sun, 4 Oct 2026 21:00:21 +0800 Subject: [PATCH 15/29] refactor(cli): generate remediation in alg_pipeline_tool instead of Core The Validator populated Chinese remediation summaries, candidate facts and JSON Patch fixes inside Core, so every SDK Create failure ran tooling code that only alg_pipeline_tool and Studio consume. That code now lives in src/cli/pipeline_remediation as the edgeflow_pipeline_tooling library; fix verification still re-runs PipelineValidator, so no rule is reimplemented. Nearest registered names for unknown node, model and backend types stay in Core suggestions. PipelineValidator::Explain is removed. validate and --explain output for 15 broken documents is byte-identical before and after the move. Co-Authored-By: Claude Opus 5.5 --- doc/CHANGELOG.md | 5 + include/core/pipeline_validator.h | 6 +- plans/ARCHITECTURE_REVIEW.md | 2 +- src/cli/CMakeLists.txt | 8 +- src/cli/pipeline_document_validation.cpp | 12 +- src/cli/pipeline_remediation.cpp | 901 +++++++++++++++++ src/cli/pipeline_remediation.h | 26 + src/core/CMakeLists.txt | 1 + src/core/name_suggestions.cpp | 57 ++ src/core/name_suggestions.h | 21 + src/core/pipeline_validator.cpp | 942 +----------------- tests/CMakeLists.txt | 3 +- tests/RuntimeTests.cmake | 3 +- .../test_pipeline_catalog_validator.cpp | 99 +- .../core/test_validated_pipeline_plan.cpp | 2 - 15 files changed, 1093 insertions(+), 995 deletions(-) create mode 100644 src/cli/pipeline_remediation.cpp create mode 100644 src/cli/pipeline_remediation.h create mode 100644 src/core/name_suggestions.cpp create mode 100644 src/core/name_suggestions.h diff --git a/doc/CHANGELOG.md b/doc/CHANGELOG.md index 03da58dd..ff08903c 100644 --- a/doc/CHANGELOG.md +++ b/doc/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +修复建议移出 Core:remediation 的原因、事实、中文摘要与经 Validator 复核的 JSON Patch 修复改由 +`alg_pipeline_tool` 生成,不再编入 SDK,也不在 Create 校验失败时执行。`PipelineValidator::Explain` +删除;Validator 只返回中性诊断,未注册类型的相近名称仍在 `suggestions` 中给出。CLI 与 Studio 的 +`validate` / `--explain` 输出不变。 + 公开错误码:Operator 门面按失败阶段映射返回码,内部的 Pipeline、Node、Model 错误码不再直接返回给宿主。 Create 阶段的配置、部署与模型/Backend 加载失败返回 `-2`(此前部分为 `-3`);Process 中节点或模型 执行失败返回 `-100`;Control 请求不合法返回 `-2`(此前为 `-1`),节点拒绝或未能应用更新返回 `-100`。 diff --git a/include/core/pipeline_validator.h b/include/core/pipeline_validator.h index e22d3111..012e6ac2 100644 --- a/include/core/pipeline_validator.h +++ b/include/core/pipeline_validator.h @@ -25,6 +25,8 @@ struct ValidationFix { nlohmann::json ToJson() const; }; +// Filled by developer tooling (alg_pipeline_tool); the Validator itself only +// reports neutral diagnostics. struct ValidationRemediation { int schema_version = 1; RemediationCause cause = RemediationCause::kUnknownConfigField; @@ -93,10 +95,6 @@ class PipelineValidator { static ValidationReport Validate( const nlohmann::json& root, const PipelineIoBoundary* io_boundary = nullptr); - - static ValidationReport Explain( - const nlohmann::json& root, - const PipelineIoBoundary* io_boundary = nullptr); }; /** diff --git a/plans/ARCHITECTURE_REVIEW.md b/plans/ARCHITECTURE_REVIEW.md index 07a09568..082d451a 100644 --- a/plans/ARCHITECTURE_REVIEW.md +++ b/plans/ARCHITECTURE_REVIEW.md @@ -321,7 +321,7 @@ Definition、Catalog、Validator、节点运行时及工具的解释须一致。 | C1 | Map/Batch 生命周期合并 | 第三节·能力节点层 | 已完成:`AuthorNode` 改为 Batch 运行时的包装,作者写法与 Definition 不变;逐项失败诊断、空批和绑定诊断有测试覆盖,Batch 绑定诊断补充期望/实际类型 | | C2 | 端口声明与实例状态分离 | 第三节·能力节点层 | 不采纳:`REGISTER_FUNCTION_NODE` 每次构造实例都重新求值 Spec,不存在跨实例复制;模型与参数已在 `AuthorNode` 上,绑定内的实例状态只剩解析后的 key。拆出 key 需要为每类绑定增加并行的实例表并改动全部绑定接口,类型擦除仍然需要。Spec 需要跨实例共享或创建成本成为瓶颈时再设计 | | C3 | 模型调用类共用持有实现 | 第三节·能力节点层 | 已完成:5 个 `XxxCall` 继承 `detail::ModelCallBase`,共用持有、只移动语义和槽位标识;具名能力方法保留 | -| C4 | 修复建议生成移到工具层 | 第三节·流程编排层 | 待评估 | +| C4 | 修复建议生成移到工具层 | 第三节·流程编排层 | 已完成:remediation 与修复复核移到 `src/cli/pipeline_remediation`,复核仍调用 Validator;未注册类型的相近名称留在 Core 诊断;15 份故障方案的 CLI 输出逐字节不变 | | C5 | 业务审计与图校验分离 | 第三节·流程编排层 | 待评估 | | C6 | Control 载荷复用 | 第三节·流程编排层 | 待评估 | | C7 | JSON 基础检查共用 | 第三节·流程编排层 | 待评估 | diff --git a/src/cli/CMakeLists.txt b/src/cli/CMakeLists.txt index 44e1fef9..0f329d74 100644 --- a/src/cli/CMakeLists.txt +++ b/src/cli/CMakeLists.txt @@ -2,12 +2,18 @@ add_executable(alg_show alg_show.cpp) target_link_libraries(alg_show PRIVATE edgeflow_internal_headers nlohmann_json::nlohmann_json) +# Developer-tool guidance on Validator diagnostics; not part of the SDK. +add_library(edgeflow_pipeline_tooling STATIC pipeline_remediation.cpp) +target_link_libraries(edgeflow_pipeline_tooling PUBLIC + edgeflow_internal_headers llm_edgeflow::internal_runtime) + add_executable(alg_pipeline_tool alg_pipeline_tool.cpp pipeline_authoring.cpp pipeline_json_schema.cpp pipeline_document_validation.cpp) target_link_libraries(alg_pipeline_tool PRIVATE - edgeflow_internal_headers llm_edgeflow::internal_runtime) + edgeflow_internal_headers llm_edgeflow::internal_runtime + edgeflow_pipeline_tooling) target_include_directories(alg_pipeline_tool PRIVATE "${PROJECT_BINARY_DIR}/generated/internal") diff --git a/src/cli/pipeline_document_validation.cpp b/src/cli/pipeline_document_validation.cpp index 9b7289d7..00e66b93 100644 --- a/src/cli/pipeline_document_validation.cpp +++ b/src/cli/pipeline_document_validation.cpp @@ -4,6 +4,7 @@ #include "adapter/deployment_preparation.h" #include "core/pipeline_validator.h" +#include "pipeline_remediation.h" namespace llm_edgeflow { @@ -39,12 +40,11 @@ DocumentValidationResult ValidatePipelineDocument( return result; } } - auto report = - mode == DocumentValidationMode::kExplain - ? PipelineValidator::Explain(prepared.neutral_pipeline_json, - &prepared.io_boundary) - : PipelineValidator::Validate(prepared.neutral_pipeline_json, - &prepared.io_boundary); + auto report = mode == DocumentValidationMode::kExplain + ? ExplainPipeline(prepared.neutral_pipeline_json, + &prepared.io_boundary) + : ValidateWithRemediation(prepared.neutral_pipeline_json, + &prepared.io_boundary); ProjectDeploymentDiagnostics(&report); result.ok = report.ok; result.response = report.ToJson(); diff --git a/src/cli/pipeline_remediation.cpp b/src/cli/pipeline_remediation.cpp new file mode 100644 index 00000000..f3dd139c --- /dev/null +++ b/src/cli/pipeline_remediation.cpp @@ -0,0 +1,901 @@ +#include "pipeline_remediation.h" + +#include +#include +#include +#include + +#include "contracts/json_pointer.h" +#include "core/name_suggestions.h" +#include "core/pipeline_catalog.h" + +namespace llm_edgeflow { +namespace { + +bool ValueMatchesConfigKind(const nlohmann::json& value, ConfigValueKind kind) { + switch (kind) { + case ConfigValueKind::kString: + return value.is_string(); + case ConfigValueKind::kInteger: + return value.is_number_integer(); + case ConfigValueKind::kNumber: + return value.is_number(); + case ConfigValueKind::kBoolean: + return value.is_boolean(); + case ConfigValueKind::kObject: + return value.is_object(); + case ConfigValueKind::kArray: + return value.is_array(); + } + return false; +} + +struct DiagnosticIdentity { + DiagnosticCode code; + std::string node_id; + std::string port; + std::string subpath; + + bool operator==(const DiagnosticIdentity& other) const { + return code == other.code && node_id == other.node_id && + port == other.port && subpath == other.subpath; + } +}; + +DiagnosticIdentity GetDiagnosticIdentity(const ValidationDiagnostic& d) { + DiagnosticIdentity id; + id.code = d.code; + id.node_id = d.node_id; + id.port = d.port; + if (d.code == DiagnosticCode::kDuplicateDependency) { + if (!d.related_nodes.empty()) { + id.subpath = "/depends_on/" + d.related_nodes.front(); + } else if (d.remediation.has_value() && + d.remediation->facts.contains("dependency_id")) { + id.subpath = "/depends_on/" + + d.remediation->facts["dependency_id"].get(); + } else { + id.subpath = "/depends_on"; + } + } else if (d.path.rfind("/pipeline/", 0) == 0) { + size_t second_slash = d.path.find('/', 10); + if (second_slash != std::string::npos) { + id.subpath = d.path.substr(second_slash); + } else { + id.subpath = "/pipeline"; + } + } else { + id.subpath = d.path; + } + return id; +} + +void PopulateBasicRemediation(ValidationDiagnostic* diag, + const nlohmann::json& root, + const PipelineCatalogSnapshot& catalog) { + if (!diag || diag->remediation.has_value()) return; + if (!root.is_object()) return; + + if (diag->code == DiagnosticCode::kUnknownNodeType || + diag->code == DiagnosticCode::kUnknownModelType || + diag->code == DiagnosticCode::kUnknownBackend) { + const nlohmann::json::json_pointer pointer(diag->path); + if (!root.contains(pointer) || !root.at(pointer).is_string()) return; + const auto& item = root.at(nlohmann::json::json_pointer( + diag->path.substr(0, diag->path.rfind('/')))); + const std::string name = root.at(pointer).get(); + ValidationRemediation rem; + std::vector names; + std::string candidates_key; + std::string next_step; + if (diag->code == DiagnosticCode::kUnknownNodeType) { + rem.cause = RemediationCause::kUnknownNodeType; + rem.facts["node_type"] = name; + candidates_key = "candidate_node_types"; + rem.summary = "节点 '" + diag->node_id + "' 的 node_type '" + name; + next_step = "新增的 Node 需要重新构建后才会注册。"; + } else { + const std::string model_id = item.value("model_id", ""); + rem.facts["model_id"] = model_id; + if (diag->code == DiagnosticCode::kUnknownModelType) { + rem.cause = RemediationCause::kUnknownModelType; + rem.facts["model_type"] = name; + candidates_key = "candidate_model_types"; + for (const auto& def : PipelineCatalog::Models()) + names.push_back(def.model_type); + std::sort(names.begin(), names.end()); + rem.facts["registered_model_types"] = names; + rem.summary = "模型 '" + model_id + "' 的 model_type '" + name; + } else { + rem.cause = RemediationCause::kUnknownBackend; + rem.facts["backend"] = name; + candidates_key = "candidate_backends"; + for (const auto& def : PipelineCatalog::Backends()) + names.push_back(def.backend_type); + std::sort(names.begin(), names.end()); + rem.facts["registered_backends"] = names; + rem.summary = "模型 '" + model_id + "' 的 backend '" + name; + next_step = "可选 Backend 需要在构建时启用。"; + } + } + rem.facts[candidates_key] = diag->suggestions; + rem.summary += "' 未在当前构建中注册。"; + if (!diag->suggestions.empty()) { + rem.summary += "相近的已注册类型:"; + for (size_t i = 0; i < diag->suggestions.size(); ++i) { + if (i) rem.summary += "、"; + rem.summary += diag->suggestions[i]; + } + rem.summary += "。"; + } + rem.summary += next_step; + diag->remediation = std::move(rem); + return; + } + + if (diag->code == DiagnosticCode::kUnknownConfigField) { + if (diag->path.rfind("/pipeline/", 0) == 0 && root.contains("pipeline") && + root["pipeline"].is_array()) { + size_t idx_end = diag->path.find('/', 10); + if (idx_end != std::string::npos) { + size_t p_idx = 0; + try { + p_idx = std::stoul(diag->path.substr(10, idx_end - 10)); + } catch (...) { + p_idx = static_cast(-1); + } + if (p_idx < root["pipeline"].size()) { + const auto& node_obj = root["pipeline"][p_idx]; + std::string node_type = node_obj.value("node_type", ""); + const auto* def = catalog.FindNode(node_type); + std::string prefix = + "/pipeline/" + std::to_string(p_idx) + "/config/"; + if (def && diag->path.rfind(prefix, 0) == 0) { + std::string field_name = diag->path.substr(prefix.size()); + ValidationRemediation rem; + rem.schema_version = 1; + rem.cause = RemediationCause::kUnknownConfigField; + rem.summary = "节点 '" + diag->node_id + "' 的配置包含未知字段 '" + + field_name + "'。"; + rem.facts["field"] = field_name; + + std::vector names; + for (const auto& field : def->config_fields) + names.push_back(field.name); + rem.facts["candidate_fields"] = + RankByEditDistance(field_name, std::move(names)); + diag->remediation = std::move(rem); + } + } + } + } + } else if (diag->code == DiagnosticCode::kMissingConfigField) { + if (diag->path.rfind("/pipeline/", 0) == 0 && root.contains("pipeline") && + root["pipeline"].is_array()) { + size_t idx_end = diag->path.find('/', 10); + if (idx_end != std::string::npos) { + size_t p_idx = 0; + try { + p_idx = std::stoul(diag->path.substr(10, idx_end - 10)); + } catch (...) { + p_idx = static_cast(-1); + } + if (p_idx < root["pipeline"].size()) { + const auto& node_obj = root["pipeline"][p_idx]; + std::string node_type = node_obj.value("node_type", ""); + const auto* def = catalog.FindNode(node_type); + std::string prefix = + "/pipeline/" + std::to_string(p_idx) + "/config/"; + std::string field_name; + if (diag->path.rfind(prefix, 0) == 0) { + field_name = diag->path.substr(prefix.size()); + } + if (def && !field_name.empty()) { + ValidationRemediation rem; + rem.schema_version = 1; + rem.cause = RemediationCause::kMissingConfigField; + rem.summary = "节点 '" + diag->node_id + "' 缺少必填配置字段 '" + + field_name + "'。"; + rem.facts["field"] = field_name; + auto cf_it = std::find_if( + def->config_fields.begin(), def->config_fields.end(), + [&](const auto& f) { return f.name == field_name; }); + if (cf_it != def->config_fields.end()) { + rem.facts["expected_type"] = ConfigValueKindName(cf_it->kind); + if (cf_it->minimum.has_value()) + rem.facts["minimum"] = *cf_it->minimum; + if (cf_it->maximum.has_value()) + rem.facts["maximum"] = *cf_it->maximum; + if (!cf_it->enum_values.empty()) + rem.facts["enum"] = cf_it->enum_values; + } + diag->remediation = std::move(rem); + } + } + } + } + } else if (diag->code == DiagnosticCode::kConfigFieldType || + diag->code == DiagnosticCode::kConfigFieldRange || + diag->code == DiagnosticCode::kConfigFieldEnum) { + if (diag->path.rfind("/pipeline/", 0) == 0 && root.contains("pipeline") && + root["pipeline"].is_array()) { + size_t idx_end = diag->path.find('/', 10); + if (idx_end != std::string::npos) { + size_t p_idx = 0; + try { + p_idx = std::stoul(diag->path.substr(10, idx_end - 10)); + } catch (...) { + p_idx = static_cast(-1); + } + if (p_idx < root["pipeline"].size()) { + const auto& node_obj = root["pipeline"][p_idx]; + std::string node_type = node_obj.value("node_type", ""); + const auto* def = catalog.FindNode(node_type); + std::string prefix = + "/pipeline/" + std::to_string(p_idx) + "/config/"; + std::string field_name; + if (diag->path.rfind(prefix, 0) == 0) { + field_name = diag->path.substr(prefix.size()); + } + if (def && !field_name.empty()) { + ValidationRemediation rem; + rem.schema_version = 1; + rem.cause = RemediationCause::kInvalidConfigValue; + rem.summary = "节点 '" + diag->node_id + "' 的配置项 '" + + field_name + "' 值不合法。"; + rem.facts["field"] = field_name; + auto cf_it = std::find_if( + def->config_fields.begin(), def->config_fields.end(), + [&](const auto& f) { return f.name == field_name; }); + if (cf_it != def->config_fields.end()) { + rem.facts["expected_type"] = ConfigValueKindName(cf_it->kind); + if (cf_it->minimum.has_value()) + rem.facts["minimum"] = *cf_it->minimum; + if (cf_it->maximum.has_value()) + rem.facts["maximum"] = *cf_it->maximum; + if (!cf_it->enum_values.empty()) + rem.facts["enum"] = cf_it->enum_values; + } + diag->remediation = std::move(rem); + } + } + } + } + } else if (diag->code == DiagnosticCode::kUnknownModelReference || + diag->code == DiagnosticCode::kModelCapabilityMismatch) { + if (diag->path.rfind("/pipeline/", 0) == 0 && root.contains("pipeline") && + root["pipeline"].is_array()) { + size_t idx_end = diag->path.find('/', 10); + if (idx_end != std::string::npos) { + size_t p_idx = 0; + try { + p_idx = std::stoul(diag->path.substr(10, idx_end - 10)); + } catch (...) { + p_idx = static_cast(-1); + } + if (p_idx < root["pipeline"].size()) { + const auto& node_obj = root["pipeline"][p_idx]; + std::string node_type = node_obj.value("node_type", ""); + const auto* def = catalog.FindNode(node_type); + if (def) { + std::string expected_prefix = + "/pipeline/" + std::to_string(p_idx) + "/config/"; + for (const auto& dep : def->model_dependencies) { + if (diag->path != + expected_prefix + EscapeJsonPointer(dep.config_field)) { + continue; + } + std::string model_id = + (node_obj.contains("config") && + node_obj["config"].contains(dep.config_field) && + node_obj["config"][dep.config_field].is_string()) + ? node_obj["config"][dep.config_field].get() + : ""; + std::string req_cap = dep.capability; + + ValidationRemediation rem; + rem.schema_version = 1; + rem.cause = (diag->code == DiagnosticCode::kUnknownModelReference) + ? RemediationCause::kUnknownModelReference + : RemediationCause::kModelCapabilityMismatch; + rem.facts["model_id"] = model_id; + rem.facts["required_capability"] = req_cap; + + std::vector candidate_model_ids; + if (root.contains("models") && root["models"].is_array()) { + for (size_t m_idx = 0; m_idx < root["models"].size(); ++m_idx) { + const auto& m = root["models"][m_idx]; + if (!m.is_object()) continue; + std::string mid = m.value("model_id", ""); + if (mid.empty()) continue; + const auto model_def = + PipelineCatalog::FindModel(m.value("model_type", "")); + if (model_def && model_def->capability == req_cap) { + candidate_model_ids.push_back(mid); + } + } + } + std::sort(candidate_model_ids.begin(), candidate_model_ids.end()); + rem.facts["candidate_model_ids"] = candidate_model_ids; + rem.summary = "节点 '" + diag->node_id + "' 引用的模型 '" + + model_id + "' 与所需能力 '" + req_cap + + "' 不符或未声明。"; + diag->remediation = std::move(rem); + break; + } + } + } + } + } + } else if (diag->code == DiagnosticCode::kMissingInputProducer) { + size_t consumer_idx = static_cast(-1); + if (diag->path.rfind("/pipeline/", 0) == 0 && root.contains("pipeline") && + root["pipeline"].is_array()) { + size_t idx_end = diag->path.find('/', 10); + std::string idx_str = (idx_end == std::string::npos) + ? diag->path.substr(10) + : diag->path.substr(10, idx_end - 10); + try { + consumer_idx = std::stoul(idx_str); + } catch (...) { + consumer_idx = static_cast(-1); + } + } + + if (consumer_idx < root.value("pipeline", nlohmann::json::array()).size()) { + const auto& consumer_node = root["pipeline"][consumer_idx]; + std::string consumer_type = consumer_node.value("node_type", ""); + const auto* consumer_def = catalog.FindNode(consumer_type); + std::string port_name = diag->port; + std::string bound_key; + if (consumer_node.contains("inputs") && + consumer_node["inputs"].contains(port_name)) { + bound_key = consumer_node["inputs"][port_name].get(); + } + + std::string expected_type; + PortContract expected_contract; + if (consumer_def) { + for (const auto& inp : consumer_def->inputs) { + if (inp.logical_name == port_name) { + expected_type = inp.type_id; + expected_contract = inp; + break; + } + } + } + + int producer_idx = -1; + std::string producer_id; + std::string producer_out_type; + PortContract producer_contract; + + for (size_t p = 0; p < root["pipeline"].size(); ++p) { + if (p == consumer_idx) continue; + const auto& p_node = root["pipeline"][p]; + std::string p_id = p_node.value("id", ""); + std::string p_type = p_node.value("node_type", ""); + const auto* p_def = catalog.FindNode(p_type); + if (!p_def) continue; + for (const auto& out : p_def->outputs) { + std::string actual_out_key = out.logical_name; + if (p_node.contains("outputs") && + p_node["outputs"].contains(out.logical_name)) { + actual_out_key = + p_node["outputs"][out.logical_name].get(); + } + if (actual_out_key == bound_key) { + producer_idx = static_cast(p); + producer_id = p_id; + producer_out_type = out.type_id; + producer_contract = out; + break; + } + } + if (producer_idx >= 0) break; + } + + if (producer_idx >= 0 && producer_out_type != expected_type) { + ValidationRemediation rem; + rem.schema_version = 1; + rem.cause = RemediationCause::kPortTypeMismatch; + rem.summary = "生产者 '" + producer_id + "' 输出类型与端口 '" + + port_name + "' 要求不符。"; + rem.facts["bound_key"] = bound_key; + rem.facts["producer_id"] = producer_id; + rem.facts["expected"] = { + {"type_id", expected_contract.type_id}, + {"cardinality", expected_contract.cardinality}, + {"provenance_policy", expected_contract.provenance_policy}, + {"lifetime", expected_contract.lifetime}}; + rem.facts["actual"] = { + {"type_id", producer_contract.type_id}, + {"cardinality", producer_contract.cardinality}, + {"provenance_policy", producer_contract.provenance_policy}, + {"lifetime", producer_contract.lifetime}}; + diag->remediation = std::move(rem); + } else { + ValidationRemediation rem; + rem.schema_version = 1; + rem.cause = RemediationCause::kNoCompatibleInputSource; + rem.summary = + bound_key.empty() + ? "请为必需输入端口 '" + port_name + "' 明确指定数据来源。" + : "Pipeline 中没有为端口 '" + port_name + "' (绑定键: '" + + bound_key + "') 提供唯一匹配类型的生产者。"; + rem.facts["bound_key"] = bound_key; + rem.facts["expected_type"] = expected_type; + rem.facts["candidate_node_types"] = diag->suggestions; + diag->remediation = std::move(rem); + } + } + } else if (diag->code == DiagnosticCode::kDuplicateDependency) { + size_t consumer_idx = static_cast(-1); + if (diag->path.rfind("/pipeline/", 0) == 0 && root.contains("pipeline") && + root["pipeline"].is_array()) { + size_t idx_end = diag->path.find('/', 10); + std::string idx_str = (idx_end == std::string::npos) + ? diag->path.substr(10) + : diag->path.substr(10, idx_end - 10); + try { + consumer_idx = std::stoul(idx_str); + } catch (...) { + consumer_idx = static_cast(-1); + } + } + if (consumer_idx < root.value("pipeline", nlohmann::json::array()).size()) { + const auto& c_node = root["pipeline"][consumer_idx]; + std::string prefix = + "/pipeline/" + std::to_string(consumer_idx) + "/depends_on/"; + std::string dup_dep; + if (diag->path.rfind(prefix, 0) == 0 && c_node.contains("depends_on") && + c_node["depends_on"].is_array()) { + try { + size_t dep_arr_idx = std::stoul(diag->path.substr(prefix.size())); + if (dep_arr_idx < c_node["depends_on"].size()) { + dup_dep = c_node["depends_on"][dep_arr_idx].get(); + } + } catch (...) { + } + } + if (!dup_dep.empty()) { + ValidationRemediation rem; + rem.schema_version = 1; + rem.cause = RemediationCause::kDuplicateDependency; + rem.summary = + "节点 '" + diag->node_id + "' 包含重复依赖 '" + dup_dep + "'。"; + rem.facts["dependency_id"] = dup_dep; + diag->remediation = std::move(rem); + } + } + } else if (diag->code == DiagnosticCode::kInvalidDependency) { + size_t consumer_idx = static_cast(-1); + if (diag->path.rfind("/pipeline/", 0) == 0 && root.contains("pipeline") && + root["pipeline"].is_array()) { + size_t idx_end = diag->path.find('/', 10); + std::string idx_str = (idx_end == std::string::npos) + ? diag->path.substr(10) + : diag->path.substr(10, idx_end - 10); + try { + consumer_idx = std::stoul(idx_str); + } catch (...) { + consumer_idx = static_cast(-1); + } + } + if (consumer_idx < root.value("pipeline", nlohmann::json::array()).size()) { + const auto& c_node = root["pipeline"][consumer_idx]; + std::string prefix = + "/pipeline/" + std::to_string(consumer_idx) + "/depends_on/"; + std::string dep_id; + if (diag->path.rfind(prefix, 0) == 0 && c_node.contains("depends_on") && + c_node["depends_on"].is_array()) { + try { + size_t dep_arr_idx = std::stoul(diag->path.substr(prefix.size())); + if (dep_arr_idx < c_node["depends_on"].size()) { + dep_id = c_node["depends_on"][dep_arr_idx].get(); + } + } catch (...) { + } + } + + std::vector> ranked; + for (const auto& item : root["pipeline"]) { + std::string valid_id = item.value("id", ""); + if (!valid_id.empty() && valid_id != diag->node_id) { + ranked.emplace_back(LevenshteinDistance(dep_id, valid_id), valid_id); + } + } + std::sort(ranked.begin(), ranked.end(), [](const auto& a, const auto& b) { + if (a.first != b.first) return a.first < b.first; + return a.second < b.second; + }); + std::vector candidate_node_ids; + for (const auto& r : ranked) { + candidate_node_ids.push_back(r.second); + } + + ValidationRemediation rem; + rem.schema_version = 1; + rem.cause = RemediationCause::kUnknownDependency; + rem.summary = + "节点 '" + diag->node_id + "' 依赖了未知的节点 ID '" + dep_id + "'。"; + rem.facts["dependency_id"] = dep_id; + rem.facts["candidate_node_ids"] = candidate_node_ids; + diag->remediation = std::move(rem); + } + } else if (diag->code == DiagnosticCode::kMissingBizOutput) { + ValidationRemediation rem; + rem.schema_version = 1; + rem.cause = RemediationCause::kMissingBizOutput; + rem.summary = "Pipeline 未产出 biz '" + root.value("biz_name", "") + + "' 所需的输出 '" + diag->port + "'。"; + rem.facts["biz_name"] = root.value("biz_name", ""); + rem.facts["bound_key"] = diag->port; + const auto* biz = catalog.FindBiz(root.value("biz_name", "")); + if (biz) { + for (const auto& eg : biz->egress) { + if (eg.blackboard_key == diag->port) { + rem.facts["expected_type"] = eg.type_id; + break; + } + } + } + diag->remediation = std::move(rem); + } else if (diag->code == DiagnosticCode::kPortCardinalityMismatch || + diag->code == DiagnosticCode::kPortProvenanceMismatch || + diag->code == DiagnosticCode::kPortLifetimeMismatch) { + size_t consumer_idx = static_cast(-1); + if (diag->path.rfind("/pipeline/", 0) == 0 && root.contains("pipeline") && + root["pipeline"].is_array()) { + size_t idx_end = diag->path.find('/', 10); + std::string idx_str = (idx_end == std::string::npos) + ? diag->path.substr(10) + : diag->path.substr(10, idx_end - 10); + try { + consumer_idx = std::stoul(idx_str); + } catch (...) { + consumer_idx = static_cast(-1); + } + } + if (consumer_idx < root.value("pipeline", nlohmann::json::array()).size()) { + const auto& consumer_node = root["pipeline"][consumer_idx]; + std::string consumer_type = consumer_node.value("node_type", ""); + const auto* consumer_def = catalog.FindNode(consumer_type); + std::string port_name = diag->port; + std::string bound_key; + if (consumer_node.contains("inputs") && + consumer_node["inputs"].contains(port_name)) { + bound_key = consumer_node["inputs"][port_name].get(); + } + + PortContract expected_contract; + if (consumer_def) { + for (const auto& inp : consumer_def->inputs) { + if (inp.logical_name == port_name) { + expected_contract = inp; + break; + } + } + } + + std::string producer_id = + diag->related_nodes.empty() ? "" : diag->related_nodes[0]; + PortContract producer_contract; + + if (producer_id == "$ingress") { + const auto* biz = catalog.FindBiz(root.value("biz_name", "")); + if (biz) { + for (const auto& ing : biz->ingress) { + if (ing.blackboard_key == bound_key) { + producer_contract = ing; + break; + } + } + } + } else { + for (const auto& p_node : root["pipeline"]) { + if (p_node.value("id", "") == producer_id) { + std::string p_type = p_node.value("node_type", ""); + const auto* p_def = catalog.FindNode(p_type); + if (p_def) { + for (const auto& out : p_def->outputs) { + std::string actual_out_key = out.logical_name; + if (p_node.contains("outputs") && + p_node["outputs"].contains(out.logical_name)) { + actual_out_key = + p_node["outputs"][out.logical_name].get(); + } + if (actual_out_key == bound_key) { + producer_contract = out; + break; + } + } + } + break; + } + } + } + + ValidationRemediation rem; + rem.schema_version = 1; + rem.cause = RemediationCause::kPortFlowMismatch; + rem.summary = "生产者 '" + producer_id + "' 与消费者 '" + diag->node_id + + "' 在端口 '" + port_name + "' 上的流契约不兼容。"; + rem.facts["bound_key"] = bound_key; + rem.facts["producer_id"] = producer_id; + rem.facts["expected"] = { + {"type_id", expected_contract.type_id}, + {"cardinality", expected_contract.cardinality}, + {"provenance_policy", expected_contract.provenance_policy}, + {"lifetime", expected_contract.lifetime}}; + rem.facts["actual"] = { + {"type_id", producer_contract.type_id}, + {"cardinality", producer_contract.cardinality}, + {"provenance_policy", producer_contract.provenance_policy}, + {"lifetime", producer_contract.lifetime}}; + diag->remediation = std::move(rem); + } + } +} + +} // namespace + +void AttachRemediation(const nlohmann::json& root, ValidationReport* report) { + if (!report) return; + const auto catalog = PipelineCatalog::Snapshot(); + for (auto& diag : report->diagnostics) { + PopulateBasicRemediation(&diag, root, catalog); + } +} + +ValidationReport ValidateWithRemediation( + const nlohmann::json& root, const PipelineIoBoundary* io_boundary) { + ValidationReport report = PipelineValidator::Validate(root, io_boundary); + AttachRemediation(root, &report); + return report; +} + +ValidationReport ExplainPipeline(const nlohmann::json& root, + const PipelineIoBoundary* io_boundary) { + const auto catalog = PipelineCatalog::Snapshot(); + ValidationReport report = ValidateWithRemediation(root, io_boundary); + if (report.ok) { + return report; + } + + if (!root.is_object() || !root.contains("pipeline") || + !root["pipeline"].is_array()) { + return report; + } + + constexpr size_t kMaxVerificationAttempts = 8; + constexpr size_t kMaxFixesPerDiagnostic = 3; + constexpr size_t kMaxFixesPerReport = 8; + size_t total_verification_attempts = 0; + size_t total_fixes_accepted = 0; + size_t fix_counter = 0; + + std::vector orig_identities; + orig_identities.reserve(report.diagnostics.size()); + for (const auto& d : report.diagnostics) { + orig_identities.push_back(GetDiagnosticIdentity(d)); + } + + for (auto& diag : report.diagnostics) { + if (total_verification_attempts >= kMaxVerificationAttempts || + total_fixes_accepted >= kMaxFixesPerReport) { + break; + } + if (!diag.remediation.has_value()) continue; + + std::vector candidate_fixes; + + if (diag.remediation->cause == RemediationCause::kUnknownConfigField) { + size_t idx_end = diag.path.find('/', 10); + size_t p_idx = std::stoul(diag.path.substr(10, idx_end - 10)); + const auto& node_obj = root["pipeline"][p_idx]; + std::string node_type = node_obj.value("node_type", ""); + const auto* def = catalog.FindNode(node_type); + std::string prefix = "/pipeline/" + std::to_string(p_idx) + "/config/"; + std::string field_name = diag.remediation->facts.value("field", ""); + if (def && node_obj.contains("config") && + node_obj["config"].is_object() && + node_obj["config"].contains(field_name)) { + const auto& original_val = node_obj["config"][field_name]; + auto candidate_fields = diag.remediation->facts.value( + "candidate_fields", std::vector{}); + for (const auto& cand : candidate_fields) { + if (node_obj["config"].contains(cand)) continue; + auto cf_it = + std::find_if(def->config_fields.begin(), def->config_fields.end(), + [&](const auto& f) { return f.name == cand; }); + if (cf_it != def->config_fields.end() && + ValueMatchesConfigKind(original_val, cf_it->kind)) { + ValidationFix fix; + fix.id = "rename-config-field-" + std::to_string(++fix_counter); + fix.title = "将 '" + field_name + "' 重命名为 '" + cand + "'"; + fix.effect = "更正字段名称为已知配置项 '" + cand + "'。"; + std::string from_path = prefix + EscapeJsonPointer(field_name); + std::string to_path = prefix + EscapeJsonPointer(cand); + fix.patch = nlohmann::json::array( + {{{"op", "test"}, {"path", from_path}, {"value", original_val}}, + {{"op", "move"}, {"from", from_path}, {"path", to_path}}}); + candidate_fixes.push_back(std::move(fix)); + } + } + } + } else if (diag.remediation->cause == + RemediationCause::kUnknownModelReference || + diag.remediation->cause == + RemediationCause::kModelCapabilityMismatch) { + size_t idx_end = diag.path.find('/', 10); + size_t p_idx = std::stoul(diag.path.substr(10, idx_end - 10)); + const auto& node_obj = root["pipeline"][p_idx]; + std::string node_type = node_obj.value("node_type", ""); + const auto* def = catalog.FindNode(node_type); + std::string model_id = diag.remediation->facts.value("model_id", ""); + auto candidate_model_ids = diag.remediation->facts.value( + "candidate_model_ids", std::vector{}); + if (def) { + std::string expected_prefix = + "/pipeline/" + std::to_string(p_idx) + "/config/"; + for (const auto& dep : def->model_dependencies) { + std::string model_path = + expected_prefix + EscapeJsonPointer(dep.config_field); + if (diag.path == model_path) { + for (const auto& cand_id : candidate_model_ids) { + if (cand_id == model_id) continue; + ValidationFix fix; + fix.id = "use-model-" + std::to_string(++fix_counter); + fix.title = "使用模型 '" + cand_id + "'"; + fix.effect = + "将模型引用从 '" + model_id + "' 更改为 '" + cand_id + "'。"; + fix.patch = nlohmann::json::array( + {{{"op", "test"}, {"path", model_path}, {"value", model_id}}, + {{"op", "replace"}, + {"path", model_path}, + {"value", cand_id}}}); + candidate_fixes.push_back(std::move(fix)); + } + break; + } + } + } + } else if (diag.remediation->cause == + RemediationCause::kDuplicateDependency) { + size_t idx_end = diag.path.find('/', 10); + size_t consumer_idx = std::stoul(diag.path.substr(10, idx_end - 10)); + const auto& c_node = root["pipeline"][consumer_idx]; + std::string dup_dep = diag.remediation->facts.value("dependency_id", ""); + std::string prefix = + "/pipeline/" + std::to_string(consumer_idx) + "/depends_on/"; + if (diag.path.rfind(prefix, 0) == 0 && c_node.contains("depends_on") && + c_node["depends_on"].is_array() && !dup_dep.empty()) { + ValidationFix fix; + fix.id = "remove-duplicate-dep-" + std::to_string(++fix_counter); + fix.title = "删除重复依赖 '" + dup_dep + "'"; + fix.effect = "移除对 '" + dup_dep + "' 的重复依赖声明。"; + fix.patch = nlohmann::json::array( + {{{"op", "test"}, {"path", diag.path}, {"value", dup_dep}}, + {{"op", "remove"}, {"path", diag.path}}}); + candidate_fixes.push_back(std::move(fix)); + } + } else if (diag.remediation->cause == + RemediationCause::kUnknownDependency) { + size_t idx_end = diag.path.find('/', 10); + size_t consumer_idx = std::stoul(diag.path.substr(10, idx_end - 10)); + const auto& c_node = root["pipeline"][consumer_idx]; + std::string prefix = + "/pipeline/" + std::to_string(consumer_idx) + "/depends_on/"; + std::string dep_id = diag.remediation->facts.value("dependency_id", ""); + auto candidate_node_ids = diag.remediation->facts.value( + "candidate_node_ids", std::vector{}); + if (diag.path.rfind(prefix, 0) == 0 && c_node.contains("depends_on") && + c_node["depends_on"].is_array() && !candidate_node_ids.empty()) { + for (const auto& cand_id : candidate_node_ids) { + ValidationFix fix; + fix.id = "replace-dependency-" + std::to_string(++fix_counter); + fix.title = "将依赖 '" + dep_id + "' 更改为 '" + cand_id + "'"; + fix.effect = "更正依赖为已知节点 '" + cand_id + "'。"; + fix.patch = nlohmann::json::array( + {{{"op", "test"}, {"path", diag.path}, {"value", dep_id}}, + {{"op", "replace"}, {"path", diag.path}, {"value", cand_id}}}); + candidate_fixes.push_back(std::move(fix)); + } + } + } + + size_t verified_for_diag = 0; + for (auto& fix : candidate_fixes) { + if (total_verification_attempts >= kMaxVerificationAttempts || + total_fixes_accepted >= kMaxFixesPerReport) { + break; + } + if (verified_for_diag >= kMaxFixesPerDiagnostic) break; + + nlohmann::json patched_root = root; + bool patch_ok = false; + try { + patched_root = patched_root.patch(fix.patch); + patch_ok = true; + } catch (...) { + patch_ok = false; + } + if (!patch_ok) continue; + + total_verification_attempts++; + ValidationReport new_report = + ValidateWithRemediation(patched_root, io_boundary); + if (new_report.ok) { + fix.verification = "pipeline_valid"; + diag.remediation->fixes.push_back(std::move(fix)); + total_fixes_accepted++; + verified_for_diag++; + } else { + bool target_still_present = false; + const auto target_id = GetDiagnosticIdentity(diag); + for (const auto& nd : new_report.diagnostics) { + if (GetDiagnosticIdentity(nd) == target_id) { + target_still_present = true; + break; + } + } + if (!target_still_present) { + bool has_new_error = false; + for (const auto& nd : new_report.diagnostics) { + const auto nd_id = GetDiagnosticIdentity(nd); + if (std::none_of( + orig_identities.begin(), orig_identities.end(), + [&](const auto& od_id) { return od_id == nd_id; })) { + has_new_error = true; + break; + } + } + if (!has_new_error) { + bool candidate_model_has_error = false; + if (diag.remediation->cause == + RemediationCause::kUnknownModelReference || + diag.remediation->cause == + RemediationCause::kModelCapabilityMismatch) { + std::string cand_mid; + for (const auto& op : fix.patch) { + if (op.value("op", "") == "replace") { + cand_mid = op.value("value", ""); + break; + } + } + if (patched_root.contains("models") && + patched_root["models"].is_array()) { + for (size_t m_idx = 0; m_idx < patched_root["models"].size(); + ++m_idx) { + if (patched_root["models"][m_idx].value("model_id", "") == + cand_mid) { + std::string m_pfx = "/models/" + std::to_string(m_idx); + for (const auto& nd : new_report.diagnostics) { + if (nd.path == m_pfx || + nd.path.rfind(m_pfx + "/", 0) == 0) { + candidate_model_has_error = true; + break; + } + } + break; + } + } + } + } + if (!candidate_model_has_error) { + fix.verification = "target_resolved"; + diag.remediation->fixes.push_back(std::move(fix)); + total_fixes_accepted++; + verified_for_diag++; + } + } + } + } + } + } + + return report; +} + +} // namespace llm_edgeflow diff --git a/src/cli/pipeline_remediation.h b/src/cli/pipeline_remediation.h new file mode 100644 index 00000000..e8f608b4 --- /dev/null +++ b/src/cli/pipeline_remediation.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +#include "core/pipeline_validator.h" + +namespace llm_edgeflow { + +// Developer-tool guidance layered on Validator diagnostics. Rules still come +// only from PipelineValidator; this code classifies its diagnostics, adds +// facts and a summary, and proposes JSON Patch fixes that the Validator +// confirms on the patched document. It is not compiled into the SDK. + +// Adds remediation cause, facts and summary to each diagnostic of report. +void AttachRemediation(const nlohmann::json& root, ValidationReport* report); + +ValidationReport ValidateWithRemediation( + const nlohmann::json& root, + const PipelineIoBoundary* io_boundary = nullptr); + +// Also keeps up to three verified fixes per diagnostic. +ValidationReport ExplainPipeline( + const nlohmann::json& root, + const PipelineIoBoundary* io_boundary = nullptr); + +} // namespace llm_edgeflow diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index ff354b6b..e82ec5ad 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -1,5 +1,6 @@ target_sources(edgeflow_orchestration_objects PRIVATE diagnostic_code.cpp + name_suggestions.cpp node_definition_validation.cpp node_registry.cpp pipeline.cpp diff --git a/src/core/name_suggestions.cpp b/src/core/name_suggestions.cpp new file mode 100644 index 00000000..5c69331c --- /dev/null +++ b/src/core/name_suggestions.cpp @@ -0,0 +1,57 @@ +#include "name_suggestions.h" + +#include +#include + +namespace llm_edgeflow { + +size_t LevenshteinDistance(std::string_view s1, std::string_view s2) { + const size_t m = s1.size(); + const size_t n = s2.size(); + std::vector dp(n + 1); + for (size_t j = 0; j <= n; ++j) dp[j] = j; + for (size_t i = 1; i <= m; ++i) { + size_t prev = dp[0]; + dp[0] = i; + for (size_t j = 1; j <= n; ++j) { + size_t temp = dp[j]; + if (s1[i - 1] == s2[j - 1]) { + dp[j] = prev; + } else { + dp[j] = 1 + std::min({prev, dp[j], dp[j - 1]}); + } + prev = temp; + } + } + return dp[n]; +} + +std::vector RankByEditDistance(std::string_view target, + std::vector names) { + std::vector> ranked; + for (auto& name : names) { + const size_t distance = LevenshteinDistance(target, name); + ranked.emplace_back(distance, std::move(name)); + } + std::sort(ranked.begin(), ranked.end()); + names.clear(); + for (auto& item : ranked) names.push_back(std::move(item.second)); + return names; +} + +std::vector NearestNames(std::string_view target, + std::vector names, + size_t limit) { + auto ranked = RankByEditDistance(target, std::move(names)); + std::vector nearest; + const size_t threshold = std::max(size_t{2}, target.size() / 3); + for (auto& name : ranked) { + if (nearest.size() == limit || + LevenshteinDistance(target, name) > threshold) + break; + nearest.push_back(std::move(name)); + } + return nearest; +} + +} // namespace llm_edgeflow diff --git a/src/core/name_suggestions.h b/src/core/name_suggestions.h new file mode 100644 index 00000000..8ccd1a8a --- /dev/null +++ b/src/core/name_suggestions.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include +#include +#include + +namespace llm_edgeflow { + +size_t LevenshteinDistance(std::string_view s1, std::string_view s2); + +// Orders names by edit distance; ties use the name for deterministic output. +std::vector RankByEditDistance(std::string_view target, + std::vector names); + +// Up to `limit` names close enough to `target` to be a likely typo. +std::vector NearestNames(std::string_view target, + std::vector names, + size_t limit = 3); + +} // namespace llm_edgeflow diff --git a/src/core/pipeline_validator.cpp b/src/core/pipeline_validator.cpp index 66819950..554154dc 100644 --- a/src/core/pipeline_validator.cpp +++ b/src/core/pipeline_validator.cpp @@ -14,61 +14,12 @@ #include "core/pipeline_config.h" #include "engine/backend_registry.h" #include "engine/model_registry.h" +#include "name_suggestions.h" #include "pipeline_config_structure.h" namespace llm_edgeflow { namespace { -size_t LevenshteinDistance(std::string_view s1, std::string_view s2) { - const size_t m = s1.size(); - const size_t n = s2.size(); - std::vector dp(n + 1); - for (size_t j = 0; j <= n; ++j) dp[j] = j; - for (size_t i = 1; i <= m; ++i) { - size_t prev = dp[0]; - dp[0] = i; - for (size_t j = 1; j <= n; ++j) { - size_t temp = dp[j]; - if (s1[i - 1] == s2[j - 1]) { - dp[j] = prev; - } else { - dp[j] = 1 + std::min({prev, dp[j], dp[j - 1]}); - } - prev = temp; - } - } - return dp[n]; -} - -// Orders names by edit distance; ties use the name for deterministic output. -std::vector RankByEditDistance(std::string_view target, - std::vector names) { - std::vector> ranked; - for (auto& name : names) { - const size_t distance = LevenshteinDistance(target, name); - ranked.emplace_back(distance, std::move(name)); - } - std::sort(ranked.begin(), ranked.end()); - names.clear(); - for (auto& item : ranked) names.push_back(std::move(item.second)); - return names; -} - -std::vector NearestNames(std::string_view target, - std::vector names, - size_t limit = 3) { - auto ranked = RankByEditDistance(target, std::move(names)); - std::vector nearest; - const size_t threshold = std::max(size_t{2}, target.size() / 3); - for (auto& name : ranked) { - if (nearest.size() == limit || - LevenshteinDistance(target, name) > threshold) - break; - nearest.push_back(std::move(name)); - } - return nearest; -} - void Add(ValidationReport* report, DiagnosticCode code, std::string path, std::string message, std::string node_id = {}, std::string port = {}, std::vector related = {}, @@ -365,634 +316,6 @@ bool ResolveTopology(const std::vector& nodes, return !invalid_reference; } -bool ValueMatchesConfigKind(const nlohmann::json& value, ConfigValueKind kind) { - switch (kind) { - case ConfigValueKind::kString: - return value.is_string(); - case ConfigValueKind::kInteger: - return value.is_number_integer(); - case ConfigValueKind::kNumber: - return value.is_number(); - case ConfigValueKind::kBoolean: - return value.is_boolean(); - case ConfigValueKind::kObject: - return value.is_object(); - case ConfigValueKind::kArray: - return value.is_array(); - } - return false; -} - -struct DiagnosticIdentity { - DiagnosticCode code; - std::string node_id; - std::string port; - std::string subpath; - - bool operator==(const DiagnosticIdentity& other) const { - return code == other.code && node_id == other.node_id && - port == other.port && subpath == other.subpath; - } -}; - -DiagnosticIdentity GetDiagnosticIdentity(const ValidationDiagnostic& d) { - DiagnosticIdentity id; - id.code = d.code; - id.node_id = d.node_id; - id.port = d.port; - if (d.code == DiagnosticCode::kDuplicateDependency) { - if (!d.related_nodes.empty()) { - id.subpath = "/depends_on/" + d.related_nodes.front(); - } else if (d.remediation.has_value() && - d.remediation->facts.contains("dependency_id")) { - id.subpath = "/depends_on/" + - d.remediation->facts["dependency_id"].get(); - } else { - id.subpath = "/depends_on"; - } - } else if (d.path.rfind("/pipeline/", 0) == 0) { - size_t second_slash = d.path.find('/', 10); - if (second_slash != std::string::npos) { - id.subpath = d.path.substr(second_slash); - } else { - id.subpath = "/pipeline"; - } - } else { - id.subpath = d.path; - } - return id; -} - -void PopulateBasicRemediation(ValidationDiagnostic* diag, - const nlohmann::json& root, - const PipelineCatalogSnapshot& catalog) { - if (!diag || diag->remediation.has_value()) return; - if (!root.is_object()) return; - - if (diag->code == DiagnosticCode::kUnknownNodeType || - diag->code == DiagnosticCode::kUnknownModelType || - diag->code == DiagnosticCode::kUnknownBackend) { - const nlohmann::json::json_pointer pointer(diag->path); - if (!root.contains(pointer) || !root.at(pointer).is_string()) return; - const auto& item = root.at(nlohmann::json::json_pointer( - diag->path.substr(0, diag->path.rfind('/')))); - const std::string name = root.at(pointer).get(); - ValidationRemediation rem; - std::vector names; - std::string candidates_key; - std::string next_step; - if (diag->code == DiagnosticCode::kUnknownNodeType) { - rem.cause = RemediationCause::kUnknownNodeType; - rem.facts["node_type"] = name; - candidates_key = "candidate_node_types"; - for (const auto& def : catalog.nodes) names.push_back(def.node_type); - rem.summary = "节点 '" + diag->node_id + "' 的 node_type '" + name; - next_step = "新增的 Node 需要重新构建后才会注册。"; - } else { - const std::string model_id = item.value("model_id", ""); - rem.facts["model_id"] = model_id; - if (diag->code == DiagnosticCode::kUnknownModelType) { - rem.cause = RemediationCause::kUnknownModelType; - rem.facts["model_type"] = name; - candidates_key = "candidate_model_types"; - for (const auto& def : PipelineCatalog::Models()) - names.push_back(def.model_type); - std::sort(names.begin(), names.end()); - rem.facts["registered_model_types"] = names; - rem.summary = "模型 '" + model_id + "' 的 model_type '" + name; - } else { - rem.cause = RemediationCause::kUnknownBackend; - rem.facts["backend"] = name; - candidates_key = "candidate_backends"; - for (const auto& def : PipelineCatalog::Backends()) - names.push_back(def.backend_type); - std::sort(names.begin(), names.end()); - rem.facts["registered_backends"] = names; - rem.summary = "模型 '" + model_id + "' 的 backend '" + name; - next_step = "可选 Backend 需要在构建时启用。"; - } - } - diag->suggestions = NearestNames(name, std::move(names)); - rem.facts[candidates_key] = diag->suggestions; - rem.summary += "' 未在当前构建中注册。"; - if (!diag->suggestions.empty()) { - rem.summary += "相近的已注册类型:"; - for (size_t i = 0; i < diag->suggestions.size(); ++i) { - if (i) rem.summary += "、"; - rem.summary += diag->suggestions[i]; - } - rem.summary += "。"; - } - rem.summary += next_step; - diag->remediation = std::move(rem); - return; - } - - if (diag->code == DiagnosticCode::kUnknownConfigField) { - if (diag->path.rfind("/pipeline/", 0) == 0 && root.contains("pipeline") && - root["pipeline"].is_array()) { - size_t idx_end = diag->path.find('/', 10); - if (idx_end != std::string::npos) { - size_t p_idx = 0; - try { - p_idx = std::stoul(diag->path.substr(10, idx_end - 10)); - } catch (...) { - p_idx = static_cast(-1); - } - if (p_idx < root["pipeline"].size()) { - const auto& node_obj = root["pipeline"][p_idx]; - std::string node_type = node_obj.value("node_type", ""); - const auto* def = catalog.FindNode(node_type); - std::string prefix = - "/pipeline/" + std::to_string(p_idx) + "/config/"; - if (def && diag->path.rfind(prefix, 0) == 0) { - std::string field_name = diag->path.substr(prefix.size()); - ValidationRemediation rem; - rem.schema_version = 1; - rem.cause = RemediationCause::kUnknownConfigField; - rem.summary = "节点 '" + diag->node_id + "' 的配置包含未知字段 '" + - field_name + "'。"; - rem.facts["field"] = field_name; - - std::vector names; - for (const auto& field : def->config_fields) - names.push_back(field.name); - rem.facts["candidate_fields"] = - RankByEditDistance(field_name, std::move(names)); - diag->remediation = std::move(rem); - } - } - } - } - } else if (diag->code == DiagnosticCode::kMissingConfigField) { - if (diag->path.rfind("/pipeline/", 0) == 0 && root.contains("pipeline") && - root["pipeline"].is_array()) { - size_t idx_end = diag->path.find('/', 10); - if (idx_end != std::string::npos) { - size_t p_idx = 0; - try { - p_idx = std::stoul(diag->path.substr(10, idx_end - 10)); - } catch (...) { - p_idx = static_cast(-1); - } - if (p_idx < root["pipeline"].size()) { - const auto& node_obj = root["pipeline"][p_idx]; - std::string node_type = node_obj.value("node_type", ""); - const auto* def = catalog.FindNode(node_type); - std::string prefix = - "/pipeline/" + std::to_string(p_idx) + "/config/"; - std::string field_name; - if (diag->path.rfind(prefix, 0) == 0) { - field_name = diag->path.substr(prefix.size()); - } - if (def && !field_name.empty()) { - ValidationRemediation rem; - rem.schema_version = 1; - rem.cause = RemediationCause::kMissingConfigField; - rem.summary = "节点 '" + diag->node_id + "' 缺少必填配置字段 '" + - field_name + "'。"; - rem.facts["field"] = field_name; - auto cf_it = std::find_if( - def->config_fields.begin(), def->config_fields.end(), - [&](const auto& f) { return f.name == field_name; }); - if (cf_it != def->config_fields.end()) { - rem.facts["expected_type"] = ConfigValueKindName(cf_it->kind); - if (cf_it->minimum.has_value()) - rem.facts["minimum"] = *cf_it->minimum; - if (cf_it->maximum.has_value()) - rem.facts["maximum"] = *cf_it->maximum; - if (!cf_it->enum_values.empty()) - rem.facts["enum"] = cf_it->enum_values; - } - diag->remediation = std::move(rem); - } - } - } - } - } else if (diag->code == DiagnosticCode::kConfigFieldType || - diag->code == DiagnosticCode::kConfigFieldRange || - diag->code == DiagnosticCode::kConfigFieldEnum) { - if (diag->path.rfind("/pipeline/", 0) == 0 && root.contains("pipeline") && - root["pipeline"].is_array()) { - size_t idx_end = diag->path.find('/', 10); - if (idx_end != std::string::npos) { - size_t p_idx = 0; - try { - p_idx = std::stoul(diag->path.substr(10, idx_end - 10)); - } catch (...) { - p_idx = static_cast(-1); - } - if (p_idx < root["pipeline"].size()) { - const auto& node_obj = root["pipeline"][p_idx]; - std::string node_type = node_obj.value("node_type", ""); - const auto* def = catalog.FindNode(node_type); - std::string prefix = - "/pipeline/" + std::to_string(p_idx) + "/config/"; - std::string field_name; - if (diag->path.rfind(prefix, 0) == 0) { - field_name = diag->path.substr(prefix.size()); - } - if (def && !field_name.empty()) { - ValidationRemediation rem; - rem.schema_version = 1; - rem.cause = RemediationCause::kInvalidConfigValue; - rem.summary = "节点 '" + diag->node_id + "' 的配置项 '" + - field_name + "' 值不合法。"; - rem.facts["field"] = field_name; - auto cf_it = std::find_if( - def->config_fields.begin(), def->config_fields.end(), - [&](const auto& f) { return f.name == field_name; }); - if (cf_it != def->config_fields.end()) { - rem.facts["expected_type"] = ConfigValueKindName(cf_it->kind); - if (cf_it->minimum.has_value()) - rem.facts["minimum"] = *cf_it->minimum; - if (cf_it->maximum.has_value()) - rem.facts["maximum"] = *cf_it->maximum; - if (!cf_it->enum_values.empty()) - rem.facts["enum"] = cf_it->enum_values; - } - diag->remediation = std::move(rem); - } - } - } - } - } else if (diag->code == DiagnosticCode::kUnknownModelReference || - diag->code == DiagnosticCode::kModelCapabilityMismatch) { - if (diag->path.rfind("/pipeline/", 0) == 0 && root.contains("pipeline") && - root["pipeline"].is_array()) { - size_t idx_end = diag->path.find('/', 10); - if (idx_end != std::string::npos) { - size_t p_idx = 0; - try { - p_idx = std::stoul(diag->path.substr(10, idx_end - 10)); - } catch (...) { - p_idx = static_cast(-1); - } - if (p_idx < root["pipeline"].size()) { - const auto& node_obj = root["pipeline"][p_idx]; - std::string node_type = node_obj.value("node_type", ""); - const auto* def = catalog.FindNode(node_type); - if (def) { - std::string expected_prefix = - "/pipeline/" + std::to_string(p_idx) + "/config/"; - for (const auto& dep : def->model_dependencies) { - if (diag->path != - expected_prefix + EscapeJsonPointer(dep.config_field)) { - continue; - } - std::string model_id = - (node_obj.contains("config") && - node_obj["config"].contains(dep.config_field) && - node_obj["config"][dep.config_field].is_string()) - ? node_obj["config"][dep.config_field].get() - : ""; - std::string req_cap = dep.capability; - - ValidationRemediation rem; - rem.schema_version = 1; - rem.cause = (diag->code == DiagnosticCode::kUnknownModelReference) - ? RemediationCause::kUnknownModelReference - : RemediationCause::kModelCapabilityMismatch; - rem.facts["model_id"] = model_id; - rem.facts["required_capability"] = req_cap; - - std::vector candidate_model_ids; - if (root.contains("models") && root["models"].is_array()) { - for (size_t m_idx = 0; m_idx < root["models"].size(); ++m_idx) { - const auto& m = root["models"][m_idx]; - if (!m.is_object()) continue; - std::string mid = m.value("model_id", ""); - if (mid.empty()) continue; - const auto model_def = - PipelineCatalog::FindModel(m.value("model_type", "")); - if (model_def && model_def->capability == req_cap) { - candidate_model_ids.push_back(mid); - } - } - } - std::sort(candidate_model_ids.begin(), candidate_model_ids.end()); - rem.facts["candidate_model_ids"] = candidate_model_ids; - rem.summary = "节点 '" + diag->node_id + "' 引用的模型 '" + - model_id + "' 与所需能力 '" + req_cap + - "' 不符或未声明。"; - diag->remediation = std::move(rem); - break; - } - } - } - } - } - } else if (diag->code == DiagnosticCode::kMissingInputProducer) { - size_t consumer_idx = static_cast(-1); - if (diag->path.rfind("/pipeline/", 0) == 0 && root.contains("pipeline") && - root["pipeline"].is_array()) { - size_t idx_end = diag->path.find('/', 10); - std::string idx_str = (idx_end == std::string::npos) - ? diag->path.substr(10) - : diag->path.substr(10, idx_end - 10); - try { - consumer_idx = std::stoul(idx_str); - } catch (...) { - consumer_idx = static_cast(-1); - } - } - - if (consumer_idx < root.value("pipeline", nlohmann::json::array()).size()) { - const auto& consumer_node = root["pipeline"][consumer_idx]; - std::string consumer_type = consumer_node.value("node_type", ""); - const auto* consumer_def = catalog.FindNode(consumer_type); - std::string port_name = diag->port; - std::string bound_key; - if (consumer_node.contains("inputs") && - consumer_node["inputs"].contains(port_name)) { - bound_key = consumer_node["inputs"][port_name].get(); - } - - std::string expected_type; - PortContract expected_contract; - if (consumer_def) { - for (const auto& inp : consumer_def->inputs) { - if (inp.logical_name == port_name) { - expected_type = inp.type_id; - expected_contract = inp; - break; - } - } - } - - int producer_idx = -1; - std::string producer_id; - std::string producer_out_type; - PortContract producer_contract; - - for (size_t p = 0; p < root["pipeline"].size(); ++p) { - if (p == consumer_idx) continue; - const auto& p_node = root["pipeline"][p]; - std::string p_id = p_node.value("id", ""); - std::string p_type = p_node.value("node_type", ""); - const auto* p_def = catalog.FindNode(p_type); - if (!p_def) continue; - for (const auto& out : p_def->outputs) { - std::string actual_out_key = out.logical_name; - if (p_node.contains("outputs") && - p_node["outputs"].contains(out.logical_name)) { - actual_out_key = - p_node["outputs"][out.logical_name].get(); - } - if (actual_out_key == bound_key) { - producer_idx = static_cast(p); - producer_id = p_id; - producer_out_type = out.type_id; - producer_contract = out; - break; - } - } - if (producer_idx >= 0) break; - } - - if (producer_idx >= 0 && producer_out_type != expected_type) { - ValidationRemediation rem; - rem.schema_version = 1; - rem.cause = RemediationCause::kPortTypeMismatch; - rem.summary = "生产者 '" + producer_id + "' 输出类型与端口 '" + - port_name + "' 要求不符。"; - rem.facts["bound_key"] = bound_key; - rem.facts["producer_id"] = producer_id; - rem.facts["expected"] = { - {"type_id", expected_contract.type_id}, - {"cardinality", expected_contract.cardinality}, - {"provenance_policy", expected_contract.provenance_policy}, - {"lifetime", expected_contract.lifetime}}; - rem.facts["actual"] = { - {"type_id", producer_contract.type_id}, - {"cardinality", producer_contract.cardinality}, - {"provenance_policy", producer_contract.provenance_policy}, - {"lifetime", producer_contract.lifetime}}; - diag->remediation = std::move(rem); - } else { - ValidationRemediation rem; - rem.schema_version = 1; - rem.cause = RemediationCause::kNoCompatibleInputSource; - rem.summary = - bound_key.empty() - ? "请为必需输入端口 '" + port_name + "' 明确指定数据来源。" - : "Pipeline 中没有为端口 '" + port_name + "' (绑定键: '" + - bound_key + "') 提供唯一匹配类型的生产者。"; - rem.facts["bound_key"] = bound_key; - rem.facts["expected_type"] = expected_type; - rem.facts["candidate_node_types"] = diag->suggestions; - diag->remediation = std::move(rem); - } - } - } else if (diag->code == DiagnosticCode::kDuplicateDependency) { - size_t consumer_idx = static_cast(-1); - if (diag->path.rfind("/pipeline/", 0) == 0 && root.contains("pipeline") && - root["pipeline"].is_array()) { - size_t idx_end = diag->path.find('/', 10); - std::string idx_str = (idx_end == std::string::npos) - ? diag->path.substr(10) - : diag->path.substr(10, idx_end - 10); - try { - consumer_idx = std::stoul(idx_str); - } catch (...) { - consumer_idx = static_cast(-1); - } - } - if (consumer_idx < root.value("pipeline", nlohmann::json::array()).size()) { - const auto& c_node = root["pipeline"][consumer_idx]; - std::string prefix = - "/pipeline/" + std::to_string(consumer_idx) + "/depends_on/"; - std::string dup_dep; - if (diag->path.rfind(prefix, 0) == 0 && c_node.contains("depends_on") && - c_node["depends_on"].is_array()) { - try { - size_t dep_arr_idx = std::stoul(diag->path.substr(prefix.size())); - if (dep_arr_idx < c_node["depends_on"].size()) { - dup_dep = c_node["depends_on"][dep_arr_idx].get(); - } - } catch (...) { - } - } - if (!dup_dep.empty()) { - ValidationRemediation rem; - rem.schema_version = 1; - rem.cause = RemediationCause::kDuplicateDependency; - rem.summary = - "节点 '" + diag->node_id + "' 包含重复依赖 '" + dup_dep + "'。"; - rem.facts["dependency_id"] = dup_dep; - diag->remediation = std::move(rem); - } - } - } else if (diag->code == DiagnosticCode::kInvalidDependency) { - size_t consumer_idx = static_cast(-1); - if (diag->path.rfind("/pipeline/", 0) == 0 && root.contains("pipeline") && - root["pipeline"].is_array()) { - size_t idx_end = diag->path.find('/', 10); - std::string idx_str = (idx_end == std::string::npos) - ? diag->path.substr(10) - : diag->path.substr(10, idx_end - 10); - try { - consumer_idx = std::stoul(idx_str); - } catch (...) { - consumer_idx = static_cast(-1); - } - } - if (consumer_idx < root.value("pipeline", nlohmann::json::array()).size()) { - const auto& c_node = root["pipeline"][consumer_idx]; - std::string prefix = - "/pipeline/" + std::to_string(consumer_idx) + "/depends_on/"; - std::string dep_id; - if (diag->path.rfind(prefix, 0) == 0 && c_node.contains("depends_on") && - c_node["depends_on"].is_array()) { - try { - size_t dep_arr_idx = std::stoul(diag->path.substr(prefix.size())); - if (dep_arr_idx < c_node["depends_on"].size()) { - dep_id = c_node["depends_on"][dep_arr_idx].get(); - } - } catch (...) { - } - } - - std::vector> ranked; - for (const auto& item : root["pipeline"]) { - std::string valid_id = item.value("id", ""); - if (!valid_id.empty() && valid_id != diag->node_id) { - ranked.emplace_back(LevenshteinDistance(dep_id, valid_id), valid_id); - } - } - std::sort(ranked.begin(), ranked.end(), [](const auto& a, const auto& b) { - if (a.first != b.first) return a.first < b.first; - return a.second < b.second; - }); - std::vector candidate_node_ids; - for (const auto& r : ranked) { - candidate_node_ids.push_back(r.second); - } - - ValidationRemediation rem; - rem.schema_version = 1; - rem.cause = RemediationCause::kUnknownDependency; - rem.summary = - "节点 '" + diag->node_id + "' 依赖了未知的节点 ID '" + dep_id + "'。"; - rem.facts["dependency_id"] = dep_id; - rem.facts["candidate_node_ids"] = candidate_node_ids; - diag->remediation = std::move(rem); - } - } else if (diag->code == DiagnosticCode::kMissingBizOutput) { - ValidationRemediation rem; - rem.schema_version = 1; - rem.cause = RemediationCause::kMissingBizOutput; - rem.summary = "Pipeline 未产出 biz '" + root.value("biz_name", "") + - "' 所需的输出 '" + diag->port + "'。"; - rem.facts["biz_name"] = root.value("biz_name", ""); - rem.facts["bound_key"] = diag->port; - const auto* biz = catalog.FindBiz(root.value("biz_name", "")); - if (biz) { - for (const auto& eg : biz->egress) { - if (eg.blackboard_key == diag->port) { - rem.facts["expected_type"] = eg.type_id; - break; - } - } - } - diag->remediation = std::move(rem); - } else if (diag->code == DiagnosticCode::kPortCardinalityMismatch || - diag->code == DiagnosticCode::kPortProvenanceMismatch || - diag->code == DiagnosticCode::kPortLifetimeMismatch) { - size_t consumer_idx = static_cast(-1); - if (diag->path.rfind("/pipeline/", 0) == 0 && root.contains("pipeline") && - root["pipeline"].is_array()) { - size_t idx_end = diag->path.find('/', 10); - std::string idx_str = (idx_end == std::string::npos) - ? diag->path.substr(10) - : diag->path.substr(10, idx_end - 10); - try { - consumer_idx = std::stoul(idx_str); - } catch (...) { - consumer_idx = static_cast(-1); - } - } - if (consumer_idx < root.value("pipeline", nlohmann::json::array()).size()) { - const auto& consumer_node = root["pipeline"][consumer_idx]; - std::string consumer_type = consumer_node.value("node_type", ""); - const auto* consumer_def = catalog.FindNode(consumer_type); - std::string port_name = diag->port; - std::string bound_key; - if (consumer_node.contains("inputs") && - consumer_node["inputs"].contains(port_name)) { - bound_key = consumer_node["inputs"][port_name].get(); - } - - PortContract expected_contract; - if (consumer_def) { - for (const auto& inp : consumer_def->inputs) { - if (inp.logical_name == port_name) { - expected_contract = inp; - break; - } - } - } - - std::string producer_id = - diag->related_nodes.empty() ? "" : diag->related_nodes[0]; - PortContract producer_contract; - - if (producer_id == "$ingress") { - const auto* biz = catalog.FindBiz(root.value("biz_name", "")); - if (biz) { - for (const auto& ing : biz->ingress) { - if (ing.blackboard_key == bound_key) { - producer_contract = ing; - break; - } - } - } - } else { - for (const auto& p_node : root["pipeline"]) { - if (p_node.value("id", "") == producer_id) { - std::string p_type = p_node.value("node_type", ""); - const auto* p_def = catalog.FindNode(p_type); - if (p_def) { - for (const auto& out : p_def->outputs) { - std::string actual_out_key = out.logical_name; - if (p_node.contains("outputs") && - p_node["outputs"].contains(out.logical_name)) { - actual_out_key = - p_node["outputs"][out.logical_name].get(); - } - if (actual_out_key == bound_key) { - producer_contract = out; - break; - } - } - } - break; - } - } - } - - ValidationRemediation rem; - rem.schema_version = 1; - rem.cause = RemediationCause::kPortFlowMismatch; - rem.summary = "生产者 '" + producer_id + "' 与消费者 '" + diag->node_id + - "' 在端口 '" + port_name + "' 上的流契约不兼容。"; - rem.facts["bound_key"] = bound_key; - rem.facts["producer_id"] = producer_id; - rem.facts["expected"] = { - {"type_id", expected_contract.type_id}, - {"cardinality", expected_contract.cardinality}, - {"provenance_policy", expected_contract.provenance_policy}, - {"lifetime", expected_contract.lifetime}}; - rem.facts["actual"] = { - {"type_id", producer_contract.type_id}, - {"cardinality", producer_contract.cardinality}, - {"provenance_policy", producer_contract.provenance_policy}, - {"lifetime", producer_contract.lifetime}}; - diag->remediation = std::move(rem); - } - } -} - ValidatedPipelinePlan ValidateAndPlanInternal( const nlohmann::json& root, const PipelineCatalogSnapshot& catalog, const PipelineIoBoundary* io_boundary = nullptr) { @@ -1000,9 +323,6 @@ ValidatedPipelinePlan ValidateAndPlanInternal( ValidationReport& report = plan.report; auto finish_plan = [&](ValidatedPipelinePlan& p) { - for (auto& diag : p.report.diagnostics) { - PopulateBasicRemediation(&diag, root, catalog); - } p.report.ok = p.report.diagnostics.empty(); }; @@ -1044,18 +364,26 @@ ValidatedPipelinePlan ValidateAndPlanInternal( auto model_def_opt = ModelRegistry::Instance().Find(model.model_type); if (!model_def_opt) { unresolved_model_ids.insert(model.model_id); + std::vector model_types; + for (const auto& def : PipelineCatalog::Models()) + model_types.push_back(def.model_type); Add(&report, DiagnosticCode::kUnknownModelType, "/models/" + std::to_string(model.source_index) + "/model_type", - "Unknown model_type: " + model.model_type); + "Unknown model_type: " + model.model_type, {}, {}, {}, + NearestNames(model.model_type, std::move(model_types))); } else { model_capabilities[model.model_id] = model_def_opt->capability; } auto backend_def_opt = BackendRegistry::Instance().Find(model.backend); if (!backend_def_opt) { + std::vector backends; + for (const auto& def : PipelineCatalog::Backends()) + backends.push_back(def.backend_type); Add(&report, DiagnosticCode::kUnknownBackend, "/models/" + std::to_string(model.source_index) + "/backend", - "Unknown backend: " + model.backend); + "Unknown backend: " + model.backend, {}, {}, {}, + NearestNames(model.backend, std::move(backends))); } if (model_def_opt && backend_def_opt) { @@ -1173,10 +501,12 @@ ValidatedPipelinePlan ValidateAndPlanInternal( node_by_id[node.id] = &node; const auto* definition = catalog.FindNode(node.node_type); if (!definition) { + std::vector node_types; + for (const auto& def : catalog.nodes) node_types.push_back(def.node_type); Add(&report, DiagnosticCode::kUnknownNodeType, "/pipeline/" + std::to_string(node.source_index) + "/node_type", "Unknown node_type or missing catalog definition: " + node.node_type, - node.id); + node.id, {}, {}, NearestNames(node.node_type, std::move(node_types))); for (const auto& output : node.ports.outputs) unresolved_output_keys.insert(output.second); continue; @@ -1816,248 +1146,4 @@ ValidationReport PipelineValidator::Validate( return ValidateAndPlan(root, io_boundary).report; } -ValidationReport PipelineValidator::Explain( - const nlohmann::json& root, const PipelineIoBoundary* io_boundary) { - const auto catalog = PipelineCatalog::Snapshot(); - ValidationReport report = - ValidateAndPlanInternal(root, catalog, io_boundary).report; - if (report.ok) { - return report; - } - - if (!root.is_object() || !root.contains("pipeline") || - !root["pipeline"].is_array()) { - return report; - } - - constexpr size_t kMaxVerificationAttempts = 8; - constexpr size_t kMaxFixesPerDiagnostic = 3; - constexpr size_t kMaxFixesPerReport = 8; - size_t total_verification_attempts = 0; - size_t total_fixes_accepted = 0; - size_t fix_counter = 0; - - std::vector orig_identities; - orig_identities.reserve(report.diagnostics.size()); - for (const auto& d : report.diagnostics) { - orig_identities.push_back(GetDiagnosticIdentity(d)); - } - - for (auto& diag : report.diagnostics) { - if (total_verification_attempts >= kMaxVerificationAttempts || - total_fixes_accepted >= kMaxFixesPerReport) { - break; - } - if (!diag.remediation.has_value()) continue; - - std::vector candidate_fixes; - - if (diag.remediation->cause == RemediationCause::kUnknownConfigField) { - size_t idx_end = diag.path.find('/', 10); - size_t p_idx = std::stoul(diag.path.substr(10, idx_end - 10)); - const auto& node_obj = root["pipeline"][p_idx]; - std::string node_type = node_obj.value("node_type", ""); - const auto* def = catalog.FindNode(node_type); - std::string prefix = "/pipeline/" + std::to_string(p_idx) + "/config/"; - std::string field_name = diag.remediation->facts.value("field", ""); - if (def && node_obj.contains("config") && - node_obj["config"].is_object() && - node_obj["config"].contains(field_name)) { - const auto& original_val = node_obj["config"][field_name]; - auto candidate_fields = diag.remediation->facts.value( - "candidate_fields", std::vector{}); - for (const auto& cand : candidate_fields) { - if (node_obj["config"].contains(cand)) continue; - auto cf_it = - std::find_if(def->config_fields.begin(), def->config_fields.end(), - [&](const auto& f) { return f.name == cand; }); - if (cf_it != def->config_fields.end() && - ValueMatchesConfigKind(original_val, cf_it->kind)) { - ValidationFix fix; - fix.id = "rename-config-field-" + std::to_string(++fix_counter); - fix.title = "将 '" + field_name + "' 重命名为 '" + cand + "'"; - fix.effect = "更正字段名称为已知配置项 '" + cand + "'。"; - std::string from_path = prefix + EscapeJsonPointer(field_name); - std::string to_path = prefix + EscapeJsonPointer(cand); - fix.patch = nlohmann::json::array( - {{{"op", "test"}, {"path", from_path}, {"value", original_val}}, - {{"op", "move"}, {"from", from_path}, {"path", to_path}}}); - candidate_fixes.push_back(std::move(fix)); - } - } - } - } else if (diag.remediation->cause == - RemediationCause::kUnknownModelReference || - diag.remediation->cause == - RemediationCause::kModelCapabilityMismatch) { - size_t idx_end = diag.path.find('/', 10); - size_t p_idx = std::stoul(diag.path.substr(10, idx_end - 10)); - const auto& node_obj = root["pipeline"][p_idx]; - std::string node_type = node_obj.value("node_type", ""); - const auto* def = catalog.FindNode(node_type); - std::string model_id = diag.remediation->facts.value("model_id", ""); - auto candidate_model_ids = diag.remediation->facts.value( - "candidate_model_ids", std::vector{}); - if (def) { - std::string expected_prefix = - "/pipeline/" + std::to_string(p_idx) + "/config/"; - for (const auto& dep : def->model_dependencies) { - std::string model_path = - expected_prefix + EscapeJsonPointer(dep.config_field); - if (diag.path == model_path) { - for (const auto& cand_id : candidate_model_ids) { - if (cand_id == model_id) continue; - ValidationFix fix; - fix.id = "use-model-" + std::to_string(++fix_counter); - fix.title = "使用模型 '" + cand_id + "'"; - fix.effect = - "将模型引用从 '" + model_id + "' 更改为 '" + cand_id + "'。"; - fix.patch = nlohmann::json::array( - {{{"op", "test"}, {"path", model_path}, {"value", model_id}}, - {{"op", "replace"}, - {"path", model_path}, - {"value", cand_id}}}); - candidate_fixes.push_back(std::move(fix)); - } - break; - } - } - } - } else if (diag.remediation->cause == - RemediationCause::kDuplicateDependency) { - size_t idx_end = diag.path.find('/', 10); - size_t consumer_idx = std::stoul(diag.path.substr(10, idx_end - 10)); - const auto& c_node = root["pipeline"][consumer_idx]; - std::string dup_dep = diag.remediation->facts.value("dependency_id", ""); - std::string prefix = - "/pipeline/" + std::to_string(consumer_idx) + "/depends_on/"; - if (diag.path.rfind(prefix, 0) == 0 && c_node.contains("depends_on") && - c_node["depends_on"].is_array() && !dup_dep.empty()) { - ValidationFix fix; - fix.id = "remove-duplicate-dep-" + std::to_string(++fix_counter); - fix.title = "删除重复依赖 '" + dup_dep + "'"; - fix.effect = "移除对 '" + dup_dep + "' 的重复依赖声明。"; - fix.patch = nlohmann::json::array( - {{{"op", "test"}, {"path", diag.path}, {"value", dup_dep}}, - {{"op", "remove"}, {"path", diag.path}}}); - candidate_fixes.push_back(std::move(fix)); - } - } else if (diag.remediation->cause == - RemediationCause::kUnknownDependency) { - size_t idx_end = diag.path.find('/', 10); - size_t consumer_idx = std::stoul(diag.path.substr(10, idx_end - 10)); - const auto& c_node = root["pipeline"][consumer_idx]; - std::string prefix = - "/pipeline/" + std::to_string(consumer_idx) + "/depends_on/"; - std::string dep_id = diag.remediation->facts.value("dependency_id", ""); - auto candidate_node_ids = diag.remediation->facts.value( - "candidate_node_ids", std::vector{}); - if (diag.path.rfind(prefix, 0) == 0 && c_node.contains("depends_on") && - c_node["depends_on"].is_array() && !candidate_node_ids.empty()) { - for (const auto& cand_id : candidate_node_ids) { - ValidationFix fix; - fix.id = "replace-dependency-" + std::to_string(++fix_counter); - fix.title = "将依赖 '" + dep_id + "' 更改为 '" + cand_id + "'"; - fix.effect = "更正依赖为已知节点 '" + cand_id + "'。"; - fix.patch = nlohmann::json::array( - {{{"op", "test"}, {"path", diag.path}, {"value", dep_id}}, - {{"op", "replace"}, {"path", diag.path}, {"value", cand_id}}}); - candidate_fixes.push_back(std::move(fix)); - } - } - } - - size_t verified_for_diag = 0; - for (auto& fix : candidate_fixes) { - if (total_verification_attempts >= kMaxVerificationAttempts || - total_fixes_accepted >= kMaxFixesPerReport) { - break; - } - if (verified_for_diag >= kMaxFixesPerDiagnostic) break; - - nlohmann::json patched_root = root; - bool patch_ok = false; - try { - patched_root = patched_root.patch(fix.patch); - patch_ok = true; - } catch (...) { - patch_ok = false; - } - if (!patch_ok) continue; - - total_verification_attempts++; - ValidationReport new_report = - ValidateAndPlanInternal(patched_root, catalog, io_boundary).report; - if (new_report.ok) { - fix.verification = "pipeline_valid"; - diag.remediation->fixes.push_back(std::move(fix)); - total_fixes_accepted++; - verified_for_diag++; - } else { - bool target_still_present = false; - const auto target_id = GetDiagnosticIdentity(diag); - for (const auto& nd : new_report.diagnostics) { - if (GetDiagnosticIdentity(nd) == target_id) { - target_still_present = true; - break; - } - } - if (!target_still_present) { - bool has_new_error = false; - for (const auto& nd : new_report.diagnostics) { - const auto nd_id = GetDiagnosticIdentity(nd); - if (std::none_of( - orig_identities.begin(), orig_identities.end(), - [&](const auto& od_id) { return od_id == nd_id; })) { - has_new_error = true; - break; - } - } - if (!has_new_error) { - bool candidate_model_has_error = false; - if (diag.remediation->cause == - RemediationCause::kUnknownModelReference || - diag.remediation->cause == - RemediationCause::kModelCapabilityMismatch) { - std::string cand_mid; - for (const auto& op : fix.patch) { - if (op.value("op", "") == "replace") { - cand_mid = op.value("value", ""); - break; - } - } - if (patched_root.contains("models") && - patched_root["models"].is_array()) { - for (size_t m_idx = 0; m_idx < patched_root["models"].size(); - ++m_idx) { - if (patched_root["models"][m_idx].value("model_id", "") == - cand_mid) { - std::string m_pfx = "/models/" + std::to_string(m_idx); - for (const auto& nd : new_report.diagnostics) { - if (nd.path == m_pfx || - nd.path.rfind(m_pfx + "/", 0) == 0) { - candidate_model_has_error = true; - break; - } - } - break; - } - } - } - } - if (!candidate_model_has_error) { - fix.verification = "target_resolved"; - diag.remediation->fixes.push_back(std::move(fix)); - total_fixes_accepted++; - verified_for_diag++; - } - } - } - } - } - } - - return report; -} - } // namespace llm_edgeflow diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ea04f695..5c917bc1 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -31,7 +31,8 @@ add_executable(alg_pipeline_tool_test ${PROJECT_SOURCE_DIR}/src/cli/pipeline_document_validation.cpp $ $) -target_link_libraries(alg_pipeline_tool_test PRIVATE llm_edgeflow::internal_runtime) +target_link_libraries(alg_pipeline_tool_test PRIVATE llm_edgeflow::internal_runtime + edgeflow_pipeline_tooling) target_compile_definitions(alg_pipeline_tool_test PRIVATE LLM_EDGEFLOW_TOOL_HAS_TEST_REGISTRATIONS=1) target_include_directories(alg_pipeline_tool_test PRIVATE diff --git a/tests/RuntimeTests.cmake b/tests/RuntimeTests.cmake index 96bab0ac..b2eae89a 100644 --- a/tests/RuntimeTests.cmake +++ b/tests/RuntimeTests.cmake @@ -144,7 +144,8 @@ add_executable(edgeflow_test_tooling_runner $ $) target_link_libraries(edgeflow_test_tooling_runner PRIVATE - llm_edgeflow::internal_runtime GTest::gtest GTest::gtest_main) + llm_edgeflow::internal_runtime edgeflow_pipeline_tooling GTest::gtest + GTest::gtest_main) edgeflow_enable_test_pch(edgeflow_test_tooling_runner) if(LLM_EDGEFLOW_HAS_WHISPERCPP) diff --git a/tests/integration/pipeline/test_pipeline_catalog_validator.cpp b/tests/integration/pipeline/test_pipeline_catalog_validator.cpp index 0a74eb3a..fd6f3b12 100644 --- a/tests/integration/pipeline/test_pipeline_catalog_validator.cpp +++ b/tests/integration/pipeline/test_pipeline_catalog_validator.cpp @@ -14,6 +14,7 @@ #include "adapter/operator/operator_value_type_registry.h" #include "adapter/pipeline_document.h" #include "adapter/shared_algorithm_runtime.h" +#include "cli/pipeline_remediation.h" #include "core/common_contracts.h" #include "core/node_registry.h" #include "core/pipeline.h" @@ -110,8 +111,8 @@ TEST(PipelineValidatorTest, auto fixture = LoadRegistrationFixture( "demo/fixtures/mock/pipeline_entity_extract_custom.json"); fixture.neutral_pipeline_json["models"][0]["model_type"] = "test_biz_llmm"; - const auto report = PipelineValidator::Validate(fixture.neutral_pipeline_json, - &fixture.io_boundary); + const auto report = ValidateWithRemediation(fixture.neutral_pipeline_json, + &fixture.io_boundary); ASSERT_EQ(report.diagnostics.size(), 1u) << report.ToJson().dump(2); const auto* d = FindDiagnostic(report, DiagnosticCode::kUnknownModelType); ASSERT_NE(d, nullptr); @@ -136,8 +137,7 @@ TEST(PipelineValidatorTest, UndeclaredModelReferenceIsStillReported) { "demo/fixtures/mock/pipeline_entity_extract_custom.json"); fixture.neutral_pipeline_json["pipeline"][0]["config"]["bind_model"] = "undeclared"; - const auto report = - PipelineValidator::Validate(fixture.neutral_pipeline_json); + const auto report = ValidateWithRemediation(fixture.neutral_pipeline_json); EXPECT_NE(FindDiagnostic(report, DiagnosticCode::kUnknownModelReference, "/pipeline/0/config/bind_model"), nullptr); @@ -148,8 +148,7 @@ TEST(PipelineValidatorTest, UnknownBackendHasRemediation) { "demo/fixtures/mock/pipeline_entity_extract_custom.json"); fixture.neutral_pipeline_json["models"][0]["backend"] = "test_causal_lm_backnd"; - const auto report = - PipelineValidator::Validate(fixture.neutral_pipeline_json); + const auto report = ValidateWithRemediation(fixture.neutral_pipeline_json); ASSERT_EQ(report.diagnostics.size(), 1u) << report.ToJson().dump(2); const auto& d = report.diagnostics.front(); EXPECT_EQ(d.code, DiagnosticCode::kUnknownBackend); @@ -169,8 +168,8 @@ TEST(PipelineValidatorTest, UnknownNodeTypeSuppressesMissingOutputCascade) { LoadRegistrationFixture("configs/pipeline_keyword_match_rules.json"); fixture.neutral_pipeline_json["pipeline"][0]["node_type"] = "TextRuleMatchNod"; - const auto report = PipelineValidator::Validate(fixture.neutral_pipeline_json, - &fixture.io_boundary); + const auto report = ValidateWithRemediation(fixture.neutral_pipeline_json, + &fixture.io_boundary); ASSERT_EQ(report.diagnostics.size(), 1u) << report.ToJson().dump(2); const auto* d = FindDiagnostic(report, DiagnosticCode::kUnknownNodeType); ASSERT_NE(d, nullptr); @@ -186,8 +185,7 @@ TEST(PipelineValidatorTest, DistantUnknownNamesHaveNoSuggestions) { LoadRegistrationFixture("configs/pipeline_keyword_match_rules.json"); fixture.neutral_pipeline_json["pipeline"][0]["node_type"] = "ZZZZZZZZZZZZZZZZZZZZ"; - const auto report = - PipelineValidator::Validate(fixture.neutral_pipeline_json); + const auto report = ValidateWithRemediation(fixture.neutral_pipeline_json); const auto* d = FindDiagnostic(report, DiagnosticCode::kUnknownNodeType); ASSERT_NE(d, nullptr); ASSERT_TRUE(d->remediation.has_value()); @@ -201,8 +199,8 @@ TEST(PipelineValidatorTest, MissingBizOutputIsReportedOnce) { LoadRegistrationFixture("configs/pipeline_keyword_match_rules.json"); fixture.neutral_pipeline_json["pipeline"] = { {{"id", "probe"}, {"node_type", "StudioCatalogProbeNode"}}}; - const auto report = PipelineValidator::Validate(fixture.neutral_pipeline_json, - &fixture.io_boundary); + const auto report = ValidateWithRemediation(fixture.neutral_pipeline_json, + &fixture.io_boundary); const auto* d = FindDiagnostic(report, DiagnosticCode::kMissingBizOutput); ASSERT_NE(d, nullptr) << report.ToJson().dump(2); EXPECT_EQ(d->path, "/pipeline"); @@ -220,8 +218,7 @@ TEST(PipelineValidatorTest, UnknownConfigFieldSuggestionsAreRanked) { auto& config = fixture.neutral_pipeline_json["pipeline"][0]["config"]; config["categoriess"] = config["categories"]; config.erase("categories"); - const auto report = - PipelineValidator::Validate(fixture.neutral_pipeline_json); + const auto report = ValidateWithRemediation(fixture.neutral_pipeline_json); const auto* d = FindDiagnostic(report, DiagnosticCode::kUnknownConfigField); ASSERT_NE(d, nullptr); ASSERT_TRUE(d->remediation.has_value()); @@ -232,8 +229,8 @@ TEST(PipelineValidatorTest, UnknownConfigFieldSuggestionsAreRanked) { TEST(PipelineValidatorTest, UnknownNodeSoleProducerSuppressesConsumerCascade) { auto fixture = UnknownProducerFixture(); - const auto report = PipelineValidator::Validate(fixture.neutral_pipeline_json, - &fixture.io_boundary); + const auto report = ValidateWithRemediation(fixture.neutral_pipeline_json, + &fixture.io_boundary); ASSERT_EQ(report.diagnostics.size(), 1u) << report.ToJson().dump(2); EXPECT_EQ(report.diagnostics.front().code, DiagnosticCode::kUnknownNodeType); EXPECT_EQ(FindDiagnostic(report, DiagnosticCode::kMissingInputProducer), @@ -242,8 +239,8 @@ TEST(PipelineValidatorTest, UnknownNodeSoleProducerSuppressesConsumerCascade) { TEST(PipelineValidatorTest, UnknownNodeDoesNotHideKnownProducerTypeMismatch) { auto fixture = UnknownProducerFixture(1); - const auto report = PipelineValidator::Validate(fixture.neutral_pipeline_json, - &fixture.io_boundary); + const auto report = ValidateWithRemediation(fixture.neutral_pipeline_json, + &fixture.io_boundary); ASSERT_EQ(report.diagnostics.size(), 2u) << report.ToJson().dump(2); EXPECT_NE(FindDiagnostic(report, DiagnosticCode::kUnknownNodeType), nullptr); EXPECT_NE(FindDiagnostic(report, DiagnosticCode::kMissingInputProducer, @@ -253,8 +250,8 @@ TEST(PipelineValidatorTest, UnknownNodeDoesNotHideKnownProducerTypeMismatch) { TEST(PipelineValidatorTest, UnknownNodeDoesNotHideDuplicateProducers) { auto fixture = UnknownProducerFixture(2); - const auto report = PipelineValidator::Validate(fixture.neutral_pipeline_json, - &fixture.io_boundary); + const auto report = ValidateWithRemediation(fixture.neutral_pipeline_json, + &fixture.io_boundary); ASSERT_EQ(report.diagnostics.size(), 3u) << report.ToJson().dump(2); EXPECT_NE(FindDiagnostic(report, DiagnosticCode::kUnknownNodeType), nullptr); EXPECT_NE(FindDiagnostic(report, DiagnosticCode::kDuplicatePortProducer, @@ -274,8 +271,8 @@ TEST(PipelineValidatorTest, UnknownNodeDoesNotHideIngressTypeMismatch) { pipeline[2]["inputs"] = {{"queries", "input_sentences"}, {"candidates", "input_sentences"}}; pipeline[2]["outputs"] = nlohmann::json::object(); - const auto report = PipelineValidator::Validate(fixture.neutral_pipeline_json, - &fixture.io_boundary); + const auto report = ValidateWithRemediation(fixture.neutral_pipeline_json, + &fixture.io_boundary); EXPECT_NE(FindDiagnostic(report, DiagnosticCode::kUnknownNodeType), nullptr); EXPECT_NE(FindDiagnostic(report, DiagnosticCode::kMissingInputProducer, "/pipeline/2/inputs/queries"), @@ -290,8 +287,8 @@ TEST(PipelineValidatorTest, pipeline[1]["outputs"]["matches"] = "input_sentences"; pipeline[2]["outputs"]["matches"] = "input_sentences"; pipeline[3]["inputs"]["text"] = "input_sentences"; - const auto report = PipelineValidator::Validate(fixture.neutral_pipeline_json, - &fixture.io_boundary); + const auto report = ValidateWithRemediation(fixture.neutral_pipeline_json, + &fixture.io_boundary); ASSERT_EQ(report.diagnostics.size(), 5u) << report.ToJson().dump(2); EXPECT_NE(FindDiagnostic(report, DiagnosticCode::kUnknownNodeType), nullptr); EXPECT_NE(FindDiagnostic(report, DiagnosticCode::kDuplicatePortProducer, @@ -310,8 +307,8 @@ TEST(PipelineValidatorTest, UnknownNodeDoesNotHideUnrelatedMissingInput) { auto fixture = UnknownProducerFixture(); fixture.neutral_pipeline_json["pipeline"][2]["inputs"]["text"] = "unrelated_key"; - const auto report = PipelineValidator::Validate(fixture.neutral_pipeline_json, - &fixture.io_boundary); + const auto report = ValidateWithRemediation(fixture.neutral_pipeline_json, + &fixture.io_boundary); ASSERT_EQ(report.diagnostics.size(), 2u) << report.ToJson().dump(2); EXPECT_NE(FindDiagnostic(report, DiagnosticCode::kMissingInputProducer, "/pipeline/2/inputs/text"), @@ -326,8 +323,8 @@ TEST(PipelineValidatorTest, "TextRuleMatchNod"; fixture.neutral_pipeline_json["pipeline"][0]["outputs"] = nlohmann::json::object(); - const auto report = PipelineValidator::Validate(fixture.neutral_pipeline_json, - &fixture.io_boundary); + const auto report = ValidateWithRemediation(fixture.neutral_pipeline_json, + &fixture.io_boundary); ASSERT_EQ(report.diagnostics.size(), 2u) << report.ToJson().dump(2); EXPECT_NE( FindDiagnostic(report, DiagnosticCode::kMissingBizOutput, "/pipeline"), @@ -346,7 +343,7 @@ TEST(PipelineValidatorTest, UnknownModelTypeDoesNotHideCapabilityMismatch) { {"model_config", nlohmann::json::object()}, {"backend_config", nlohmann::json::object()}}); root["pipeline"][0]["config"]["bind_model"] = "known_embedding"; - const auto report = PipelineValidator::Validate(root, &fixture.io_boundary); + const auto report = ValidateWithRemediation(root, &fixture.io_boundary); ASSERT_EQ(report.diagnostics.size(), 2u) << report.ToJson().dump(2); EXPECT_NE(FindDiagnostic(report, DiagnosticCode::kUnknownModelType), nullptr); EXPECT_NE(FindDiagnostic(report, DiagnosticCode::kModelCapabilityMismatch, @@ -430,7 +427,7 @@ TEST(PipelineValidatorTest, AllRepositoryPipelinesValidate) { ++skipped_optional; continue; } - const auto direct_report = PipelineValidator::Validate(pipeline); + const auto direct_report = ValidateWithRemediation(pipeline); EXPECT_FALSE(direct_report.ok); EXPECT_TRUE(std::any_of( direct_report.diagnostics.begin(), direct_report.diagnostics.end(), @@ -439,7 +436,7 @@ TEST(PipelineValidatorTest, AllRepositoryPipelinesValidate) { diagnostic.path == "/deployment"; })); const auto neutral = PrepareExternalFixtureForCore(pipeline); - const auto report = PipelineValidator::Validate(neutral); + const auto report = ValidateWithRemediation(neutral); EXPECT_TRUE(report.ok) << entry.path() << "\n" << report.ToJson().dump(2); ++validated; } @@ -458,7 +455,7 @@ TEST(PipelineValidatorTest, RejectsRemovedRuleCategoriesField) { config["default_categories"] = config["categories"]; config.erase("categories"); - const auto report = PipelineValidator::Validate(pipeline); + const auto report = ValidateWithRemediation(pipeline); ASSERT_FALSE(report.ok); EXPECT_TRUE(std::any_of( report.diagnostics.begin(), report.diagnostics.end(), @@ -480,7 +477,7 @@ TEST(PipelineValidatorTest, ModelPathsUseLexicalChecksWithoutDeploymentRoots) { std::string("missing/../artifact.bin"), std::filesystem::absolute("missing/artifact.bin").string()}) { pipeline["models"][0]["model_path"] = safe_path; - const auto report = PipelineValidator::Validate(pipeline); + const auto report = ValidateWithRemediation(pipeline); EXPECT_TRUE(report.ok) << safe_path << "\n" << report.ToJson().dump(2); } @@ -488,7 +485,7 @@ TEST(PipelineValidatorTest, ModelPathsUseLexicalChecksWithoutDeploymentRoots) { {"..", "../artifact.bin", "missing/../../artifact.bin", "..\\artifact.bin"}) { pipeline["models"][0]["model_path"] = unsafe_path; - const auto report = PipelineValidator::Validate(pipeline); + const auto report = ValidateWithRemediation(pipeline); EXPECT_FALSE(report.ok) << unsafe_path; EXPECT_TRUE( std::any_of(report.diagnostics.begin(), report.diagnostics.end(), @@ -525,7 +522,7 @@ TEST(PipelineValidatorTest, ReportsConfigAndCapabilityErrors) { {{"id", "post"}, {"node_type", "StructuredJsonParseNode"}, {"depends_on", {"llm"}}}}}}; - const auto report = PipelineValidator::Validate(pipeline); + const auto report = ValidateWithRemediation(pipeline); EXPECT_FALSE(report.ok); std::set codes; for (const auto& diagnostic : report.diagnostics) @@ -620,7 +617,7 @@ TEST(PipelineValidatorTest, SerializedModelBranchesRunInSeparateLayers) { {"inputs", {{"text", "ans_left"}}}, {"outputs", {{"document", "extracted_entities"}}}}}}}; - const auto report = PipelineValidator::Validate(config); + const auto report = ValidateWithRemediation(config); ASSERT_TRUE(report.ok) << report.ToJson().dump(2); EXPECT_EQ(report.topological_layers, (std::vector>{ @@ -717,7 +714,7 @@ TEST(PipelineValidatorTest, WhisperPipelineValidationDependsOnBackend) { nlohmann::json pipeline; stream >> pipeline; pipeline = PrepareExternalFixtureForCore(pipeline); - const auto report = PipelineValidator::Validate(pipeline); + const auto report = ValidateWithRemediation(pipeline); #ifdef HAVE_WHISPERCPP EXPECT_TRUE(report.ok) << report.ToJson().dump(2); const auto plan = PipelineValidator::ValidateAndPlan(pipeline); @@ -760,7 +757,7 @@ TEST(PipelineValidatorTest, {"node_type", type}, {"depends_on", nlohmann::json::array()}, {"config", config}}); - const auto report = PipelineValidator::Validate(root); + const auto report = ValidateWithRemediation(root); EXPECT_FALSE(report.ok); EXPECT_TRUE( std::any_of(report.diagnostics.begin(), report.diagnostics.end(), @@ -824,7 +821,7 @@ TEST(PipelineValidatorTest, root = PrepareExternalFixtureForCore(root); for (auto& node : root["pipeline"]) node.erase("depends_on"); - const auto report = PipelineValidator::Explain(root); + const auto report = ExplainPipeline(root); EXPECT_TRUE(report.ok) << report.ToJson().dump(2); EXPECT_TRUE(report.diagnostics.empty()); const auto plan = PipelineValidator::ValidateAndPlan(root); @@ -845,7 +842,7 @@ TEST(PipelineValidatorTest, ExplainReturnsCandidateFixForUnknownConfigField) { root["pipeline"][0]["config"]["temprature"] = 0.1; root["pipeline"][0]["config"].erase("temperature"); - const auto report = PipelineValidator::Explain(root); + const auto report = ExplainPipeline(root); EXPECT_FALSE(report.ok); ASSERT_FALSE(report.diagnostics.empty()); @@ -889,7 +886,7 @@ TEST(PipelineValidatorTest, ExplainReturnsCandidateFixForUnknownConfigField) { // Applying patch recovers a fully valid pipeline const auto patched = root.patch(fix.patch); - const auto verified_report = PipelineValidator::Validate(patched); + const auto verified_report = ValidateWithRemediation(patched); EXPECT_TRUE(verified_report.ok) << verified_report.ToJson().dump(2); } @@ -916,7 +913,7 @@ TEST(PipelineValidatorTest, ExplainRespectsFixBounds) { {"config", {{"corpus", {"sample"}}}}}); } - const auto report = PipelineValidator::Explain(root); + const auto report = ExplainPipeline(root); EXPECT_FALSE(report.ok); size_t total_fixes = 0; @@ -942,7 +939,7 @@ TEST(PipelineValidatorTest, ExplainCleanPipelineReturnsOk) { stream >> root; root = PrepareExternalFixtureForCore(root); - const auto report = PipelineValidator::Explain(root); + const auto report = ExplainPipeline(root); EXPECT_TRUE(report.ok); EXPECT_TRUE(report.diagnostics.empty()); EXPECT_FALSE(report.topological_order.empty()); @@ -967,7 +964,7 @@ TEST(PipelineValidatorTest, ExplainTargetResolved) { root["pipeline"][1]["config"]["field_types"] = { {"field_x", "unsupported_type"}}; - const auto report = PipelineValidator::Explain(root); + const auto report = ExplainPipeline(root); EXPECT_FALSE(report.ok); ASSERT_GE(report.diagnostics.size(), 2U); @@ -1002,7 +999,7 @@ TEST(PipelineValidatorTest, ValidateProducesBasicRemediation) { // Explicitly connect an input to a key with no producer. root["pipeline"][1]["inputs"]["text"] = "missing_result"; - const auto report = PipelineValidator::Validate(root); + const auto report = ValidateWithRemediation(root); EXPECT_FALSE(report.ok); ASSERT_FALSE(report.diagnostics.empty()); @@ -1068,7 +1065,7 @@ TEST(PipelineValidatorTest, ExplainCapsVerificationAttemptsAtEight) { {"backend_config", nlohmann::json::object()}, }); - const auto report = PipelineValidator::Explain(root); + const auto report = ExplainPipeline(root); EXPECT_FALSE(report.ok); size_t total_fixes = 0; @@ -1111,7 +1108,7 @@ TEST(PipelineValidatorTest, ExplainRejectsInvalidModelCandidates) { {"temperature", 0.1}, {"max_tokens", 64}}}}}}}; - const auto report = PipelineValidator::Explain(root); + const auto report = ExplainPipeline(root); EXPECT_FALSE(report.ok); bool proposed_broken_model = false; @@ -1155,13 +1152,13 @@ TEST(PipelineValidatorTest, SplitItemsFeedItemWiseNodeButNotPerRequestEgress) { {"inputs", {{"text", "input_sentences"}}}, {"outputs", {{"matches", "rule_matches"}}}}}}}; - const auto accepted = PipelineValidator::Validate(root); + const auto accepted = ValidateWithRemediation(root); EXPECT_TRUE(accepted.ok) << accepted.ToJson().dump(); // Per-chunk matches cannot become the one-row-per-request biz output. root["pipeline"][1]["outputs"]["matches"] = "rule_matches"; root["pipeline"][2]["outputs"]["matches"] = "request_matches"; - const auto rejected = PipelineValidator::Validate(root); + const auto rejected = ValidateWithRemediation(root); ASSERT_FALSE(rejected.ok); ASSERT_EQ(rejected.diagnostics.size(), 1u) << rejected.ToJson().dump(); EXPECT_EQ(rejected.diagnostics[0].code, @@ -1186,7 +1183,7 @@ TEST(PipelineValidatorTest, ExplainReturnsPortFlowMismatchRemediation) { {"outputs", {{"embedding", "chunk_embeddings"}}}, {"config", {{"lifetime", "session"}}}}}}}; - const auto report = PipelineValidator::Validate(root); + const auto report = ValidateWithRemediation(root); EXPECT_FALSE(report.ok); ASSERT_FALSE(report.diagnostics.empty()); @@ -1242,7 +1239,7 @@ TEST(PipelineValidatorTest, {"depends_on", {"dep_a", "dep_a", "dep_b", "dep_b"}}, {"config", {{"corpus", {"c"}}}}}}}}; - const auto report = PipelineValidator::Explain(root); + const auto report = ExplainPipeline(root); EXPECT_FALSE(report.ok); const ValidationDiagnostic* diag_a = nullptr; diff --git a/tests/unit/core/test_validated_pipeline_plan.cpp b/tests/unit/core/test_validated_pipeline_plan.cpp index 177d3cc5..b6d51adb 100644 --- a/tests/unit/core/test_validated_pipeline_plan.cpp +++ b/tests/unit/core/test_validated_pipeline_plan.cpp @@ -472,8 +472,6 @@ TEST_F(PortShapeTest, MisalignedItemWiseInputsAreRejected) { EXPECT_EQ(errors[0].node_id, "pair"); EXPECT_EQ(errors[0].port, "right"); EXPECT_EQ(errors[0].related_nodes, std::vector({"split"})); - ASSERT_TRUE(errors[0].remediation.has_value()); - EXPECT_EQ(errors[0].remediation->cause, RemediationCause::kPortFlowMismatch); } TEST_F(PortShapeTest, ItemWiseInputsPairOnlyWithinOneFanOut) { From 5cae3eda8d97b88b637924f76b830c4fbde035c2 Mon Sep 17 00:00:00 2001 From: chamsechan Date: Sun, 4 Oct 2026 21:01:09 +0800 Subject: [PATCH 16/29] docs(plans): record why biz audit stays with graph validation for now Init-time binding audit already checks converter and biz keys and types; the Validator's remaining cross-checks cover flow contracts and converter-required optional outputs. Moving biz ownership to the Adapter would touch Catalog, CLI/Studio and biz_name without a driving defect. Co-Authored-By: Claude Opus 5.5 --- plans/ARCHITECTURE_REVIEW.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plans/ARCHITECTURE_REVIEW.md b/plans/ARCHITECTURE_REVIEW.md index 082d451a..9af42ff7 100644 --- a/plans/ARCHITECTURE_REVIEW.md +++ b/plans/ARCHITECTURE_REVIEW.md @@ -322,7 +322,7 @@ Definition、Catalog、Validator、节点运行时及工具的解释须一致。 | C2 | 端口声明与实例状态分离 | 第三节·能力节点层 | 不采纳:`REGISTER_FUNCTION_NODE` 每次构造实例都重新求值 Spec,不存在跨实例复制;模型与参数已在 `AuthorNode` 上,绑定内的实例状态只剩解析后的 key。拆出 key 需要为每类绑定增加并行的实例表并改动全部绑定接口,类型擦除仍然需要。Spec 需要跨实例共享或创建成本成为瓶颈时再设计 | | C3 | 模型调用类共用持有实现 | 第三节·能力节点层 | 已完成:5 个 `XxxCall` 继承 `detail::ModelCallBase`,共用持有、只移动语义和槽位标识;具名能力方法保留 | | C4 | 修复建议生成移到工具层 | 第三节·流程编排层 | 已完成:remediation 与修复复核移到 `src/cli/pipeline_remediation`,复核仍调用 Validator;未注册类型的相近名称留在 Core 诊断;15 份故障方案的 CLI 输出逐字节不变 | -| C5 | 业务审计与图校验分离 | 第三节·流程编排层 | 待评估 | +| C5 | 业务审计与图校验分离 | 第三节·流程编排层 | 不采纳:Init 时的绑定全量审计已检查转换器与业务端口的 key 和类型;Validator 中剩余的交叉检查负责流契约一致性,以及业务可选、但某个转换器必需的输出,需要转换器视图或组合后的边界。把业务定义移到 Adapter 会牵动 Catalog、CLI/Studio 和 Pipeline 文档的 `biz_name`,目前没有缺陷驱动;跨请求状态改变边界语义时再一并设计 | | C6 | Control 载荷复用 | 第三节·流程编排层 | 待评估 | | C7 | JSON 基础检查共用 | 第三节·流程编排层 | 待评估 | | C8 | 部署信息转存与命名 | 第三节·接入适配层 | 待评估 | From 2e6448f893a629cf7ea3d2515d133e92671aa98a Mon Sep 17 00:00:00 2001 From: chamsechan Date: Sun, 4 Oct 2026 21:01:22 +0800 Subject: [PATCH 17/29] docs(plans): record why Control payloads are still parsed per layer Control is a low-frequency update path, Core's broadcast precheck and a Node's direct-call validation have distinct duties, and reusing the parsed payload would change INode::Control for every node and test. Co-Authored-By: Claude Opus 5.5 --- plans/ARCHITECTURE_REVIEW.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plans/ARCHITECTURE_REVIEW.md b/plans/ARCHITECTURE_REVIEW.md index 9af42ff7..3e68465e 100644 --- a/plans/ARCHITECTURE_REVIEW.md +++ b/plans/ARCHITECTURE_REVIEW.md @@ -323,7 +323,7 @@ Definition、Catalog、Validator、节点运行时及工具的解释须一致。 | C3 | 模型调用类共用持有实现 | 第三节·能力节点层 | 已完成:5 个 `XxxCall` 继承 `detail::ModelCallBase`,共用持有、只移动语义和槽位标识;具名能力方法保留 | | C4 | 修复建议生成移到工具层 | 第三节·流程编排层 | 已完成:remediation 与修复复核移到 `src/cli/pipeline_remediation`,复核仍调用 Validator;未注册类型的相近名称留在 Core 诊断;15 份故障方案的 CLI 输出逐字节不变 | | C5 | 业务审计与图校验分离 | 第三节·流程编排层 | 不采纳:Init 时的绑定全量审计已检查转换器与业务端口的 key 和类型;Validator 中剩余的交叉检查负责流契约一致性,以及业务可选、但某个转换器必需的输出,需要转换器视图或组合后的边界。把业务定义移到 Adapter 会牵动 Catalog、CLI/Studio 和 Pipeline 文档的 `biz_name`,目前没有缺陷驱动;跨请求状态改变边界语义时再一并设计 | -| C6 | Control 载荷复用 | 第三节·流程编排层 | 待评估 | +| C6 | Control 载荷复用 | 第三节·流程编排层 | 不采纳:Control 是低频配置更新,第二次解析只增加微秒级开销;Core 的广播预检(任何节点更新前完成)与 Node 对直接调用的校验职责不同,都需保留。复用解析结果需要修改 `INode::Control` 签名及全部节点与测试,收益不足 | | C7 | JSON 基础检查共用 | 第三节·流程编排层 | 待评估 | | C8 | 部署信息转存与命名 | 第三节·接入适配层 | 待评估 | | C9 | 契约元数据减少重复填写 | 第三节·接入适配层 | 待评估 | From da70557a679b9c894222e158e4ff7fb2daad5908 Mon Sep 17 00:00:00 2001 From: chamsechan Date: Sun, 4 Oct 2026 21:09:29 +0800 Subject: [PATCH 18/29] refactor(contracts): reuse JSON Schema keyword predicates for Control payloads Control payload validation re-implemented the type, enum, minimum and maximum keyword checks already provided by json_structure. It now calls those predicates and keeps its own messages and error contract. Config field validation stays separate because its typed definitions, default filling and diagnostic paths differ. Co-Authored-By: Claude Opus 5.5 --- include/contracts/control_payload.h | 25 ++++++++----------------- include/contracts/json_structure.h | 5 +++-- plans/ARCHITECTURE_REVIEW.md | 2 +- 3 files changed, 12 insertions(+), 20 deletions(-) diff --git a/include/contracts/control_payload.h b/include/contracts/control_payload.h index 304472dc..92693233 100644 --- a/include/contracts/control_payload.h +++ b/include/contracts/control_payload.h @@ -7,6 +7,7 @@ #include #include +#include "contracts/json_structure.h" #include "nlohmann/json.hpp" namespace llm_edgeflow { @@ -98,22 +99,14 @@ inline bool ValidateValue(const nlohmann::json& payload, if (err_msg) *err_msg = message; return false; }; - if (schema.contains("type") && schema["type"].is_string()) { - const std::string type = schema["type"].get(); - const bool matches = (type == "object" && payload.is_object()) || - (type == "array" && payload.is_array()) || - (type == "string" && payload.is_string()) || - (type == "boolean" && payload.is_boolean()) || - (type == "number" && payload.is_number()) || - (type == "integer" && payload.is_number_integer()) || - (type == "null" && payload.is_null()); - if (!matches) - return fail("Control payload does not match type '" + type + "'"); + if (schema.contains("type") && schema["type"].is_string() && + !json_structure::HasType(payload, schema)) { + return fail("Control payload does not match type '" + + schema["type"].get() + "'"); } if (schema.contains("enum") && schema["enum"].is_array() && - std::find(schema["enum"].begin(), schema["enum"].end(), payload) == - schema["enum"].end()) { + !json_structure::AllowsValue(payload, schema)) { return fail("Control payload value is not in the allowed enum"); } @@ -121,12 +114,10 @@ inline bool ValidateValue(const nlohmann::json& payload, const auto number = payload.get(); if (!std::isfinite(number)) return fail("Control payload number must be finite"); - if (schema.contains("minimum") && - number < schema["minimum"].get()) + if (json_structure::BelowMinimum(number, schema)) return fail("Control payload number is below minimum " + schema["minimum"].dump()); - if (schema.contains("maximum") && - number > schema["maximum"].get()) + if (json_structure::AboveMaximum(number, schema)) return fail("Control payload number is above maximum " + schema["maximum"].dump()); } diff --git a/include/contracts/json_structure.h b/include/contracts/json_structure.h index b86faffa..25633aad 100644 --- a/include/contracts/json_structure.h +++ b/include/contracts/json_structure.h @@ -7,8 +7,9 @@ namespace llm_edgeflow::json_structure { -// Shallow structural predicates, shared by the existing ordered parsers and -// their editor schema. No recursive validation, normalization or diagnostics. +// Shallow JSON Schema keyword predicates, shared by the ordered parsers, their +// editor schema and Control payload validation. No recursive validation, +// normalization or diagnostics; each caller keeps its own error contract. using Json = nlohmann::json; inline Json Object(Json properties, Json required = Json::array()) { diff --git a/plans/ARCHITECTURE_REVIEW.md b/plans/ARCHITECTURE_REVIEW.md index 3e68465e..4a58c636 100644 --- a/plans/ARCHITECTURE_REVIEW.md +++ b/plans/ARCHITECTURE_REVIEW.md @@ -324,7 +324,7 @@ Definition、Catalog、Validator、节点运行时及工具的解释须一致。 | C4 | 修复建议生成移到工具层 | 第三节·流程编排层 | 已完成:remediation 与修复复核移到 `src/cli/pipeline_remediation`,复核仍调用 Validator;未注册类型的相近名称留在 Core 诊断;15 份故障方案的 CLI 输出逐字节不变 | | C5 | 业务审计与图校验分离 | 第三节·流程编排层 | 不采纳:Init 时的绑定全量审计已检查转换器与业务端口的 key 和类型;Validator 中剩余的交叉检查负责流契约一致性,以及业务可选、但某个转换器必需的输出,需要转换器视图或组合后的边界。把业务定义移到 Adapter 会牵动 Catalog、CLI/Studio 和 Pipeline 文档的 `biz_name`,目前没有缺陷驱动;跨请求状态改变边界语义时再一并设计 | | C6 | Control 载荷复用 | 第三节·流程编排层 | 不采纳:Control 是低频配置更新,第二次解析只增加微秒级开销;Core 的广播预检(任何节点更新前完成)与 Node 对直接调用的校验职责不同,都需保留。复用解析结果需要修改 `INode::Control` 签名及全部节点与测试,收益不足 | -| C7 | JSON 基础检查共用 | 第三节·流程编排层 | 待评估 | +| C7 | JSON 基础检查共用 | 第三节·流程编排层 | 已完成:Control 载荷校验改用 `json_structure` 的 type/enum/minimum/maximum 判定,诊断文案不变;配置字段使用类型化定义,默认值补齐与错误定位契约不同,保持独立 | | C8 | 部署信息转存与命名 | 第三节·接入适配层 | 待评估 | | C9 | 契约元数据减少重复填写 | 第三节·接入适配层 | 待评估 | | C10 | 宿主类型分散注册 | 第三节·接入适配层 | 待评估 | From 7ab1fe0a7297c7c456a011d55dcccf6ea7ebc952 Mon Sep 17 00:00:00 2001 From: chamsechan Date: Sun, 4 Oct 2026 21:13:08 +0800 Subject: [PATCH 19/29] refactor(adapter): share one binding selection across deployment stages PreparedDeployment and ValidatedIoPlan repeated the same eight fields, copied one by one and partly renamed. Both now derive from IoBindingSelection, so the validated plan moves the prepared selection in one step and output pool fields keep one name. The Operator handle reads converters from the runtime's plan instead of storing copies, and ResolvedOperatorConfig no longer duplicates the binding identity. Preparation and resource loading remain separate stages. Co-Authored-By: Claude Opus 5.5 --- plans/ARCHITECTURE_REVIEW.md | 2 +- src/adapter/deployment_preparation.h | 20 +++----- src/adapter/io_binding_resolver.cpp | 11 +--- src/adapter/io_binding_resolver.h | 12 +---- src/adapter/operator/operator_adapter.cpp | 50 +++++++++---------- .../operator/operator_config_resolver.cpp | 2 - .../operator/operator_config_resolver.h | 2 - src/cli/alg_pipeline_tool.cpp | 19 ++++--- .../operator/test_operator_api.cpp | 10 ++-- 9 files changed, 49 insertions(+), 79 deletions(-) diff --git a/plans/ARCHITECTURE_REVIEW.md b/plans/ARCHITECTURE_REVIEW.md index 4a58c636..7bdb43aa 100644 --- a/plans/ARCHITECTURE_REVIEW.md +++ b/plans/ARCHITECTURE_REVIEW.md @@ -325,7 +325,7 @@ Definition、Catalog、Validator、节点运行时及工具的解释须一致。 | C5 | 业务审计与图校验分离 | 第三节·流程编排层 | 不采纳:Init 时的绑定全量审计已检查转换器与业务端口的 key 和类型;Validator 中剩余的交叉检查负责流契约一致性,以及业务可选、但某个转换器必需的输出,需要转换器视图或组合后的边界。把业务定义移到 Adapter 会牵动 Catalog、CLI/Studio 和 Pipeline 文档的 `biz_name`,目前没有缺陷驱动;跨请求状态改变边界语义时再一并设计 | | C6 | Control 载荷复用 | 第三节·流程编排层 | 不采纳:Control 是低频配置更新,第二次解析只增加微秒级开销;Core 的广播预检(任何节点更新前完成)与 Node 对直接调用的校验职责不同,都需保留。复用解析结果需要修改 `INode::Control` 签名及全部节点与测试,收益不足 | | C7 | JSON 基础检查共用 | 第三节·流程编排层 | 已完成:Control 载荷校验改用 `json_structure` 的 type/enum/minimum/maximum 判定,诊断文案不变;配置字段使用类型化定义,默认值补齐与错误定位契约不同,保持独立 | -| C8 | 部署信息转存与命名 | 第三节·接入适配层 | 待评估 | +| C8 | 部署信息转存与命名 | 第三节·接入适配层 | 已完成:`PreparedDeployment` 与 `ValidatedIoPlan` 共用 `IoBindingSelection`,逐字段转存改为一次移动,输出池字段同名;handle 不再另存转换器指针,`ResolvedOperatorConfig` 不再重复绑定标识。预检与资源加载的阶段边界不变 | | C9 | 契约元数据减少重复填写 | 第三节·接入适配层 | 待评估 | | C10 | 宿主类型分散注册 | 第三节·接入适配层 | 待评估 | | D1 | Model 身份声明去重 | 第三节·模型执行层 | 待评估 | diff --git a/src/adapter/deployment_preparation.h b/src/adapter/deployment_preparation.h index 94158ca5..fa0ee16b 100644 --- a/src/adapter/deployment_preparation.h +++ b/src/adapter/deployment_preparation.h @@ -22,7 +22,10 @@ struct DeploymentPrepareOptions { std::string model_root_dir; }; -struct PreparedDeployment { +// Converter selection, port mappings and output pool budgets resolved from a +// deployment document. Preparation produces it; the validated Operator plan +// keeps it unchanged. +struct IoBindingSelection { IoBindingDefinition binding; const InputConverterDefinition* input_converter = nullptr; const OutputConverterDefinition* output_converter = nullptr; @@ -32,22 +35,13 @@ struct PreparedDeployment { std::unordered_map output_specs; std::unordered_map output_parameter_texts; +}; +struct PreparedDeployment : IoBindingSelection { nlohmann::json neutral_pipeline_json; PipelineIoBoundary io_boundary; - void Clear() { - binding = IoBindingDefinition{}; - input_converter = nullptr; - output_converter = nullptr; - input_port_bindings = InputPortBindings{}; - output_port_bindings = OutputPortBindings{}; - effective_max_batch_size = 0; - output_specs.clear(); - output_parameter_texts.clear(); - neutral_pipeline_json = nullptr; - io_boundary = PipelineIoBoundary{}; - } + void Clear() { *this = PreparedDeployment{}; } }; /** diff --git a/src/adapter/io_binding_resolver.cpp b/src/adapter/io_binding_resolver.cpp index 2105276c..02d376ae 100644 --- a/src/adapter/io_binding_resolver.cpp +++ b/src/adapter/io_binding_resolver.cpp @@ -223,15 +223,8 @@ int IoBindingResolver::ResolveFromPipelineJson( // 组装不可变接入计划 auto io_plan = std::make_unique(); - io_plan->binding = std::move(prepared.binding); - io_plan->input_converter = prepared.input_converter; - io_plan->output_converter = prepared.output_converter; - io_plan->input_port_bindings = std::move(prepared.input_port_bindings); - io_plan->output_port_bindings = std::move(prepared.output_port_bindings); - io_plan->effective_max_batch_size = prepared.effective_max_batch_size; - io_plan->operator_output_specs = std::move(prepared.output_specs); - io_plan->operator_output_parameter_texts = - std::move(prepared.output_parameter_texts); + static_cast(*io_plan) = + std::move(static_cast(prepared)); io_plan->resolved_pipeline_json = std::move(prepared.neutral_pipeline_json); io_plan->resolved_pipeline_json.erase("biz_name"); io_plan->resolved_pipeline_json["deployment"] = diff --git a/src/adapter/io_binding_resolver.h b/src/adapter/io_binding_resolver.h index 38978aa8..b23d83dc 100644 --- a/src/adapter/io_binding_resolver.h +++ b/src/adapter/io_binding_resolver.h @@ -5,6 +5,7 @@ #include #include "adapter/deployment_io_config.h" +#include "adapter/deployment_preparation.h" #include "adapter/io_binding.h" #include "adapter/io_converter.h" #include "adapter/operator/operator_value_type_registry.h" @@ -16,16 +17,7 @@ namespace llm_edgeflow { /** * @brief 已验证的不可变接入计划 (同时包含 I/O 转换器绑定与内部 Pipeline 计划) */ -struct ValidatedIoPlan { - IoBindingDefinition binding; - const InputConverterDefinition* input_converter = nullptr; - const OutputConverterDefinition* output_converter = nullptr; - InputPortBindings input_port_bindings; - OutputPortBindings output_port_bindings; - size_t effective_max_batch_size = 64; - - std::unordered_map operator_output_specs; - std::unordered_map operator_output_parameter_texts; +struct ValidatedIoPlan : IoBindingSelection { // External document snapshot with resolved model paths, without derived biz. nlohmann::json resolved_pipeline_json; diff --git a/src/adapter/operator/operator_adapter.cpp b/src/adapter/operator/operator_adapter.cpp index b04179ba..61cbfa4f 100644 --- a/src/adapter/operator/operator_adapter.cpp +++ b/src/adapter/operator/operator_adapter.cpp @@ -28,8 +28,6 @@ void SetLastError(std::string_view err) noexcept { struct OperatorHandle { std::unique_ptr runtime; uint32_t effective_process_batch_limit = 25; - const llm_edgeflow::InputConverterDefinition* input_converter = nullptr; - const llm_edgeflow::OutputConverterDefinition* output_converter = nullptr; std::unordered_map> output_pools; @@ -222,9 +220,8 @@ int Operator_Create(void** handle, const CreateParam* param) noexcept { for (const auto& out_slot : runtime->GetIoPlan()->output_converter->external_slots) { if (out_slot.direction != llm_edgeflow::PortDirection::kOutput) continue; - auto pit = - runtime->GetIoPlan()->operator_output_specs.find(out_slot.slot_name); - if (pit == runtime->GetIoPlan()->operator_output_specs.end()) { + auto pit = runtime->GetIoPlan()->output_specs.find(out_slot.slot_name); + if (pit == runtime->GetIoPlan()->output_specs.end()) { if (out_slot.required) { SetLastError("Missing output pool configuration for slot " + out_slot.slot_name); @@ -257,8 +254,6 @@ int Operator_Create(void** handle, const CreateParam* param) noexcept { auto handle_instance = std::make_unique(); handle_instance->effective_process_batch_limit = effective_batch_limit; - handle_instance->input_converter = runtime->GetIoPlan()->input_converter; - handle_instance->output_converter = runtime->GetIoPlan()->output_converter; handle_instance->output_pools = std::move(pools); handle_instance->runtime = std::move(runtime); @@ -319,17 +314,20 @@ int Operator_Process(void* handle, const NamedIoBatch& inputs, std::lock_guard lock(h->mutex); - if (!h->runtime || !h->input_converter || !h->output_converter) { + const auto* plan = h->runtime ? h->runtime->GetIoPlan() : nullptr; + if (!plan || !plan->input_converter || !plan->output_converter) { SetLastError( "Handle runtime or converter definitions are null in Process"); return -1; } + const auto* input_converter = plan->input_converter; + const auto* output_converter = plan->output_converter; // 1. 验证并提取外部输入槽 llm_edgeflow::ExternalInputBatchView in_view; std::string in_err; int in_ret = llm_edgeflow::ValidateAndExtractOperatorInputs( - inputs, *h->input_converter, llm_edgeflow::InputLimits{}, &in_view, + inputs, *input_converter, llm_edgeflow::InputLimits{}, &in_view, &in_err); if (in_ret != 0) { SetLastError(in_err); @@ -341,7 +339,7 @@ int Operator_Process(void* handle, const NamedIoBatch& inputs, frame_out_bindings; std::string out_err; int out_ret = llm_edgeflow::ResolveOperatorOutputs( - outputs, *h->output_converter, &frame_out_bindings, &out_err); + outputs, *output_converter, &frame_out_bindings, &out_err); if (out_ret != 0) { SetLastError(out_err); return out_ret; @@ -353,22 +351,21 @@ int Operator_Process(void* handle, const NamedIoBatch& inputs, std::vector request_ids; llm_edgeflow::InputDecodeOptions in_options; - in_options.converter_id = h->input_converter->converter_id; + in_options.converter_id = input_converter->converter_id; in_options.max_batch_size = h->effective_process_batch_limit; in_options.request_ids = &request_ids; llm_edgeflow::AdapterStatus decode_status; - int decode_ret = h->input_converter->decode_fn( - in_view, in_options, h->runtime->GetIoPlan()->input_port_bindings, - &req_ctx, &decode_status); + int decode_ret = input_converter->decode_fn(in_view, in_options, + plan->input_port_bindings, + &req_ctx, &decode_status); if (decode_ret != 0) { - SetLastError("DecodeInput failed for " + - h->input_converter->converter_id + ": " + - decode_status.ToString()); + SetLastError("DecodeInput failed for " + input_converter->converter_id + + ": " + decode_status.ToString()); return decode_ret; } if (request_ids.size() != inputs.size()) { - SetLastError("DecodeInput for " + h->input_converter->converter_id + + SetLastError("DecodeInput for " + input_converter->converter_id + " recorded " + std::to_string(request_ids.size()) + " request ids for " + std::to_string(inputs.size()) + " inputs"); @@ -400,7 +397,7 @@ int Operator_Process(void* handle, const NamedIoBatch& inputs, // 6. 执行统一输出编码 (将结果写入已租用的外部结构块) llm_edgeflow::ExternalOutputBatchView out_view; out_view.count = inputs.size(); - for (const auto& slot : h->output_converter->external_slots) { + for (const auto& slot : output_converter->external_slots) { if (slot.direction == PortDirection::kOutput) { out_view.slot_types[slot.slot_name] = slot.type_id; auto pool = h->output_pools.find(slot.slot_name); @@ -414,18 +411,17 @@ int Operator_Process(void* handle, const NamedIoBatch& inputs, llm_edgeflow::OutputEncodeOptions out_options; - out_options.converter_id = h->output_converter->converter_id; + out_options.converter_id = output_converter->converter_id; out_options.request_ids = &request_ids; size_t written_count = 0; llm_edgeflow::AdapterStatus encode_status; - int encode_ret = h->output_converter->encode_fn( - &req_ctx, h->runtime->GetIoPlan()->output_port_bindings, out_options, - &out_view, &written_count, &encode_status); + int encode_ret = output_converter->encode_fn( + &req_ctx, plan->output_port_bindings, out_options, &out_view, + &written_count, &encode_status); if (encode_ret != 0) { - SetLastError("EncodeOutput failed for " + - h->output_converter->converter_id + ": " + - encode_status.ToString()); + SetLastError("EncodeOutput failed for " + output_converter->converter_id + + ": " + encode_status.ToString()); return encode_ret; } if (written_count != inputs.size()) { @@ -604,7 +600,7 @@ int ResolveOperatorConfigBiz(const char* model_path, const char* cfg_file_name, return ret; } - *out_biz_name = resolved.biz_name; + *out_biz_name = resolved.io_plan->binding.biz_name; return 0; } catch (const std::exception& e) { if (out_biz_name) out_biz_name->clear(); diff --git a/src/adapter/operator/operator_config_resolver.cpp b/src/adapter/operator/operator_config_resolver.cpp index a66fa8b4..0d117b96 100644 --- a/src/adapter/operator/operator_config_resolver.cpp +++ b/src/adapter/operator/operator_config_resolver.cpp @@ -339,8 +339,6 @@ int OperatorConfigResolver::Resolve( result->conf_path = full_cfg; result->pipeline_path = dep_config.resolved_pipe_path; result->model_root_path = canon_root; - result->biz_name = io_plan->binding.biz_name; - result->io_binding = io_plan->binding.binding_id; result->effective_frame_depth = effective_depth; result->effective_process_batch_limit = static_cast( std::min(effective_depth, io_plan->effective_max_batch_size)); diff --git a/src/adapter/operator/operator_config_resolver.h b/src/adapter/operator/operator_config_resolver.h index 0c0db3ca..46b63edc 100644 --- a/src/adapter/operator/operator_config_resolver.h +++ b/src/adapter/operator/operator_config_resolver.h @@ -19,8 +19,6 @@ struct ResolvedOperatorConfig { std::filesystem::path conf_path; std::filesystem::path pipeline_path; std::filesystem::path model_root_path; - std::string biz_name; - std::string io_binding; std::unique_ptr io_plan; uint32_t effective_frame_depth = 0; // normalized output pool depth uint32_t effective_process_batch_limit = 0; // min(pool depth, binding limit) diff --git a/src/cli/alg_pipeline_tool.cpp b/src/cli/alg_pipeline_tool.cpp index 9a8d94cf..b420b2ea 100644 --- a/src/cli/alg_pipeline_tool.cpp +++ b/src/cli/alg_pipeline_tool.cpp @@ -173,14 +173,13 @@ nlohmann::json ProfilesJson(const std::string& biz_filter, std::string* error) { nlohmann::json OutputPoolsJson(const llm_edgeflow::ValidatedIoPlan& plan) { nlohmann::json output_pools = nlohmann::json::object(); - for (const auto& [slot, pool] : plan.operator_output_specs) { - output_pools[slot] = { - {"type", pool.type}, - {"allocator", pool.allocator}, - {"params", plan.operator_output_parameter_texts.at(slot)}, - {"meta_num", pool.meta_num}, - {"metadata_type_id", pool.metadata_type_id}, - {"capacities", pool.capacities}}; + for (const auto& [slot, pool] : plan.output_specs) { + output_pools[slot] = {{"type", pool.type}, + {"allocator", pool.allocator}, + {"params", plan.output_parameter_texts.at(slot)}, + {"meta_num", pool.meta_num}, + {"metadata_type_id", pool.metadata_type_id}, + {"capacities", pool.capacities}}; } return output_pools; } @@ -242,8 +241,8 @@ nlohmann::json ResolveConf(const std::string& file, const std::string& root, {"source", "pipeline.models.model_path"}, {"resolved", model.resolved_model_path}}); nlohmann::json configuration = { - {"biz_name", resolved.biz_name}, - {"io_binding", resolved.io_binding}, + {"biz_name", resolved.io_plan->binding.biz_name}, + {"io_binding", resolved.io_plan->binding.binding_id}, {"conf_path", resolved.conf_path.string()}, {"pipeline_path", resolved.pipeline_path.string()}, {"model_root", resolved.model_root_path.string()}, diff --git a/tests/integration/operator/test_operator_api.cpp b/tests/integration/operator/test_operator_api.cpp index bc50d3fe..50d2e322 100644 --- a/tests/integration/operator/test_operator_api.cpp +++ b/tests/integration/operator/test_operator_api.cpp @@ -2625,12 +2625,12 @@ TEST_F(OperatorApiTest, SCOPED_TRACE(slot); const auto& source = expected_alloc.at(slot).at("params"); EXPECT_FALSE(source.contains("reject_hit")); - EXPECT_EQ(resolved.io_plan->operator_output_parameter_texts.at(slot), + EXPECT_EQ(resolved.io_plan->output_parameter_texts.at(slot), source.dump()); - EXPECT_EQ(resolved.io_plan->operator_output_parameter_texts.at(slot).find( - "reject_hit"), - std::string::npos); - EXPECT_FALSE(resolved.io_plan->operator_output_specs.at(slot) + EXPECT_EQ( + resolved.io_plan->output_parameter_texts.at(slot).find("reject_hit"), + std::string::npos); + EXPECT_FALSE(resolved.io_plan->output_specs.at(slot) .Parameters() .reject_hit); } From 406f9e25a30e5828fe6888a18c7f3eff8927ddf1 Mon Sep 17 00:00:00 2001 From: chamsechan Date: Sun, 4 Oct 2026 21:16:20 +0800 Subject: [PATCH 20/29] refactor(adapter): derive converter external_type from slot types Every production converter hand-wrote external_type as its slot types joined in declaration order. The registry now fills that value when the field is empty and keeps explicit protocol labels, so 16 converter definitions drop the duplicated string; the generated Catalog is byte-identical. Protocol identity, schema version, slot value types and capacity policy checks are unchanged. Co-Authored-By: Claude Opus 5.5 --- doc/dev_guide/operator_output_allocation.md | 1 + plans/ARCHITECTURE_REVIEW.md | 2 +- src/adapter/input/audio_input.cpp | 1 - src/adapter/input/audit_input.cpp | 1 - src/adapter/input/doc_query_input.cpp | 1 - src/adapter/input/image_query_input.cpp | 1 - src/adapter/input/rerank_input.cpp | 1 - src/adapter/input/text_input.cpp | 2 - src/adapter/input/translate_json_input.cpp | 1 - src/adapter/io_converter_registry.cpp | 39 ++++++++++++------- src/adapter/output/audio_result_output.cpp | 1 - src/adapter/output/audit_result_output.cpp | 1 - src/adapter/output/doc_answer_output.cpp | 1 - src/adapter/output/invoice_result_output.cpp | 1 - src/adapter/output/keyword_result_output.cpp | 1 - src/adapter/output/rerank_result_output.cpp | 1 - .../output/structured_document_output.cpp | 1 - .../output/translation_json_output.cpp | 1 - tests/unit/adapter/test_io_converters.cpp | 34 +++++++++++++--- 19 files changed, 55 insertions(+), 37 deletions(-) diff --git a/doc/dev_guide/operator_output_allocation.md b/doc/dev_guide/operator_output_allocation.md index fd3fd101..5a602754 100644 --- a/doc/dev_guide/operator_output_allocation.md +++ b/doc/dev_guide/operator_output_allocation.md @@ -56,6 +56,7 @@ JSON 读取器将选中值通过 `dump()` 转为拥有自身存储的 `std::stri `ExternalSlotDefinition`。例如逻辑槽名为 `result`、已注册类型后缀为 `entity_out`、外部 key 为 `sdk.answer` 时,分别设置 `slot_name = "result"`、`type_suffix = "entity_out"`、`key_suffix = "answer"`。 `schema_version`、输出 `cardinality`、`capacity_policy` 使用 Definition 的默认值时无需赋值; +`external_type` 留空时由外部槽类型按声明顺序拼接(如 `CompanyFrame,CompanyString`)。 规则不同时显式填写。 以下配置来自参与编译的 diff --git a/plans/ARCHITECTURE_REVIEW.md b/plans/ARCHITECTURE_REVIEW.md index 7bdb43aa..bb1640a9 100644 --- a/plans/ARCHITECTURE_REVIEW.md +++ b/plans/ARCHITECTURE_REVIEW.md @@ -326,7 +326,7 @@ Definition、Catalog、Validator、节点运行时及工具的解释须一致。 | C6 | Control 载荷复用 | 第三节·流程编排层 | 不采纳:Control 是低频配置更新,第二次解析只增加微秒级开销;Core 的广播预检(任何节点更新前完成)与 Node 对直接调用的校验职责不同,都需保留。复用解析结果需要修改 `INode::Control` 签名及全部节点与测试,收益不足 | | C7 | JSON 基础检查共用 | 第三节·流程编排层 | 已完成:Control 载荷校验改用 `json_structure` 的 type/enum/minimum/maximum 判定,诊断文案不变;配置字段使用类型化定义,默认值补齐与错误定位契约不同,保持独立 | | C8 | 部署信息转存与命名 | 第三节·接入适配层 | 已完成:`PreparedDeployment` 与 `ValidatedIoPlan` 共用 `IoBindingSelection`,逐字段转存改为一次移动,输出池字段同名;handle 不再另存转换器指针,`ResolvedOperatorConfig` 不再重复绑定标识。预检与资源加载的阶段边界不变 | -| C9 | 契约元数据减少重复填写 | 第三节·接入适配层 | 待评估 | +| C9 | 契约元数据减少重复填写 | 第三节·接入适配层 | 已完成:`external_type` 缺省时由外部槽类型推导,16 个生产转换器删除手写值,Catalog 输出逐字节不变;`schema_id`、版本、槽位 `value_type` 与容量策略检查保留 | | C10 | 宿主类型分散注册 | 第三节·接入适配层 | 待评估 | | D1 | Model 身份声明去重 | 第三节·模型执行层 | 待评估 | | D2 | Backend Provider 函数化注册 | 第三节·模型执行层 | 待评估 | diff --git a/src/adapter/input/audio_input.cpp b/src/adapter/input/audio_input.cpp index 8572b131..dcb035cd 100644 --- a/src/adapter/input/audio_input.cpp +++ b/src/adapter/input/audio_input.cpp @@ -56,7 +56,6 @@ InputConverterDefinition MakeOperatorAudioInputConverter() { def.converter_id = "audio.pcm.operator.v1"; def.schema_id = "audio.pcm.request"; - def.external_type = "CompanyOperatorAudioInput"; def.external_slots = { ExternalInputSlot(kInputSlot)}; diff --git a/src/adapter/input/audit_input.cpp b/src/adapter/input/audit_input.cpp index ef960004..df693232 100644 --- a/src/adapter/input/audit_input.cpp +++ b/src/adapter/input/audit_input.cpp @@ -91,7 +91,6 @@ InputConverterDefinition MakeOperatorAuditInputConverter() { def.converter_id = "audit.plain.operator.v1"; def.schema_id = "audit.plain.request"; - def.external_type = "CompanyOperatorAuditInput"; def.external_slots = { ExternalInputSlot(kInputSlot)}; diff --git a/src/adapter/input/doc_query_input.cpp b/src/adapter/input/doc_query_input.cpp index f4d0cdc2..4145091e 100644 --- a/src/adapter/input/doc_query_input.cpp +++ b/src/adapter/input/doc_query_input.cpp @@ -90,7 +90,6 @@ InputConverterDefinition MakeOperatorDocQueryInputConverter() { def.converter_id = "doc_query.plain.operator.v1"; def.schema_id = "doc_query.plain.request"; - def.external_type = "CompanyOperatorDocInput"; def.external_slots = {ExternalInputSlot(kInputSlot)}; def.logical_ports = {OutputPort(kRawDocs), OutputPort(kRawQueries)}; diff --git a/src/adapter/input/image_query_input.cpp b/src/adapter/input/image_query_input.cpp index d2fbc73f..8c9d8543 100644 --- a/src/adapter/input/image_query_input.cpp +++ b/src/adapter/input/image_query_input.cpp @@ -91,7 +91,6 @@ InputConverterDefinition MakeOperatorImageQueryInputConverter() { def.converter_id = "image_query.plain.operator.v1"; def.schema_id = "image_query.plain.request"; - def.external_type = "CompanyFrame,CompanyString"; def.external_slots = {ExternalInputSlot(kFrameSlot), ExternalInputSlot(kQuerySlot)}; diff --git a/src/adapter/input/rerank_input.cpp b/src/adapter/input/rerank_input.cpp index 9465f581..eab9ae21 100644 --- a/src/adapter/input/rerank_input.cpp +++ b/src/adapter/input/rerank_input.cpp @@ -114,7 +114,6 @@ InputConverterDefinition MakeOperatorRerankInputConverter() { def.converter_id = "rerank.plain.operator.v1"; def.schema_id = "rerank.plain.request"; - def.external_type = "CompanyOperatorRerankInput"; def.external_slots = { ExternalInputSlot(kInputSlot)}; diff --git a/src/adapter/input/text_input.cpp b/src/adapter/input/text_input.cpp index 3fe86645..d0c9b3f9 100644 --- a/src/adapter/input/text_input.cpp +++ b/src/adapter/input/text_input.cpp @@ -55,7 +55,6 @@ InputConverterDefinition MakeOperatorEntityInputConverter() { def.converter_id = "text.plain.operator.v1"; def.schema_id = "text.plain.request"; - def.external_type = "CompanyOperatorEntityInput"; def.external_slots = { ExternalInputSlot(kEntitySlot, kEntitySlot)}; @@ -69,7 +68,6 @@ InputConverterDefinition MakeOperatorKeywordInputConverter() { def.converter_id = "keyword.plain.operator.v1"; def.schema_id = "text.plain.request"; - def.external_type = "CompanyOperatorKeywordInput"; def.external_slots = {ExternalInputSlot( kKeywordSlot, kKeywordSlot)}; diff --git a/src/adapter/input/translate_json_input.cpp b/src/adapter/input/translate_json_input.cpp index b66a5e71..a074a3cd 100644 --- a/src/adapter/input/translate_json_input.cpp +++ b/src/adapter/input/translate_json_input.cpp @@ -61,7 +61,6 @@ InputConverterDefinition MakeOperatorTranslateJsonInputConverter() { def.converter_id = "translate.json.operator.v1"; def.schema_id = "translate.json.request"; - def.external_type = "CompanyOperatorEntityInput"; def.external_slots = { ExternalInputSlot(kInputSlot, kInputSlot)}; diff --git a/src/adapter/io_converter_registry.cpp b/src/adapter/io_converter_registry.cpp index 8d3884cb..c3a1a077 100644 --- a/src/adapter/io_converter_registry.cpp +++ b/src/adapter/io_converter_registry.cpp @@ -2,6 +2,21 @@ namespace llm_edgeflow { +namespace { + +// Default protocol carrier label: the external slot types in declaration +// order, e.g. "CompanyFrame,CompanyString". +std::string JoinedSlotTypes(const std::vector& slots) { + std::string joined; + for (const auto& slot : slots) { + if (!joined.empty()) joined += ","; + joined += slot.type_id; + } + return joined; +} + +} // namespace + IoConverterRegistry& IoConverterRegistry::Instance() { static IoConverterRegistry instance; return instance; @@ -33,12 +48,6 @@ bool IoConverterRegistry::RegisterInputConverter( ") in InputConverterDefinition for: " + def.converter_id); return false; } - if (def.external_type.empty()) { - conflict_errors_.push_back( - "Empty external_type in InputConverterDefinition for: " + - def.converter_id); - return false; - } if (def.external_slots.empty()) { conflict_errors_.push_back( "Empty external_slots in InputConverterDefinition for: " + @@ -82,7 +91,11 @@ bool IoConverterRegistry::RegisterInputConverter( return false; } - input_converters_[def.converter_id] = def; + auto& stored = input_converters_[def.converter_id]; + stored = def; + if (stored.external_type.empty()) { + stored.external_type = JoinedSlotTypes(stored.external_slots); + } return true; } @@ -113,12 +126,6 @@ bool IoConverterRegistry::RegisterOutputConverter( ") in OutputConverterDefinition for: " + def.converter_id); return false; } - if (def.external_type.empty()) { - conflict_errors_.push_back( - "Empty external_type in OutputConverterDefinition for: " + - def.converter_id); - return false; - } if (def.external_slots.empty()) { conflict_errors_.push_back( "Empty external_slots in OutputConverterDefinition for: " + @@ -162,7 +169,11 @@ bool IoConverterRegistry::RegisterOutputConverter( return false; } - output_converters_[def.converter_id] = def; + auto& stored = output_converters_[def.converter_id]; + stored = def; + if (stored.external_type.empty()) { + stored.external_type = JoinedSlotTypes(stored.external_slots); + } return true; } diff --git a/src/adapter/output/audio_result_output.cpp b/src/adapter/output/audio_result_output.cpp index b0839cfc..a2e08968 100644 --- a/src/adapter/output/audio_result_output.cpp +++ b/src/adapter/output/audio_result_output.cpp @@ -93,7 +93,6 @@ OutputConverterDefinition MakeOperatorAudioResultOutputConverter() { def.converter_id = "audio_result.plain.operator.v1"; def.schema_id = "audio_result.plain.response"; - def.external_type = "CompanyOperatorAudioOutput"; def.external_slots = { ExternalOutputSlot(kOutputSlot)}; diff --git a/src/adapter/output/audit_result_output.cpp b/src/adapter/output/audit_result_output.cpp index 8f80e00c..da405425 100644 --- a/src/adapter/output/audit_result_output.cpp +++ b/src/adapter/output/audit_result_output.cpp @@ -135,7 +135,6 @@ OutputConverterDefinition MakeOperatorAuditResultOutputConverter() { def.converter_id = "audit_result.plain.operator.v1"; def.schema_id = "audit_result.plain.response"; - def.external_type = "CompanyOperatorAuditOutput"; def.external_slots = { ExternalOutputSlot(kOutputSlot)}; diff --git a/src/adapter/output/doc_answer_output.cpp b/src/adapter/output/doc_answer_output.cpp index f0e6e700..c15367f5 100644 --- a/src/adapter/output/doc_answer_output.cpp +++ b/src/adapter/output/doc_answer_output.cpp @@ -97,7 +97,6 @@ OutputConverterDefinition MakeOperatorDocAnswerOutputConverter() { def.converter_id = "doc_answer.plain.operator.v1"; def.schema_id = "doc_answer.plain.response"; - def.external_type = "CompanyOperatorDocOutput"; def.external_slots = { ExternalOutputSlot(kOutputSlot)}; diff --git a/src/adapter/output/invoice_result_output.cpp b/src/adapter/output/invoice_result_output.cpp index 5bda3eb8..42259fbb 100644 --- a/src/adapter/output/invoice_result_output.cpp +++ b/src/adapter/output/invoice_result_output.cpp @@ -92,7 +92,6 @@ OutputConverterDefinition MakeOperatorInvoiceResultOutputConverter() { def.converter_id = "invoice_result.plain.operator.v1"; def.schema_id = "invoice_result.plain.response"; - def.external_type = "CompanyOdOutput"; def.external_slots = {ExternalOutputSlot(kOutputSlot)}; def.logical_ports = {RequiredInputPort(kExtractedInvoiceJson), diff --git a/src/adapter/output/keyword_result_output.cpp b/src/adapter/output/keyword_result_output.cpp index 2473b542..287a5d0c 100644 --- a/src/adapter/output/keyword_result_output.cpp +++ b/src/adapter/output/keyword_result_output.cpp @@ -40,7 +40,6 @@ OutputConverterDefinition MakeOperatorKeywordResultOutputConverter() { def.converter_id = "keyword.result.operator.v1"; def.schema_id = "keyword.result.response"; - def.external_type = "CompanyOperatorKeywordOutput"; def.external_slots = { ExternalOutputSlot(kOutputSlot)}; diff --git a/src/adapter/output/rerank_result_output.cpp b/src/adapter/output/rerank_result_output.cpp index a5a2e43b..06d284e5 100644 --- a/src/adapter/output/rerank_result_output.cpp +++ b/src/adapter/output/rerank_result_output.cpp @@ -99,7 +99,6 @@ OutputConverterDefinition MakeOperatorRerankResultOutputConverter() { def.converter_id = "rerank_result.plain.operator.v1"; def.schema_id = "rerank_result.plain.response"; - def.external_type = "CompanyOperatorRerankOutput"; def.external_slots = { ExternalOutputSlot(kOutputSlot)}; diff --git a/src/adapter/output/structured_document_output.cpp b/src/adapter/output/structured_document_output.cpp index 715c47de..83226b25 100644 --- a/src/adapter/output/structured_document_output.cpp +++ b/src/adapter/output/structured_document_output.cpp @@ -43,7 +43,6 @@ OutputConverterDefinition MakeOperatorStructuredDocumentOutputConverter() { def.converter_id = "document.structured.operator.v1"; def.schema_id = "document.structured.response"; - def.external_type = "CompanyOperatorEntityOutput"; def.external_slots = { ExternalOutputSlot(kOutputSlot)}; diff --git a/src/adapter/output/translation_json_output.cpp b/src/adapter/output/translation_json_output.cpp index 5123a3bf..fa3e13b7 100644 --- a/src/adapter/output/translation_json_output.cpp +++ b/src/adapter/output/translation_json_output.cpp @@ -41,7 +41,6 @@ OutputConverterDefinition MakeOperatorTranslationJsonOutputConverter() { def.converter_id = "translate.json.operator.v1"; def.schema_id = "translate.json.response"; - def.external_type = "CompanyOperatorEntityOutput"; def.external_slots = { ExternalOutputSlot(kOutputSlot)}; diff --git a/tests/unit/adapter/test_io_converters.cpp b/tests/unit/adapter/test_io_converters.cpp index 279e08b5..0ff692c0 100644 --- a/tests/unit/adapter/test_io_converters.cpp +++ b/tests/unit/adapter/test_io_converters.cpp @@ -144,6 +144,33 @@ TEST(IoConverterTest, RegisterAndFindOutputConverter) { EXPECT_EQ(found->converter_id, "test.output.operator.v1"); } +TEST(IoConverterTest, ExternalTypeDefaultsToSlotTypesInOrder) { + InputConverterDefinition input; + input.converter_id = "derived.external_type.in"; + input.schema_id = "test"; + input.external_slots = { + ExternalSlotDefinition("frame", "CompanyFrame", PortDirection::kInput, + true, "frame", "frame"), + ExternalSlotDefinition("query", "CompanyString", PortDirection::kInput, + true, "string", "query")}; + input.logical_ports = {NodePortDefinition("texts", "TextBatch", true)}; + input.decode_fn = &DummyDecode; + ASSERT_TRUE(IoConverterRegistry::Instance().RegisterInputConverter(input)); + EXPECT_EQ(IoConverterRegistry::Instance() + .FindInputConverter(input.converter_id) + ->external_type, + "CompanyFrame,CompanyString"); + + // An explicit protocol label is kept as declared. + input.converter_id = "labeled.external_type.in"; + input.external_type = "custom.carrier"; + ASSERT_TRUE(IoConverterRegistry::Instance().RegisterInputConverter(input)); + EXPECT_EQ(IoConverterRegistry::Instance() + .FindInputConverter(input.converter_id) + ->external_type, + "custom.carrier"); +} + TEST(IoConverterTest, RejectsInvalidDefinitions) { InputConverterDefinition bad_in; bad_in.converter_id = ""; @@ -172,13 +199,8 @@ TEST(IoConverterTest, RejectsInvalidDefinitions) { bad_in.schema_version = 0; EXPECT_FALSE(IoConverterRegistry::Instance().RegisterInputConverter(bad_in)); - // 缺少 external_type - bad_in.schema_version = 1; - bad_in.external_type = ""; - EXPECT_FALSE(IoConverterRegistry::Instance().RegisterInputConverter(bad_in)); - // 缺少 external_slots - bad_in.external_type = "int"; + bad_in.schema_version = 1; bad_in.external_slots.clear(); EXPECT_FALSE(IoConverterRegistry::Instance().RegisterInputConverter(bad_in)); From 7c410e3bb4ca10dcc94455bca369ca7ff157d1f7 Mon Sep 17 00:00:00 2001 From: chamsechan Date: Sun, 4 Oct 2026 21:16:41 +0800 Subject: [PATCH 21/29] docs(plans): record why host type registration stays centralized Host structs are platform definitions shared across converters, so each type needs one owner for its traits and layout; splitting registration per type adds files without removing steps, and real SDK types can only be integrated inside the authorized network. Co-Authored-By: Claude Opus 5.5 --- plans/ARCHITECTURE_REVIEW.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plans/ARCHITECTURE_REVIEW.md b/plans/ARCHITECTURE_REVIEW.md index bb1640a9..1713d7d2 100644 --- a/plans/ARCHITECTURE_REVIEW.md +++ b/plans/ARCHITECTURE_REVIEW.md @@ -327,7 +327,7 @@ Definition、Catalog、Validator、节点运行时及工具的解释须一致。 | C7 | JSON 基础检查共用 | 第三节·流程编排层 | 已完成:Control 载荷校验改用 `json_structure` 的 type/enum/minimum/maximum 判定,诊断文案不变;配置字段使用类型化定义,默认值补齐与错误定位契约不同,保持独立 | | C8 | 部署信息转存与命名 | 第三节·接入适配层 | 已完成:`PreparedDeployment` 与 `ValidatedIoPlan` 共用 `IoBindingSelection`,逐字段转存改为一次移动,输出池字段同名;handle 不再另存转换器指针,`ResolvedOperatorConfig` 不再重复绑定标识。预检与资源加载的阶段边界不变 | | C9 | 契约元数据减少重复填写 | 第三节·接入适配层 | 已完成:`external_type` 缺省时由外部槽类型推导,16 个生产转换器删除手写值,Catalog 输出逐字节不变;`schema_id`、版本、槽位 `value_type` 与容量策略检查保留 | -| C10 | 宿主类型分散注册 | 第三节·接入适配层 | 待评估 | +| C10 | 宿主类型分散注册 | 第三节·接入适配层 | 不采纳:宿主结构是多个转换器共用的平台定义(如 `CompanyOperatorEntityInput` 同时用于文本与翻译输入),类型特征和布局每个类型需要唯一所有者,目前两个集中文件就是所有者;按类型拆分只增加文件而不减少步骤。真实 SDK 类型只能在授权内网接入,现在重设该接缝属于推测 | | D1 | Model 身份声明去重 | 第三节·模型执行层 | 待评估 | | D2 | Backend Provider 函数化注册 | 第三节·模型执行层 | 待评估 | | D3 | Model/Backend 配置声明与读取统一 | 第三节·模型执行层 | 待评估 | From f066eab7bc36b556958ac1512b8b2fb047c139c7 Mon Sep 17 00:00:00 2001 From: chamsechan Date: Sun, 4 Oct 2026 21:20:48 +0800 Subject: [PATCH 22/29] refactor(engine): declare each Model's identity once Every Model repeated its type, capability and concurrency in class constants, three IModel overrides and its ModelDefinition. Models now derive from ModelIdentity, declare only kModelType and kConcurrency, take the capability from ModelCapabilityTraits, and start their Definition from MakeModelDefinition(). The runtime factory still checks each created instance against its Definition; the Catalog is byte-identical. Co-Authored-By: Claude Opus 5.5 --- .../skills/edgeflow-model-developer/SKILL.md | 4 +- doc/developer_guide.md | 3 ++ include/engine/model_identity.h | 46 +++++++++++++++++++ plans/ARCHITECTURE_REVIEW.md | 2 +- .../bge_embedding/bge_embedding_model.cpp | 19 +------- .../bge_embedding/bge_embedding_model.h | 11 ++--- .../bge_reranker/bge_reranker_model.cpp | 19 +------- .../models/bge_reranker/bge_reranker_model.h | 11 ++--- .../generated_text_embedding_model.cpp | 16 +------ .../generated_text_embedding_model.h | 10 ++-- .../qwen_causal_lm/qwen_causal_lm_model.cpp | 19 +------- .../qwen_causal_lm/qwen_causal_lm_model.h | 11 ++--- .../vision_document/vision_document_model.cpp | 16 +------ .../vision_document/vision_document_model.h | 10 ++-- .../models/whisper_asr/whisper_asr_model.cpp | 19 +------- .../models/whisper_asr/whisper_asr_model.h | 10 ++-- 16 files changed, 91 insertions(+), 135 deletions(-) create mode 100644 include/engine/model_identity.h diff --git a/.agents/skills/edgeflow-model-developer/SKILL.md b/.agents/skills/edgeflow-model-developer/SKILL.md index f4eb70aa..dc96706b 100644 --- a/.agents/skills/edgeflow-model-developer/SKILL.md +++ b/.agents/skills/edgeflow-model-developer/SKILL.md @@ -19,7 +19,9 @@ description: 新增或修改 LLM-EdgeFlow Model 的预处理、输出解释和 不进入 Model。需要新执行运行时,转 [Backend skill](../edgeflow-backend-developer/SKILL.md); 必须扩展中性协议时按跨层契约变更处理,不能直接引用具体 Backend。 - 通过 `REGISTER_MODEL_WITH_DEFINITION` 注册完整 `ModelDefinition`,声明能力、执行协议、 - 并发约束及 `config_fields`。不要在 Web、skill 或 Node 再维护模型列表。 + 并发约束及 `config_fields`。身份只写一次:继承 `ModelIdentity` 并声明 + `kModelType`、`kConcurrency`,Definition 从 `MakeModelDefinition()` 开始。 + 不要在 Web、skill 或 Node 再维护模型列表。 - 额外配置语义写入纯函数 `validate_config`:输入已完成字段校验和默认值补齐,函数不做 文件/资源 I/O;Validator 和 Factory 会在 Backend 创建/加载前执行。直接 Create 复用 相同语义检查,资源存在性、Tensor metadata 和会话相关检查留在创建阶段。 diff --git a/doc/developer_guide.md b/doc/developer_guide.md index 6ca09f6b..172c965d 100644 --- a/doc/developer_guide.md +++ b/doc/developer_guide.md @@ -169,6 +169,9 @@ Node 不依赖自定义实现。编写、构建和复用步骤见 Model 自注册需实现 `IModel` 的某一强类型能力并声明所需协议;Backend 实现 `IInferenceBackend` 并只返回中性 `IBackendSession`。二者分别提供完整 `ModelDefinition` / `BackendDefinition` 并使用对应 `REGISTER_*_WITH_DEFINITION` 宏。 +Model 继承 [`ModelIdentity`](../include/engine/model_identity.h),只声明 +`kModelType` 与 `kConcurrency`,能力由接口推导;Definition 用 `MakeModelDefinition()` +取得同一身份后再补协议和配置字段。创建时工厂仍逐项核对实例与 Definition。 Model 的字段类型、默认值和范围由 `config_fields` 声明;字段之间或文本内容的额外规则 放在可选的 `ModelDefinition::validate_config` 中。Validator 与 ModelRuntimeFactory diff --git a/include/engine/model_identity.h b/include/engine/model_identity.h new file mode 100644 index 00000000..0420ca03 --- /dev/null +++ b/include/engine/model_identity.h @@ -0,0 +1,46 @@ +#pragma once + +#include + +#include "engine/inference_definition.h" +#include "engine/model_capability_traits.h" +#include "engine/model_interface.h" + +namespace llm_edgeflow { + +// Declares a Model's identity once. Derived supplies kModelType and +// kConcurrency; the capability follows from the implemented interface. The +// registered Definition starts from the same values, and the runtime factory +// still checks every created instance against its Definition. +template +class ModelIdentity : public CapabilityInterface { + public: + using Capabilities = CapabilityInterface; + + const std::string& ModelType() const noexcept override { + static const std::string type = Derived::kModelType; + return type; + } + const std::string& Capability() const noexcept override { + static const std::string capability = + ModelCapabilityTraits::Capability(); + return capability; + } + InferenceConcurrency Concurrency() const noexcept override { + return Derived::kConcurrency; + } +}; + +// Definition carrying ModelClass's identity; callers add the description, +// protocol, configuration fields and validator. +template +ModelDefinition MakeModelDefinition() { + ModelDefinition definition; + definition.model_type = ModelClass::kModelType; + definition.capability = + ModelCapabilityTraits::Capability(); + definition.concurrency = ModelClass::kConcurrency; + return definition; +} + +} // namespace llm_edgeflow diff --git a/plans/ARCHITECTURE_REVIEW.md b/plans/ARCHITECTURE_REVIEW.md index 1713d7d2..9aa02311 100644 --- a/plans/ARCHITECTURE_REVIEW.md +++ b/plans/ARCHITECTURE_REVIEW.md @@ -328,7 +328,7 @@ Definition、Catalog、Validator、节点运行时及工具的解释须一致。 | C8 | 部署信息转存与命名 | 第三节·接入适配层 | 已完成:`PreparedDeployment` 与 `ValidatedIoPlan` 共用 `IoBindingSelection`,逐字段转存改为一次移动,输出池字段同名;handle 不再另存转换器指针,`ResolvedOperatorConfig` 不再重复绑定标识。预检与资源加载的阶段边界不变 | | C9 | 契约元数据减少重复填写 | 第三节·接入适配层 | 已完成:`external_type` 缺省时由外部槽类型推导,16 个生产转换器删除手写值,Catalog 输出逐字节不变;`schema_id`、版本、槽位 `value_type` 与容量策略检查保留 | | C10 | 宿主类型分散注册 | 第三节·接入适配层 | 不采纳:宿主结构是多个转换器共用的平台定义(如 `CompanyOperatorEntityInput` 同时用于文本与翻译输入),类型特征和布局每个类型需要唯一所有者,目前两个集中文件就是所有者;按类型拆分只增加文件而不减少步骤。真实 SDK 类型只能在授权内网接入,现在重设该接缝属于推测 | -| D1 | Model 身份声明去重 | 第三节·模型执行层 | 待评估 | +| D1 | Model 身份声明去重 | 第三节·模型执行层 | 已完成:6 个生产 Model 继承 `ModelIdentity`,身份只声明 `kModelType`/`kConcurrency`,能力由接口推导,Definition 用 `MakeModelDefinition`;工厂的实例核验保留,Catalog 逐字节不变。Backend 类型名随 D2 处理 | | D2 | Backend Provider 函数化注册 | 第三节·模型执行层 | 待评估 | | D3 | Model/Backend 配置声明与读取统一 | 第三节·模型执行层 | 待评估 | | D4 | Backend 条件编译由 CMake 收敛 | 第三节·模型执行层 | 待评估 | diff --git a/src/engine/models/bge_embedding/bge_embedding_model.cpp b/src/engine/models/bge_embedding/bge_embedding_model.cpp index f77f6f4b..38ea9af1 100644 --- a/src/engine/models/bge_embedding/bge_embedding_model.cpp +++ b/src/engine/models/bge_embedding/bge_embedding_model.cpp @@ -192,20 +192,6 @@ std::shared_ptr BgeEmbeddingModel::Create(const ModelCreateContext& ctx, max_batch_size); } -const std::string& BgeEmbeddingModel::ModelType() const noexcept { - static const std::string type = kModelType; - return type; -} - -const std::string& BgeEmbeddingModel::Capability() const noexcept { - static const std::string cap = kCapability; - return cap; -} - -InferenceConcurrency BgeEmbeddingModel::Concurrency() const noexcept { - return InferenceConcurrency::kConcurrent; -} - int BgeEmbeddingModel::Embed(const TextBatch& inputs, const EmbeddingOptions& options, EmbeddingBatch* outputs, @@ -405,12 +391,9 @@ int BgeEmbeddingModel::RawEmbedSlice( } static const ModelDefinition kBgeEmbeddingModelDefinition = [] { - ModelDefinition def; - def.model_type = BgeEmbeddingModel::kModelType; - def.capability = BgeEmbeddingModel::kCapability; + auto def = MakeModelDefinition(); def.description = "BGE text embedding model using TensorGraph protocol"; def.required_protocol = ExecutionProtocol::kTensorGraph; - def.concurrency = InferenceConcurrency::kConcurrent; def.config_fields = { {"tokenizer_file", ConfigValueKind::kString, diff --git a/src/engine/models/bge_embedding/bge_embedding_model.h b/src/engine/models/bge_embedding/bge_embedding_model.h index 9b40072c..da60a3c9 100644 --- a/src/engine/models/bge_embedding/bge_embedding_model.h +++ b/src/engine/models/bge_embedding/bge_embedding_model.h @@ -6,6 +6,7 @@ #include "engine/backend_interface.h" #include "engine/fixed_batch_executor.h" +#include "engine/model_identity.h" #include "engine/model_interface.h" #include "engine/model_registry.h" #include "engine/models/bge_common/bert_wordpiece_tokenizer.h" @@ -24,10 +25,12 @@ namespace llm_edgeflow { * - 负责池化 (Pooling: CLS / Mean) 与 L2 归一化; * - 使用 FixedBatchExecutor 驱动批次并保持 (req_id, sub_id) 溯源。 */ -class BgeEmbeddingModel final : public IEmbeddingModel { +class BgeEmbeddingModel final + : public ModelIdentity { public: inline static constexpr char kModelType[] = "bge_embedding"; - inline static constexpr char kCapability[] = "embedding"; + static constexpr InferenceConcurrency kConcurrency = + InferenceConcurrency::kConcurrent; static std::shared_ptr Create(const ModelCreateContext& ctx, std::string* diagnostic); @@ -39,10 +42,6 @@ class BgeEmbeddingModel final : public IEmbeddingModel { ~BgeEmbeddingModel() override = default; - const std::string& ModelType() const noexcept override; - const std::string& Capability() const noexcept override; - InferenceConcurrency Concurrency() const noexcept override; - int Embed(const TextBatch& inputs, const EmbeddingOptions& options, EmbeddingBatch* outputs, std::string* diagnostic = nullptr) noexcept override; diff --git a/src/engine/models/bge_reranker/bge_reranker_model.cpp b/src/engine/models/bge_reranker/bge_reranker_model.cpp index e9cad67b..a35f14f9 100644 --- a/src/engine/models/bge_reranker/bge_reranker_model.cpp +++ b/src/engine/models/bge_reranker/bge_reranker_model.cpp @@ -137,20 +137,6 @@ std::shared_ptr BgeRerankerModel::Create(const ModelCreateContext& ctx, std::move(output_name), std::move(score_activation), max_batch_size); } -const std::string& BgeRerankerModel::ModelType() const noexcept { - static const std::string type = kModelType; - return type; -} - -const std::string& BgeRerankerModel::Capability() const noexcept { - static const std::string cap = kCapability; - return cap; -} - -InferenceConcurrency BgeRerankerModel::Concurrency() const noexcept { - return InferenceConcurrency::kConcurrent; -} - int BgeRerankerModel::Score(const QueryCandidatesBatch& inputs, ScoreBatch* outputs, std::string* diagnostic) noexcept { @@ -316,13 +302,10 @@ int BgeRerankerModel::RawScoreSlice(const QueryCandidatesBatch& all_inputs, } static const ModelDefinition kBgeRerankerModelDefinition = [] { - ModelDefinition def; - def.model_type = BgeRerankerModel::kModelType; - def.capability = BgeRerankerModel::kCapability; + auto def = MakeModelDefinition(); def.description = "BGE cross-encoder reranker model using TensorGraph protocol"; def.required_protocol = ExecutionProtocol::kTensorGraph; - def.concurrency = InferenceConcurrency::kConcurrent; def.config_fields = { {"tokenizer_file", ConfigValueKind::kString, diff --git a/src/engine/models/bge_reranker/bge_reranker_model.h b/src/engine/models/bge_reranker/bge_reranker_model.h index f8e80723..eb362aa4 100644 --- a/src/engine/models/bge_reranker/bge_reranker_model.h +++ b/src/engine/models/bge_reranker/bge_reranker_model.h @@ -6,6 +6,7 @@ #include "engine/backend_interface.h" #include "engine/fixed_batch_executor.h" +#include "engine/model_identity.h" #include "engine/model_interface.h" #include "engine/model_registry.h" #include "engine/models/bge_common/bert_wordpiece_tokenizer.h" @@ -24,10 +25,12 @@ namespace llm_edgeflow { * - 负责 logit 校验与 score 激活 (sigmoid / identity); * - 使用 FixedBatchExecutor 驱动批次并保持 (req_id, sub_id) 溯源。 */ -class BgeRerankerModel final : public IRerankModel { +class BgeRerankerModel final + : public ModelIdentity { public: inline static constexpr char kModelType[] = "bge_reranker"; - inline static constexpr char kCapability[] = "rerank"; + static constexpr InferenceConcurrency kConcurrency = + InferenceConcurrency::kConcurrent; static std::shared_ptr Create(const ModelCreateContext& ctx, std::string* diagnostic); @@ -39,10 +42,6 @@ class BgeRerankerModel final : public IRerankModel { ~BgeRerankerModel() override = default; - const std::string& ModelType() const noexcept override; - const std::string& Capability() const noexcept override; - InferenceConcurrency Concurrency() const noexcept override; - int Score(const QueryCandidatesBatch& inputs, ScoreBatch* outputs, std::string* diagnostic = nullptr) noexcept override; diff --git a/src/engine/models/generated_text_embedding/generated_text_embedding_model.cpp b/src/engine/models/generated_text_embedding/generated_text_embedding_model.cpp index a8124369..41b546fb 100644 --- a/src/engine/models/generated_text_embedding/generated_text_embedding_model.cpp +++ b/src/engine/models/generated_text_embedding/generated_text_embedding_model.cpp @@ -54,17 +54,6 @@ std::shared_ptr GeneratedTextEmbeddingModel::Create( } } -const std::string& GeneratedTextEmbeddingModel::ModelType() const noexcept { - static const std::string type = "generated_text_embedding"; - return type; -} -const std::string& GeneratedTextEmbeddingModel::Capability() const noexcept { - static const std::string capability = "embedding"; - return capability; -} -InferenceConcurrency GeneratedTextEmbeddingModel::Concurrency() const noexcept { - return InferenceConcurrency::kConcurrent; -} int GeneratedTextEmbeddingModel::Embed(const TextBatch& inputs, const EmbeddingOptions& options, EmbeddingBatch* outputs, @@ -147,14 +136,11 @@ int GeneratedTextEmbeddingModel::Embed(const TextBatch& inputs, } static const ModelDefinition kGeneratedTextEmbeddingDefinition = [] { - ModelDefinition definition; - definition.model_type = "generated_text_embedding"; - definition.capability = "embedding"; + auto definition = MakeModelDefinition(); definition.description = "Experimental text vectors from greedy generated-token hidden states; " "last/mean pooling, not encoder embeddings"; definition.required_protocol = ExecutionProtocol::kGeneratedTokenEmbedding; - definition.concurrency = InferenceConcurrency::kConcurrent; definition.config_fields = { {"embedding_dim", ConfigValueKind::kInteger, diff --git a/src/engine/models/generated_text_embedding/generated_text_embedding_model.h b/src/engine/models/generated_text_embedding/generated_text_embedding_model.h index a28fd890..e847a03d 100644 --- a/src/engine/models/generated_text_embedding/generated_text_embedding_model.h +++ b/src/engine/models/generated_text_embedding/generated_text_embedding_model.h @@ -1,6 +1,7 @@ #pragma once #include "engine/backend_interface.h" +#include "engine/model_identity.h" #include "engine/model_interface.h" #include "engine/model_registry.h" @@ -9,13 +10,14 @@ namespace llm_edgeflow { // Pools generated-token hidden states. This is a distinct vector space from // encoder embeddings: callers must evaluate retrieval quality and rebuild // indexes when changing the model, prompt, generation limit or pooling. -class GeneratedTextEmbeddingModel final : public IEmbeddingModel { +class GeneratedTextEmbeddingModel final + : public ModelIdentity { public: + inline static constexpr char kModelType[] = "generated_text_embedding"; + static constexpr InferenceConcurrency kConcurrency = + InferenceConcurrency::kConcurrent; static std::shared_ptr Create(const ModelCreateContext& context, std::string* diagnostic); - const std::string& ModelType() const noexcept override; - const std::string& Capability() const noexcept override; - InferenceConcurrency Concurrency() const noexcept override; int Embed(const TextBatch& inputs, const EmbeddingOptions& options, EmbeddingBatch* outputs, std::string* diagnostic = nullptr) noexcept override; diff --git a/src/engine/models/qwen_causal_lm/qwen_causal_lm_model.cpp b/src/engine/models/qwen_causal_lm/qwen_causal_lm_model.cpp index 651f0393..9fce8ee1 100644 --- a/src/engine/models/qwen_causal_lm/qwen_causal_lm_model.cpp +++ b/src/engine/models/qwen_causal_lm/qwen_causal_lm_model.cpp @@ -78,20 +78,6 @@ QwenCausalLmModel::QwenCausalLmModel( add_bos_(add_bos), random_seed_(random_seed) {} -const std::string& QwenCausalLmModel::ModelType() const noexcept { - static const std::string type = kModelType; - return type; -} - -const std::string& QwenCausalLmModel::Capability() const noexcept { - static const std::string capability = kCapability; - return capability; -} - -InferenceConcurrency QwenCausalLmModel::Concurrency() const noexcept { - return InferenceConcurrency::kConcurrent; -} - std::string QwenCausalLmModel::ApplyChatTemplate( const std::string& prompt) const { std::string formatted; @@ -182,13 +168,10 @@ int QwenCausalLmModel::GenerateOne(const TraceableItem& prompt, } static const ModelDefinition kQwenCausalLmModelDefinition = [] { - ModelDefinition definition; - definition.model_type = QwenCausalLmModel::kModelType; - definition.capability = QwenCausalLmModel::kCapability; + auto definition = MakeModelDefinition(); definition.description = "Qwen ChatML model using the unified text-generation protocol"; definition.required_protocol = ExecutionProtocol::kTextGeneration; - definition.concurrency = InferenceConcurrency::kConcurrent; definition.config_fields = { {"system_prompt", ConfigValueKind::kString, diff --git a/src/engine/models/qwen_causal_lm/qwen_causal_lm_model.h b/src/engine/models/qwen_causal_lm/qwen_causal_lm_model.h index d51de354..79395abb 100644 --- a/src/engine/models/qwen_causal_lm/qwen_causal_lm_model.h +++ b/src/engine/models/qwen_causal_lm/qwen_causal_lm_model.h @@ -5,6 +5,7 @@ #include #include "engine/backend_interface.h" +#include "engine/model_identity.h" #include "engine/model_interface.h" #include "engine/model_registry.h" @@ -17,10 +18,12 @@ namespace llm_edgeflow { * sampling, generation loops and vendor resources belong below the unified * ITextGenerationSession boundary. */ -class QwenCausalLmModel final : public ILlmModel { +class QwenCausalLmModel final + : public ModelIdentity { public: inline static constexpr char kModelType[] = "qwen_causal_lm"; - inline static constexpr char kCapability[] = "llm"; + static constexpr InferenceConcurrency kConcurrency = + InferenceConcurrency::kConcurrent; static std::shared_ptr Create(const ModelCreateContext& ctx, std::string* diagnostic); @@ -30,10 +33,6 @@ class QwenCausalLmModel final : public ILlmModel { int64_t random_seed); ~QwenCausalLmModel() override = default; - const std::string& ModelType() const noexcept override; - const std::string& Capability() const noexcept override; - InferenceConcurrency Concurrency() const noexcept override; - int Generate(const TextBatch& prompts, const GenerateOptions& options, TextBatch* outputs, std::string* diagnostic = nullptr) noexcept override; diff --git a/src/engine/models/vision_document/vision_document_model.cpp b/src/engine/models/vision_document/vision_document_model.cpp index c56a4087..8d2ef6ca 100644 --- a/src/engine/models/vision_document/vision_document_model.cpp +++ b/src/engine/models/vision_document/vision_document_model.cpp @@ -59,17 +59,6 @@ std::shared_ptr VisionDocumentModel::Create( } } -const std::string& VisionDocumentModel::ModelType() const noexcept { - static const std::string value = "vision_document"; - return value; -} -const std::string& VisionDocumentModel::Capability() const noexcept { - static const std::string value = "ocr"; - return value; -} -InferenceConcurrency VisionDocumentModel::Concurrency() const noexcept { - return InferenceConcurrency::kConcurrent; -} int VisionDocumentModel::Recognize(const ImageRefBatch& images, OcrDocumentBatch* outputs, std::string* diagnostic) noexcept { @@ -115,13 +104,10 @@ int VisionDocumentModel::Recognize(const ImageRefBatch& images, } static const ModelDefinition kVisionDocumentDefinition = [] { - ModelDefinition definition; - definition.model_type = "vision_document"; - definition.capability = "ocr"; + auto definition = MakeModelDefinition(); definition.description = "Image-to-text document recognition; text only, no detected boxes"; definition.required_protocol = ExecutionProtocol::kImageTextGeneration; - definition.concurrency = InferenceConcurrency::kConcurrent; definition.validate_config = ValidateVisionConfig; definition.config_fields = { {"prompt", diff --git a/src/engine/models/vision_document/vision_document_model.h b/src/engine/models/vision_document/vision_document_model.h index 16902f79..75f8fa79 100644 --- a/src/engine/models/vision_document/vision_document_model.h +++ b/src/engine/models/vision_document/vision_document_model.h @@ -1,18 +1,20 @@ #pragma once #include "engine/backend_interface.h" +#include "engine/model_identity.h" #include "engine/model_interface.h" #include "engine/model_registry.h" namespace llm_edgeflow { -class VisionDocumentModel final : public IOcrModel { +class VisionDocumentModel final + : public ModelIdentity { public: + inline static constexpr char kModelType[] = "vision_document"; + static constexpr InferenceConcurrency kConcurrency = + InferenceConcurrency::kConcurrent; static std::shared_ptr Create(const ModelCreateContext& context, std::string* diagnostic); - const std::string& ModelType() const noexcept override; - const std::string& Capability() const noexcept override; - InferenceConcurrency Concurrency() const noexcept override; int Recognize(const ImageRefBatch& images, OcrDocumentBatch* outputs, std::string* diagnostic = nullptr) noexcept override; diff --git a/src/engine/models/whisper_asr/whisper_asr_model.cpp b/src/engine/models/whisper_asr/whisper_asr_model.cpp index c638d1f7..2c082bed 100644 --- a/src/engine/models/whisper_asr/whisper_asr_model.cpp +++ b/src/engine/models/whisper_asr/whisper_asr_model.cpp @@ -71,20 +71,6 @@ std::shared_ptr WhisperAsrModel::Create( } } -const std::string& WhisperAsrModel::ModelType() const noexcept { - static const std::string value = "whisper_asr"; - return value; -} - -const std::string& WhisperAsrModel::Capability() const noexcept { - static const std::string value = "asr"; - return value; -} - -InferenceConcurrency WhisperAsrModel::Concurrency() const noexcept { - return InferenceConcurrency::kConcurrent; -} - int WhisperAsrModel::Transcribe(const AudioPcmBatch& audio, TextBatch* outputs, std::string* diagnostic) noexcept { if (diagnostic) diagnostic->clear(); @@ -199,13 +185,10 @@ int WhisperAsrModel::Transcribe(const AudioPcmBatch& audio, TextBatch* outputs, } static const ModelDefinition kWhisperAsrModelDefinition = [] { - ModelDefinition definition; - definition.model_type = "whisper_asr"; - definition.capability = "asr"; + auto definition = MakeModelDefinition(); definition.description = "Whisper automatic speech recognition model for float32 PCM"; definition.required_protocol = ExecutionProtocol::kAudioTranscription; - definition.concurrency = InferenceConcurrency::kConcurrent; definition.config_fields = { ConfigFieldDefinition{"language", ConfigValueKind::kString, diff --git a/src/engine/models/whisper_asr/whisper_asr_model.h b/src/engine/models/whisper_asr/whisper_asr_model.h index 0e0b5f1d..68a7c1bc 100644 --- a/src/engine/models/whisper_asr/whisper_asr_model.h +++ b/src/engine/models/whisper_asr/whisper_asr_model.h @@ -4,20 +4,20 @@ #include #include "engine/backend_interface.h" +#include "engine/model_identity.h" #include "engine/model_interface.h" #include "engine/model_registry.h" namespace llm_edgeflow { -class WhisperAsrModel final : public IAsrModel { +class WhisperAsrModel final : public ModelIdentity { public: + inline static constexpr char kModelType[] = "whisper_asr"; + static constexpr InferenceConcurrency kConcurrency = + InferenceConcurrency::kConcurrent; static std::shared_ptr Create(const ModelCreateContext& context, std::string* diagnostic); - const std::string& ModelType() const noexcept override; - const std::string& Capability() const noexcept override; - InferenceConcurrency Concurrency() const noexcept override; - int Transcribe(const AudioPcmBatch& audio, TextBatch* outputs, std::string* diagnostic = nullptr) noexcept override; From 315a04d4656688469862abb122a11834ea3b7a53 Mon Sep 17 00:00:00 2001 From: chamsechan Date: Sun, 4 Oct 2026 21:23:17 +0800 Subject: [PATCH 23/29] refactor(engine): declare each Backend provider's type once The four production Backends repeated their type name in kBackendType, a BackendType() override and the BackendDefinition. Providers now derive from BackendIdentity and start their Definition from MakeBackendDefinition(); the factory still checks provider and session types. Function-based provider registration is not adopted: barriers, diagnostics, session ownership and protocol checks live in Load and the factory either way, while the registry API and every test backend would change. The Catalog is byte-identical. Co-Authored-By: Claude Opus 5.5 --- .../edgeflow-backend-developer/SKILL.md | 4 ++- doc/developer_guide.md | 4 ++- include/engine/backend_identity.h | 31 +++++++++++++++++++ plans/ARCHITECTURE_REVIEW.md | 2 +- .../backends/kite_llm/kite_llm_backend.cpp | 8 +---- .../backends/kite_llm/kite_llm_backend.h | 4 +-- .../backends/llama_cpp/llama_cpp_backend.cpp | 8 +---- .../backends/llama_cpp/llama_cpp_backend.h | 5 ++- .../onnxruntime/onnxruntime_backend.cpp | 8 +---- .../onnxruntime/onnxruntime_backend.h | 5 ++- .../whisper_cpp/whisper_cpp_backend.cpp | 8 +---- .../whisper_cpp/whisper_cpp_backend.h | 5 ++- 12 files changed, 50 insertions(+), 42 deletions(-) create mode 100644 include/engine/backend_identity.h diff --git a/.agents/skills/edgeflow-backend-developer/SKILL.md b/.agents/skills/edgeflow-backend-developer/SKILL.md index b1685a22..c2b56760 100644 --- a/.agents/skills/edgeflow-backend-developer/SKILL.md +++ b/.agents/skills/edgeflow-backend-developer/SKILL.md @@ -21,7 +21,9 @@ description: 新增或修改 LLM-EdgeFlow Backend 的厂商 SDK、硬件运行 2. 明确资源创建/失败回收/销毁顺序、输入输出内存生命周期、执行协议、真实并发能力和 BatchPolicy。`BackendLoadSpec::requested_protocol` 必须匹配,尽早拒绝不支持的协议。 Session 实际协议仍需与声明一致。 -3. 注册 `BackendDefinition` 与 `REGISTER_BACKEND_WITH_DEFINITION`,声明全部字段、默认值、 +3. Provider 继承 `BackendIdentity` 并只在 `kBackendType` 声明类型名;从 + `MakeBackendDefinition()` 开始注册 `BackendDefinition` 与 + `REGISTER_BACKEND_WITH_DEFINITION`,声明全部字段、默认值、 范围和协议;额外纯配置约束使用 `validate_config`,Load 复用同一解析规则。 prompt 格式、图像解码、向量池化等模型语义交给 Model。 diff --git a/doc/developer_guide.md b/doc/developer_guide.md index 172c965d..0a636f4f 100644 --- a/doc/developer_guide.md +++ b/doc/developer_guide.md @@ -171,7 +171,9 @@ Model 自注册需实现 `IModel` 的某一强类型能力并声明所需协议 `ModelDefinition` / `BackendDefinition` 并使用对应 `REGISTER_*_WITH_DEFINITION` 宏。 Model 继承 [`ModelIdentity`](../include/engine/model_identity.h),只声明 `kModelType` 与 `kConcurrency`,能力由接口推导;Definition 用 `MakeModelDefinition()` -取得同一身份后再补协议和配置字段。创建时工厂仍逐项核对实例与 Definition。 +取得同一身份后再补协议和配置字段。Backend Provider 同样继承 +[`BackendIdentity`](../include/engine/backend_identity.h),Definition 从 +`MakeBackendDefinition()` 开始。创建时工厂仍逐项核对实例、Session 与 Definition。 Model 的字段类型、默认值和范围由 `config_fields` 声明;字段之间或文本内容的额外规则 放在可选的 `ModelDefinition::validate_config` 中。Validator 与 ModelRuntimeFactory diff --git a/include/engine/backend_identity.h b/include/engine/backend_identity.h new file mode 100644 index 00000000..97f90249 --- /dev/null +++ b/include/engine/backend_identity.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +#include "engine/backend_interface.h" +#include "engine/inference_definition.h" + +namespace llm_edgeflow { + +// Declares a Backend provider's type once through Derived::kBackendType. The +// runtime factory still compares the provider and every loaded session with +// the requested Definition. +template +class BackendIdentity : public IInferenceBackend { + public: + const std::string& BackendType() const noexcept override { + static const std::string type = Derived::kBackendType; + return type; + } +}; + +// Definition carrying BackendClass's type; callers add protocols, +// concurrency, configuration fields and validator. +template +BackendDefinition MakeBackendDefinition() { + BackendDefinition definition; + definition.backend_type = BackendClass::kBackendType; + return definition; +} + +} // namespace llm_edgeflow diff --git a/plans/ARCHITECTURE_REVIEW.md b/plans/ARCHITECTURE_REVIEW.md index 9aa02311..1ccd5aef 100644 --- a/plans/ARCHITECTURE_REVIEW.md +++ b/plans/ARCHITECTURE_REVIEW.md @@ -329,7 +329,7 @@ Definition、Catalog、Validator、节点运行时及工具的解释须一致。 | C9 | 契约元数据减少重复填写 | 第三节·接入适配层 | 已完成:`external_type` 缺省时由外部槽类型推导,16 个生产转换器删除手写值,Catalog 输出逐字节不变;`schema_id`、版本、槽位 `value_type` 与容量策略检查保留 | | C10 | 宿主类型分散注册 | 第三节·接入适配层 | 不采纳:宿主结构是多个转换器共用的平台定义(如 `CompanyOperatorEntityInput` 同时用于文本与翻译输入),类型特征和布局每个类型需要唯一所有者,目前两个集中文件就是所有者;按类型拆分只增加文件而不减少步骤。真实 SDK 类型只能在授权内网接入,现在重设该接缝属于推测 | | D1 | Model 身份声明去重 | 第三节·模型执行层 | 已完成:6 个生产 Model 继承 `ModelIdentity`,身份只声明 `kModelType`/`kConcurrency`,能力由接口推导,Definition 用 `MakeModelDefinition`;工厂的实例核验保留,Catalog 逐字节不变。Backend 类型名随 D2 处理 | -| D2 | Backend Provider 函数化注册 | 第三节·模型执行层 | 待评估 | +| D2 | Backend Provider 函数化注册 | 第三节·模型执行层 | 部分完成:4 个生产 Backend 继承 `BackendIdentity`,类型名只在 `kBackendType` 声明,Definition 用 `MakeBackendDefinition`。函数化注册不采纳:Provider 只是一个小类,异常屏障、诊断、Session 所有权和协议检查都在 `Load` 与工厂中,改为函数不会减少这些工作,却要改动 `BackendRegistry` 接口和所有测试替身 | | D3 | Model/Backend 配置声明与读取统一 | 第三节·模型执行层 | 待评估 | | D4 | Backend 条件编译由 CMake 收敛 | 第三节·模型执行层 | 待评估 | | D5 | 注册表公共操作抽取 | 第三节·横向 | 待评估 | diff --git a/src/engine/backends/kite_llm/kite_llm_backend.cpp b/src/engine/backends/kite_llm/kite_llm_backend.cpp index 192239da..edb93944 100644 --- a/src/engine/backends/kite_llm/kite_llm_backend.cpp +++ b/src/engine/backends/kite_llm/kite_llm_backend.cpp @@ -463,11 +463,6 @@ class KiteImageTextGenerationSession final : public KiteImageSessionBase { } // namespace -const std::string& KiteLlmBackend::BackendType() const noexcept { - static const std::string type = kBackendType; - return type; -} - std::shared_ptr KiteLlmBackend::Load( const BackendLoadSpec& spec, std::string* diagnostic) noexcept { try { @@ -601,8 +596,7 @@ std::shared_ptr KiteLlmBackend::Load( #ifdef HAVE_KITELLM static const BackendDefinition kKiteLlmBackendDefinition = [] { - BackendDefinition definition; - definition.backend_type = KiteLlmBackend::kBackendType; + auto definition = MakeBackendDefinition(); definition.description = "kiteLLM text/image generation and generated-token embeddings (pinned " "CPU release; native " diff --git a/src/engine/backends/kite_llm/kite_llm_backend.h b/src/engine/backends/kite_llm/kite_llm_backend.h index 605e5310..af9f7cf5 100644 --- a/src/engine/backends/kite_llm/kite_llm_backend.h +++ b/src/engine/backends/kite_llm/kite_llm_backend.h @@ -3,16 +3,16 @@ #include #include +#include "engine/backend_identity.h" #include "engine/backend_interface.h" namespace llm_edgeflow { -class KiteLlmBackend final : public IInferenceBackend { +class KiteLlmBackend final : public BackendIdentity { public: inline static constexpr char kBackendType[] = "kite_llm"; ~KiteLlmBackend() override = default; - const std::string& BackendType() const noexcept override; std::shared_ptr Load( const BackendLoadSpec& spec, std::string* diagnostic = nullptr) noexcept override; diff --git a/src/engine/backends/llama_cpp/llama_cpp_backend.cpp b/src/engine/backends/llama_cpp/llama_cpp_backend.cpp index bb33b728..a8042ad7 100644 --- a/src/engine/backends/llama_cpp/llama_cpp_backend.cpp +++ b/src/engine/backends/llama_cpp/llama_cpp_backend.cpp @@ -433,11 +433,6 @@ class LlamaCppTextGenerationSession final : public ITextGenerationSession { } // namespace -const std::string& LlamaCppBackend::BackendType() const noexcept { - static const std::string type = kBackendType; - return type; -} - std::shared_ptr LlamaCppBackend::Load( const BackendLoadSpec& spec, std::string* diagnostic) noexcept { try { @@ -548,8 +543,7 @@ std::shared_ptr LlamaCppBackend::Load( #ifdef HAVE_LLAMACPP static const BackendDefinition kLlamaCppBackendDefinition = [] { - BackendDefinition def; - def.backend_type = LlamaCppBackend::kBackendType; + auto def = MakeBackendDefinition(); def.description = "llama.cpp GGUF text-generation backend"; def.supported_protocols = {ExecutionProtocol::kTextGeneration}; def.concurrency = InferenceConcurrency::kSerialized; diff --git a/src/engine/backends/llama_cpp/llama_cpp_backend.h b/src/engine/backends/llama_cpp/llama_cpp_backend.h index 96aa0188..226d5da8 100644 --- a/src/engine/backends/llama_cpp/llama_cpp_backend.h +++ b/src/engine/backends/llama_cpp/llama_cpp_backend.h @@ -3,6 +3,7 @@ #include #include +#include "engine/backend_identity.h" #include "engine/backend_interface.h" namespace llm_edgeflow { @@ -14,14 +15,12 @@ namespace llm_edgeflow { * This class owns no chat template, sampling, stop-word, or generation-loop * semantics. */ -class LlamaCppBackend final : public IInferenceBackend { +class LlamaCppBackend final : public BackendIdentity { public: inline static constexpr char kBackendType[] = "llama_cpp"; ~LlamaCppBackend() override = default; - const std::string& BackendType() const noexcept override; - std::shared_ptr Load( const BackendLoadSpec& spec, std::string* diagnostic = nullptr) noexcept override; diff --git a/src/engine/backends/onnxruntime/onnxruntime_backend.cpp b/src/engine/backends/onnxruntime/onnxruntime_backend.cpp index b968226a..8cada428 100644 --- a/src/engine/backends/onnxruntime/onnxruntime_backend.cpp +++ b/src/engine/backends/onnxruntime/onnxruntime_backend.cpp @@ -551,11 +551,6 @@ class OnnxTensorGraphSession : public ITensorGraphSession { OnnxRuntimeBackend::OnnxRuntimeBackend() = default; OnnxRuntimeBackend::~OnnxRuntimeBackend() = default; -const std::string& OnnxRuntimeBackend::BackendType() const noexcept { - static const std::string type = kBackendType; - return type; -} - std::shared_ptr OnnxRuntimeBackend::Load( const BackendLoadSpec& spec, std::string* diagnostic) noexcept { try { @@ -732,8 +727,7 @@ std::shared_ptr OnnxRuntimeBackend::Load( #ifdef HAVE_ONNXRUNTIME static const BackendDefinition kOnnxRuntimeBackendDefinition = [] { - BackendDefinition def; - def.backend_type = OnnxRuntimeBackend::kBackendType; + auto def = MakeBackendDefinition(); def.description = "Microsoft ONNX Runtime TensorGraph inference backend"; def.supported_protocols = {ExecutionProtocol::kTensorGraph}; def.concurrency = InferenceConcurrency::kConcurrent; diff --git a/src/engine/backends/onnxruntime/onnxruntime_backend.h b/src/engine/backends/onnxruntime/onnxruntime_backend.h index 7afdc96c..2934e88c 100644 --- a/src/engine/backends/onnxruntime/onnxruntime_backend.h +++ b/src/engine/backends/onnxruntime/onnxruntime_backend.h @@ -4,6 +4,7 @@ #include #include +#include "engine/backend_identity.h" #include "engine/backend_interface.h" #include "engine/backend_registry.h" @@ -38,15 +39,13 @@ bool InferBatchPolicy(const std::vector& inputs, * - 仅在 onnxruntime_backend.cpp 内部使用 onnxruntime_cxx_api.h; * - 上层 Model、Node、Pipeline 完全通过中性 TensorMap 交互。 */ -class OnnxRuntimeBackend : public IInferenceBackend { +class OnnxRuntimeBackend : public BackendIdentity { public: inline static constexpr char kBackendType[] = "onnxruntime"; OnnxRuntimeBackend(); ~OnnxRuntimeBackend() override; - const std::string& BackendType() const noexcept override; - std::shared_ptr Load( const BackendLoadSpec& spec, std::string* diagnostic = nullptr) noexcept override; diff --git a/src/engine/backends/whisper_cpp/whisper_cpp_backend.cpp b/src/engine/backends/whisper_cpp/whisper_cpp_backend.cpp index 00c3bab9..0befb8f6 100644 --- a/src/engine/backends/whisper_cpp/whisper_cpp_backend.cpp +++ b/src/engine/backends/whisper_cpp/whisper_cpp_backend.cpp @@ -204,8 +204,7 @@ class WhisperCppSession final : public IAudioTranscriptionSession { }; static const BackendDefinition kWhisperCppBackendDefinition = [] { - BackendDefinition def; - def.backend_type = WhisperCppBackend::kBackendType; + auto def = MakeBackendDefinition(); def.description = "whisper.cpp ASR inference backend"; def.supported_protocols = {ExecutionProtocol::kAudioTranscription}; def.concurrency = InferenceConcurrency::kSerialized; @@ -226,11 +225,6 @@ REGISTER_BACKEND_WITH_DEFINITION(WhisperCppBackend, } // namespace -const std::string& WhisperCppBackend::BackendType() const noexcept { - static const std::string type = kBackendType; - return type; -} - std::shared_ptr WhisperCppBackend::Load( const BackendLoadSpec& spec, std::string* diagnostic) noexcept { try { diff --git a/src/engine/backends/whisper_cpp/whisper_cpp_backend.h b/src/engine/backends/whisper_cpp/whisper_cpp_backend.h index 32c1468b..b40ecfa6 100644 --- a/src/engine/backends/whisper_cpp/whisper_cpp_backend.h +++ b/src/engine/backends/whisper_cpp/whisper_cpp_backend.h @@ -4,18 +4,17 @@ #include #include +#include "engine/backend_identity.h" #include "engine/backend_interface.h" namespace llm_edgeflow { -class WhisperCppBackend final : public IInferenceBackend { +class WhisperCppBackend final : public BackendIdentity { public: inline static constexpr char kBackendType[] = "whisper_cpp"; ~WhisperCppBackend() override = default; - const std::string& BackendType() const noexcept override; - std::shared_ptr Load( const BackendLoadSpec& spec, std::string* diagnostic = nullptr) noexcept override; From a042e1d0535feccfb06582a6078ea3367f90d8ee Mon Sep 17 00:00:00 2001 From: chamsechan Date: Sun, 4 Oct 2026 21:37:12 +0800 Subject: [PATCH 24/29] refactor(engine): read Model and Backend config defaults from declarations Models and Backends declared each config default in their Definition and repeated it as the fallback of json::value() at creation (33 reads, all matching today). They now read through ConfigValueOrDefault, a neutral contracts helper that falls back to the declared default, and expose their Definitions through accessors so creation code can reach them. Factory normalization, semantic validation and creation checks are unchanged; the Catalog is byte-identical. Co-Authored-By: Claude Opus 5.5 --- doc/developer_guide.md | 3 +- include/contracts/config_schema.h | 19 ++ plans/ARCHITECTURE_REVIEW.md | 2 +- .../backends/kite_llm/kite_llm_backend.cpp | 59 +++--- .../onnxruntime/onnxruntime_backend.cpp | 112 ++++++----- .../bge_embedding/bge_embedding_model.cpp | 177 ++++++++++-------- .../bge_reranker/bge_reranker_model.cpp | 157 +++++++++------- .../generated_text_embedding_model.cpp | 147 ++++++++------- .../qwen_causal_lm/qwen_causal_lm_model.cpp | 89 +++++---- .../vision_document/vision_document_model.cpp | 109 ++++++----- .../models/whisper_asr/whisper_asr_model.cpp | 85 +++++---- .../test_definition_schema_validation.cpp | 26 +++ 12 files changed, 569 insertions(+), 416 deletions(-) diff --git a/doc/developer_guide.md b/doc/developer_guide.md index 0a636f4f..6de1967c 100644 --- a/doc/developer_guide.md +++ b/doc/developer_guide.md @@ -175,7 +175,8 @@ Model 继承 [`ModelIdentity`](../include/engine/model_iden [`BackendIdentity`](../include/engine/backend_identity.h),Definition 从 `MakeBackendDefinition()` 开始。创建时工厂仍逐项核对实例、Session 与 Definition。 -Model 的字段类型、默认值和范围由 `config_fields` 声明;字段之间或文本内容的额外规则 +Model 的字段类型、默认值和范围由 `config_fields` 声明,创建时用 `ConfigValueOrDefault` 读取, +默认值只写在声明中;字段之间或文本内容的额外规则 放在可选的 `ModelDefinition::validate_config` 中。Validator 与 ModelRuntimeFactory 均先完成字段校验和默认值补齐,再调用此纯函数;失败时不创建 Backend 或加载模型。 该函数只检查配置,不读文件或创建会话。直接 Model 创建入口应复用相同语义检查; diff --git a/include/contracts/config_schema.h b/include/contracts/config_schema.h index 55c3f6c6..9d04454d 100644 --- a/include/contracts/config_schema.h +++ b/include/contracts/config_schema.h @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -70,4 +71,22 @@ inline const char* ConfigValueKindName(ConfigValueKind kind) noexcept { } } +// Reads a declared configuration field: the configured value when present, +// otherwise the default from its declaration, so each default is written once +// in the Definition. Throws for undeclared fields or a configured value of the +// wrong type, like nlohmann::json::value. +template +T ConfigValueOrDefault(const nlohmann::json& config, + const std::vector& fields, + const std::string& name) { + if (config.is_object()) { + const auto it = config.find(name); + if (it != config.end()) return it->template get(); + } + for (const auto& field : fields) { + if (field.name == name) return field.default_value.template get(); + } + throw std::invalid_argument("Undeclared configuration field: " + name); +} + } // namespace llm_edgeflow diff --git a/plans/ARCHITECTURE_REVIEW.md b/plans/ARCHITECTURE_REVIEW.md index 1ccd5aef..8be8fd4e 100644 --- a/plans/ARCHITECTURE_REVIEW.md +++ b/plans/ARCHITECTURE_REVIEW.md @@ -330,7 +330,7 @@ Definition、Catalog、Validator、节点运行时及工具的解释须一致。 | C10 | 宿主类型分散注册 | 第三节·接入适配层 | 不采纳:宿主结构是多个转换器共用的平台定义(如 `CompanyOperatorEntityInput` 同时用于文本与翻译输入),类型特征和布局每个类型需要唯一所有者,目前两个集中文件就是所有者;按类型拆分只增加文件而不减少步骤。真实 SDK 类型只能在授权内网接入,现在重设该接缝属于推测 | | D1 | Model 身份声明去重 | 第三节·模型执行层 | 已完成:6 个生产 Model 继承 `ModelIdentity`,身份只声明 `kModelType`/`kConcurrency`,能力由接口推导,Definition 用 `MakeModelDefinition`;工厂的实例核验保留,Catalog 逐字节不变。Backend 类型名随 D2 处理 | | D2 | Backend Provider 函数化注册 | 第三节·模型执行层 | 部分完成:4 个生产 Backend 继承 `BackendIdentity`,类型名只在 `kBackendType` 声明,Definition 用 `MakeBackendDefinition`。函数化注册不采纳:Provider 只是一个小类,异常屏障、诊断、Session 所有权和协议检查都在 `Load` 与工厂中,改为函数不会减少这些工作,却要改动 `BackendRegistry` 接口和所有测试替身 | -| D3 | Model/Backend 配置声明与读取统一 | 第三节·模型执行层 | 待评估 | +| D3 | Model/Backend 配置声明与读取统一 | 第三节·模型执行层 | 已完成:`contracts` 新增 `ConfigValueOrDefault`,6 个 Model 与 2 个 Backend 的 33 处读取改为取 Definition 中声明的默认值(此前手写默认值均与声明一致),Catalog 逐字节不变。未把 Node 的 `Parameters` 移入 contracts:Model 配置只在创建时读取一次,读取器已消除重复 | | D4 | Backend 条件编译由 CMake 收敛 | 第三节·模型执行层 | 待评估 | | D5 | 注册表公共操作抽取 | 第三节·横向 | 待评估 | | D6 | 运行时契约与编排实现目录分离 | 第三节·横向 | 待评估 | diff --git a/src/engine/backends/kite_llm/kite_llm_backend.cpp b/src/engine/backends/kite_llm/kite_llm_backend.cpp index edb93944..27131f49 100644 --- a/src/engine/backends/kite_llm/kite_llm_backend.cpp +++ b/src/engine/backends/kite_llm/kite_llm_backend.cpp @@ -25,6 +25,11 @@ #endif namespace llm_edgeflow { + +#ifdef HAVE_KITELLM +static const BackendDefinition& KiteLlmBackendDefinition(); +#endif + namespace { bool IsCpuPlatform(const std::string& platform) { @@ -508,8 +513,9 @@ std::shared_ptr KiteLlmBackend::Load( return nullptr; } } - const std::string run_config_file = - spec.backend_config.value("run_config_file", ""); + const std::string run_config_file = ConfigValueOrDefault( + spec.backend_config, KiteLlmBackendDefinition().config_fields, + "run_config_file"); std::string resolved_run_config; if (!ResolveRunConfig(spec.model_path, run_config_file, &resolved_run_config, diagnostic)) { @@ -595,31 +601,34 @@ std::shared_ptr KiteLlmBackend::Load( } #ifdef HAVE_KITELLM -static const BackendDefinition kKiteLlmBackendDefinition = [] { - auto definition = MakeBackendDefinition(); - definition.description = - "kiteLLM text/image generation and generated-token embeddings (pinned " - "CPU release; native " - "device ID)"; - definition.supported_protocols = { - ExecutionProtocol::kTextGeneration, - ExecutionProtocol::kImageTextGeneration, - ExecutionProtocol::kGeneratedTokenEmbedding}; - definition.concurrency = InferenceConcurrency::kSerialized; - definition.config_fields = { - {"run_config_file", - ConfigValueKind::kString, - false, - "", - std::nullopt, - std::nullopt, - {}, - "Optional model-relative kiteLLM run-config file; device ID is passed " - "through the native parameter API"}}; +static const BackendDefinition& KiteLlmBackendDefinition() { + static const BackendDefinition definition = [] { + auto definition = MakeBackendDefinition(); + definition.description = + "kiteLLM text/image generation and generated-token embeddings (pinned " + "CPU release; native " + "device ID)"; + definition.supported_protocols = { + ExecutionProtocol::kTextGeneration, + ExecutionProtocol::kImageTextGeneration, + ExecutionProtocol::kGeneratedTokenEmbedding}; + definition.concurrency = InferenceConcurrency::kSerialized; + definition.config_fields = { + {"run_config_file", + ConfigValueKind::kString, + false, + "", + std::nullopt, + std::nullopt, + {}, + "Optional model-relative kiteLLM run-config file; device ID is passed " + "through the native parameter API"}}; + return definition; + }(); return definition; -}(); +} -REGISTER_BACKEND_WITH_DEFINITION(KiteLlmBackend, kKiteLlmBackendDefinition); +REGISTER_BACKEND_WITH_DEFINITION(KiteLlmBackend, KiteLlmBackendDefinition()); #endif } // namespace llm_edgeflow diff --git a/src/engine/backends/onnxruntime/onnxruntime_backend.cpp b/src/engine/backends/onnxruntime/onnxruntime_backend.cpp index 8cada428..73b09331 100644 --- a/src/engine/backends/onnxruntime/onnxruntime_backend.cpp +++ b/src/engine/backends/onnxruntime/onnxruntime_backend.cpp @@ -18,6 +18,10 @@ namespace llm_edgeflow { +#ifdef HAVE_ONNXRUNTIME +static const BackendDefinition& OnnxRuntimeBackendDefinition(); +#endif + namespace onnxruntime_detail { bool ValidateInputTensor(const Tensor& tensor, const TensorSpec& spec, @@ -589,10 +593,15 @@ std::shared_ptr OnnxRuntimeBackend::Load( "OnnxRuntimeBackend"); Ort::SessionOptions session_options; - int intra_threads = spec.backend_config.value("intra_op_num_threads", 2); - int inter_threads = spec.backend_config.value("inter_op_num_threads", 1); - std::string opt_level_str = - spec.backend_config.value("graph_optimization_level", "all"); + int intra_threads = ConfigValueOrDefault( + spec.backend_config, OnnxRuntimeBackendDefinition().config_fields, + "intra_op_num_threads"); + int inter_threads = ConfigValueOrDefault( + spec.backend_config, OnnxRuntimeBackendDefinition().config_fields, + "inter_op_num_threads"); + std::string opt_level_str = ConfigValueOrDefault( + spec.backend_config, OnnxRuntimeBackendDefinition().config_fields, + "graph_optimization_level"); session_options.SetIntraOpNumThreads(intra_threads); session_options.SetInterOpNumThreads(inter_threads); @@ -699,8 +708,9 @@ std::shared_ptr OnnxRuntimeBackend::Load( outputs.push_back(std::move(out_spec)); } - const size_t config_max_batch = - spec.backend_config.value("max_batch_size", 4); + const size_t config_max_batch = ConfigValueOrDefault( + spec.backend_config, OnnxRuntimeBackendDefinition().config_fields, + "max_batch_size"); BatchPolicy policy; if (!onnxruntime_detail::InferBatchPolicy(inputs, outputs, config_max_batch, &policy, diagnostic)) { @@ -726,51 +736,55 @@ std::shared_ptr OnnxRuntimeBackend::Load( } #ifdef HAVE_ONNXRUNTIME -static const BackendDefinition kOnnxRuntimeBackendDefinition = [] { - auto def = MakeBackendDefinition(); - def.description = "Microsoft ONNX Runtime TensorGraph inference backend"; - def.supported_protocols = {ExecutionProtocol::kTensorGraph}; - def.concurrency = InferenceConcurrency::kConcurrent; - def.config_fields = { - {"max_batch_size", - ConfigValueKind::kInteger, - false, - 4, - 1.0, - 1024.0, - {}, - "动态 batch 的样本数上限;静态 batch 模型使用张量形状声明的固定批次。"}, - {"intra_op_num_threads", - ConfigValueKind::kInteger, - false, - 2, - 1.0, - 64.0, - {}, - "ONNX Runtime 单个算子内部的 CPU 线程数。"}, - {"inter_op_num_threads", - ConfigValueKind::kInteger, - false, - 1, - 1.0, - 64.0, - {}, - "传给 ONNX Runtime 的算子间线程数;是否使用由运行时执行模式决定。"}, - {"graph_optimization_level", - ConfigValueKind::kString, - false, - "all", - std::nullopt, - std::nullopt, - {"none", "basic", "extended", "all"}, - "传给 ONNX Runtime 的图优化级别:none 关闭,basic/extended/all " - "依次选择对应优化集合。"}, - }; - return def; -}(); +static const BackendDefinition& OnnxRuntimeBackendDefinition() { + static const BackendDefinition definition = [] { + auto def = MakeBackendDefinition(); + def.description = "Microsoft ONNX Runtime TensorGraph inference backend"; + def.supported_protocols = {ExecutionProtocol::kTensorGraph}; + def.concurrency = InferenceConcurrency::kConcurrent; + def.config_fields = { + {"max_batch_size", + ConfigValueKind::kInteger, + false, + 4, + 1.0, + 1024.0, + {}, + "动态 batch 的样本数上限;静态 batch " + "模型使用张量形状声明的固定批次。"}, + {"intra_op_num_threads", + ConfigValueKind::kInteger, + false, + 2, + 1.0, + 64.0, + {}, + "ONNX Runtime 单个算子内部的 CPU 线程数。"}, + {"inter_op_num_threads", + ConfigValueKind::kInteger, + false, + 1, + 1.0, + 64.0, + {}, + "传给 ONNX Runtime 的算子间线程数;是否使用由运行时执行模式决定。"}, + {"graph_optimization_level", + ConfigValueKind::kString, + false, + "all", + std::nullopt, + std::nullopt, + {"none", "basic", "extended", "all"}, + "传给 ONNX Runtime 的图优化级别:none 关闭,basic/extended/all " + "依次选择对应优化集合。"}, + }; + return def; + }(); + return definition; +} REGISTER_BACKEND_WITH_DEFINITION(OnnxRuntimeBackend, - kOnnxRuntimeBackendDefinition); + OnnxRuntimeBackendDefinition()); #endif } // namespace llm_edgeflow diff --git a/src/engine/models/bge_embedding/bge_embedding_model.cpp b/src/engine/models/bge_embedding/bge_embedding_model.cpp index 38ea9af1..b6dbed82 100644 --- a/src/engine/models/bge_embedding/bge_embedding_model.cpp +++ b/src/engine/models/bge_embedding/bge_embedding_model.cpp @@ -14,6 +14,8 @@ namespace llm_edgeflow { +static const ModelDefinition& BgeEmbeddingModelDefinition(); + namespace { bool ValidateEmbeddingOutput(const Tensor& tensor, size_t expected_batch, @@ -162,14 +164,24 @@ std::shared_ptr BgeEmbeddingModel::Create(const ModelCreateContext& ctx, return nullptr; } size_t embedding_dim = ctx.model_config["embedding_dim"].get(); - size_t max_batch_size = ctx.model_config.value("max_batch_size", 4); - std::string tokenizer_file = - ctx.model_config.value("tokenizer_file", "vocab.txt"); - bool do_lower_case = ctx.model_config.value("do_lower_case", true); - size_t max_length = ctx.model_config.value("max_length", 512); - std::string pooling = ctx.model_config.value("pooling_strategy", "cls"); - std::string output_name = - ctx.model_config.value("output_name", "last_hidden_state"); + size_t max_batch_size = ConfigValueOrDefault( + ctx.model_config, BgeEmbeddingModelDefinition().config_fields, + "max_batch_size"); + std::string tokenizer_file = ConfigValueOrDefault( + ctx.model_config, BgeEmbeddingModelDefinition().config_fields, + "tokenizer_file"); + bool do_lower_case = ConfigValueOrDefault( + ctx.model_config, BgeEmbeddingModelDefinition().config_fields, + "do_lower_case"); + size_t max_length = ConfigValueOrDefault( + ctx.model_config, BgeEmbeddingModelDefinition().config_fields, + "max_length"); + std::string pooling = ConfigValueOrDefault( + ctx.model_config, BgeEmbeddingModelDefinition().config_fields, + "pooling_strategy"); + std::string output_name = ConfigValueOrDefault( + ctx.model_config, BgeEmbeddingModelDefinition().config_fields, + "output_name"); if (!ValidateModelBatchLimit(tensor_session->GetBatchPolicy(), max_batch_size, diagnostic) || @@ -390,77 +402,82 @@ int BgeEmbeddingModel::RawEmbedSlice( } } -static const ModelDefinition kBgeEmbeddingModelDefinition = [] { - auto def = MakeModelDefinition(); - def.description = "BGE text embedding model using TensorGraph protocol"; - def.required_protocol = ExecutionProtocol::kTensorGraph; - def.config_fields = { - {"tokenizer_file", - ConfigValueKind::kString, - false, - "vocab.txt", - std::nullopt, - std::nullopt, - {}, - "匹配该权重的 BERT WordPiece 词表;相对路径基于模型资源目录,例如 " - "vocab.txt,也可使用绝对路径。"}, - {"do_lower_case", - ConfigValueKind::kBoolean, - false, - true, - std::nullopt, - std::nullopt, - {}, - "分词前将英文大写和全角英文字母归一为半角小写,须与模型训练时的分词设置" - "一致。"}, - {"max_length", - ConfigValueKind::kInteger, - false, - 512, - 2.0, - 4096.0, - {}, - "每条文本编码后的固定 token 长度,包含 " - "[CLS]/[SEP];超长截断,不足补齐,须匹配模型张量形状。"}, - {"pooling_strategy", - ConfigValueKind::kString, - false, - "cls", - std::nullopt, - std::nullopt, - {"cls", "mean"}, - "三维 token 输出的池化:cls 取首 token,mean 按 attention_mask " - "求均值;二维向量输出直接使用。"}, - {"output_name", - ConfigValueKind::kString, - false, - "last_hidden_state", - std::nullopt, - std::nullopt, - {}, - "读取的模型输出张量名,例如 " - "last_hidden_state,必须与模型导出名称一致。"}, - {"embedding_dim", - ConfigValueKind::kInteger, - true, - nlohmann::json(), - 1.0, - 65536.0, - {}, - "每条输出向量的维数,必须与所选权重的输出维度一致。"}, - {"max_batch_size", - ConfigValueKind::kInteger, - false, - 4, - 1.0, - 1024.0, - {}, - "模型切批的样本数上限;必须满足 Backend 的动态上限或固定批次要求。"}, - }; - def.validate_config = ValidateBertModelConfig; - return def; -}(); - -REGISTER_MODEL_WITH_DEFINITION(BgeEmbeddingModel, kBgeEmbeddingModelDefinition); +static const ModelDefinition& BgeEmbeddingModelDefinition() { + static const ModelDefinition definition = [] { + auto def = MakeModelDefinition(); + def.description = "BGE text embedding model using TensorGraph protocol"; + def.required_protocol = ExecutionProtocol::kTensorGraph; + def.config_fields = { + {"tokenizer_file", + ConfigValueKind::kString, + false, + "vocab.txt", + std::nullopt, + std::nullopt, + {}, + "匹配该权重的 BERT WordPiece 词表;相对路径基于模型资源目录,例如 " + "vocab.txt,也可使用绝对路径。"}, + {"do_lower_case", + ConfigValueKind::kBoolean, + false, + true, + std::nullopt, + std::nullopt, + {}, + "分词前将英文大写和全角英文字母归一为半角小写,须与模型训练时的分词设" + "置" + "一致。"}, + {"max_length", + ConfigValueKind::kInteger, + false, + 512, + 2.0, + 4096.0, + {}, + "每条文本编码后的固定 token 长度,包含 " + "[CLS]/[SEP];超长截断,不足补齐,须匹配模型张量形状。"}, + {"pooling_strategy", + ConfigValueKind::kString, + false, + "cls", + std::nullopt, + std::nullopt, + {"cls", "mean"}, + "三维 token 输出的池化:cls 取首 token,mean 按 attention_mask " + "求均值;二维向量输出直接使用。"}, + {"output_name", + ConfigValueKind::kString, + false, + "last_hidden_state", + std::nullopt, + std::nullopt, + {}, + "读取的模型输出张量名,例如 " + "last_hidden_state,必须与模型导出名称一致。"}, + {"embedding_dim", + ConfigValueKind::kInteger, + true, + nlohmann::json(), + 1.0, + 65536.0, + {}, + "每条输出向量的维数,必须与所选权重的输出维度一致。"}, + {"max_batch_size", + ConfigValueKind::kInteger, + false, + 4, + 1.0, + 1024.0, + {}, + "模型切批的样本数上限;必须满足 Backend 的动态上限或固定批次要求。"}, + }; + def.validate_config = ValidateBertModelConfig; + return def; + }(); + return definition; +} + +REGISTER_MODEL_WITH_DEFINITION(BgeEmbeddingModel, + BgeEmbeddingModelDefinition()); } // namespace llm_edgeflow diff --git a/src/engine/models/bge_reranker/bge_reranker_model.cpp b/src/engine/models/bge_reranker/bge_reranker_model.cpp index a35f14f9..80e0290c 100644 --- a/src/engine/models/bge_reranker/bge_reranker_model.cpp +++ b/src/engine/models/bge_reranker/bge_reranker_model.cpp @@ -13,6 +13,8 @@ namespace llm_edgeflow { +static const ModelDefinition& BgeRerankerModelDefinition(); + namespace { bool ValidateRerankOutput(const Tensor& tensor, size_t expected_batch, @@ -62,10 +64,15 @@ std::shared_ptr BgeRerankerModel::Create(const ModelCreateContext& ctx, RequireTensorGraphSession(ctx.backend_session, diagnostic); if (!tensor_session) return nullptr; - std::string tokenizer_file = - ctx.model_config.value("tokenizer_file", "vocab.txt"); - bool do_lower_case = ctx.model_config.value("do_lower_case", true); - size_t max_length = ctx.model_config.value("max_length", 512); + std::string tokenizer_file = ConfigValueOrDefault( + ctx.model_config, BgeRerankerModelDefinition().config_fields, + "tokenizer_file"); + bool do_lower_case = ConfigValueOrDefault( + ctx.model_config, BgeRerankerModelDefinition().config_fields, + "do_lower_case"); + size_t max_length = ConfigValueOrDefault( + ctx.model_config, BgeRerankerModelDefinition().config_fields, + "max_length"); if (max_length < 3 || max_length > 4096) { if (diagnostic) { *diagnostic = "Field 'max_length' must be in range [3, 4096], got: " + @@ -74,9 +81,12 @@ std::shared_ptr BgeRerankerModel::Create(const ModelCreateContext& ctx, return nullptr; } - std::string output_name = ctx.model_config.value("output_name", "logits"); - std::string score_activation = - ctx.model_config.value("score_activation", "sigmoid"); + std::string output_name = ConfigValueOrDefault( + ctx.model_config, BgeRerankerModelDefinition().config_fields, + "output_name"); + std::string score_activation = ConfigValueOrDefault( + ctx.model_config, BgeRerankerModelDefinition().config_fields, + "score_activation"); if (score_activation != "sigmoid" && score_activation != "identity") { if (diagnostic) { *diagnostic = @@ -86,7 +96,9 @@ std::shared_ptr BgeRerankerModel::Create(const ModelCreateContext& ctx, return nullptr; } - size_t max_batch_size = ctx.model_config.value("max_batch_size", 4); + size_t max_batch_size = ConfigValueOrDefault( + ctx.model_config, BgeRerankerModelDefinition().config_fields, + "max_batch_size"); if (max_batch_size == 0) { if (diagnostic) { *diagnostic = "Field 'max_batch_size' must be at least 1"; @@ -301,67 +313,72 @@ int BgeRerankerModel::RawScoreSlice(const QueryCandidatesBatch& all_inputs, } } -static const ModelDefinition kBgeRerankerModelDefinition = [] { - auto def = MakeModelDefinition(); - def.description = - "BGE cross-encoder reranker model using TensorGraph protocol"; - def.required_protocol = ExecutionProtocol::kTensorGraph; - def.config_fields = { - {"tokenizer_file", - ConfigValueKind::kString, - false, - "vocab.txt", - std::nullopt, - std::nullopt, - {}, - "匹配该重排权重的 BERT WordPiece 词表;相对路径基于模型资源目录,例如 " - "vocab.txt,也可使用绝对路径。"}, - {"do_lower_case", - ConfigValueKind::kBoolean, - false, - true, - std::nullopt, - std::nullopt, - {}, - "查询和候选分词前,将英文大写和全角英文字母归一为半角小写。"}, - {"max_length", - ConfigValueKind::kInteger, - false, - 512, - 3.0, - 4096.0, - {}, - "查询与候选成对编码后的总 token 长度,包含 " - "[CLS]/[SEP];截断和补齐后须匹配模型张量形状。"}, - {"output_name", - ConfigValueKind::kString, - false, - "logits", - std::nullopt, - std::nullopt, - {}, - "读取查询与候选评分的输出张量名,例如 logits,必须与模型导出名称一致。"}, - {"score_activation", - ConfigValueKind::kString, - false, - "sigmoid", - std::nullopt, - std::nullopt, - {"sigmoid", "identity"}, - "sigmoid 将模型原始分数映射至 [0,1];identity 直接使用原始分数。"}, - {"max_batch_size", - ConfigValueKind::kInteger, - false, - 4, - 1.0, - 1024.0, - {}, - "一次模型执行处理的查询与候选对数量上限;必须满足 Backend 的批次约束。"}, - }; - def.validate_config = ValidateBertModelConfig; - return def; -}(); - -REGISTER_MODEL_WITH_DEFINITION(BgeRerankerModel, kBgeRerankerModelDefinition); +static const ModelDefinition& BgeRerankerModelDefinition() { + static const ModelDefinition definition = [] { + auto def = MakeModelDefinition(); + def.description = + "BGE cross-encoder reranker model using TensorGraph protocol"; + def.required_protocol = ExecutionProtocol::kTensorGraph; + def.config_fields = { + {"tokenizer_file", + ConfigValueKind::kString, + false, + "vocab.txt", + std::nullopt, + std::nullopt, + {}, + "匹配该重排权重的 BERT WordPiece 词表;相对路径基于模型资源目录,例如 " + "vocab.txt,也可使用绝对路径。"}, + {"do_lower_case", + ConfigValueKind::kBoolean, + false, + true, + std::nullopt, + std::nullopt, + {}, + "查询和候选分词前,将英文大写和全角英文字母归一为半角小写。"}, + {"max_length", + ConfigValueKind::kInteger, + false, + 512, + 3.0, + 4096.0, + {}, + "查询与候选成对编码后的总 token 长度,包含 " + "[CLS]/[SEP];截断和补齐后须匹配模型张量形状。"}, + {"output_name", + ConfigValueKind::kString, + false, + "logits", + std::nullopt, + std::nullopt, + {}, + "读取查询与候选评分的输出张量名,例如 " + "logits,必须与模型导出名称一致。"}, + {"score_activation", + ConfigValueKind::kString, + false, + "sigmoid", + std::nullopt, + std::nullopt, + {"sigmoid", "identity"}, + "sigmoid 将模型原始分数映射至 [0,1];identity 直接使用原始分数。"}, + {"max_batch_size", + ConfigValueKind::kInteger, + false, + 4, + 1.0, + 1024.0, + {}, + "一次模型执行处理的查询与候选对数量上限;必须满足 Backend " + "的批次约束。"}, + }; + def.validate_config = ValidateBertModelConfig; + return def; + }(); + return definition; +} + +REGISTER_MODEL_WITH_DEFINITION(BgeRerankerModel, BgeRerankerModelDefinition()); } // namespace llm_edgeflow diff --git a/src/engine/models/generated_text_embedding/generated_text_embedding_model.cpp b/src/engine/models/generated_text_embedding/generated_text_embedding_model.cpp index 41b546fb..07666831 100644 --- a/src/engine/models/generated_text_embedding/generated_text_embedding_model.cpp +++ b/src/engine/models/generated_text_embedding/generated_text_embedding_model.cpp @@ -10,6 +10,8 @@ namespace llm_edgeflow { +static const ModelDefinition& GeneratedTextEmbeddingDefinition(); + std::shared_ptr GeneratedTextEmbeddingModel::Create( const ModelCreateContext& context, std::string* diagnostic) { try { @@ -26,8 +28,9 @@ std::shared_ptr GeneratedTextEmbeddingModel::Create( auto model = std::make_shared(); model->session_ = std::move(session); const auto& dimension = context.model_config.at("embedding_dim"); - const auto limit = - context.model_config.value("max_tokens", nlohmann::json(1)); + const auto limit = ConfigValueOrDefault( + context.model_config, GeneratedTextEmbeddingDefinition().config_fields, + "max_tokens"); if (!dimension.is_number_integer() || dimension < 1 || dimension > 65536 || !limit.is_number_integer() || limit < 1 || limit > 64) { throw std::runtime_error( @@ -35,10 +38,18 @@ std::shared_ptr GeneratedTextEmbeddingModel::Create( } model->embedding_dim_ = dimension.get(); model->max_tokens_ = limit.get(); - model->pooling_ = context.model_config.value("pooling", "last"); - model->prefix_ = context.model_config.value("prefix", ""); - model->suffix_ = context.model_config.value("suffix", ""); - model->add_bos_ = context.model_config.value("add_bos", false); + model->pooling_ = ConfigValueOrDefault( + context.model_config, GeneratedTextEmbeddingDefinition().config_fields, + "pooling"); + model->prefix_ = ConfigValueOrDefault( + context.model_config, GeneratedTextEmbeddingDefinition().config_fields, + "prefix"); + model->suffix_ = ConfigValueOrDefault( + context.model_config, GeneratedTextEmbeddingDefinition().config_fields, + "suffix"); + model->add_bos_ = ConfigValueOrDefault( + context.model_config, GeneratedTextEmbeddingDefinition().config_fields, + "add_bos"); if (model->pooling_ != "last" && model->pooling_ != "mean") { throw std::runtime_error( "Invalid generated_text_embedding configuration"); @@ -135,67 +146,71 @@ int GeneratedTextEmbeddingModel::Embed(const TextBatch& inputs, outputs, diagnostic); } -static const ModelDefinition kGeneratedTextEmbeddingDefinition = [] { - auto definition = MakeModelDefinition(); - definition.description = - "Experimental text vectors from greedy generated-token hidden states; " - "last/mean pooling, not encoder embeddings"; - definition.required_protocol = ExecutionProtocol::kGeneratedTokenEmbedding; - definition.config_fields = { - {"embedding_dim", - ConfigValueKind::kInteger, - true, - nullptr, - 1.0, - 65536.0, - {}, - "生成 token 的隐藏向量维数,必须与 Backend 返回的每个 token " - "向量维度一致。"}, - {"max_tokens", - ConfigValueKind::kInteger, - false, - 1, - 1.0, - 64.0, - {}, - "用于生成嵌入的 token 数上限;控制参与 last/mean 池化的生成 " - "token,不是输入文本长度。"}, - {"pooling", - ConfigValueKind::kString, - false, - "last", - std::nullopt, - std::nullopt, - {"last", "mean"}, - "last 取最后一个生成 token 的向量;mean 对全部生成 token 向量求均值。"}, - {"prefix", - ConfigValueKind::kString, - false, - "", - std::nullopt, - std::nullopt, - {}, - "直接拼接在输入文本之前的模型提示词,不自动插入分隔符。"}, - {"suffix", - ConfigValueKind::kString, - false, - "", - std::nullopt, - std::nullopt, - {}, - "直接拼接在输入文本之后的模型提示词,不自动插入分隔符。"}, - {"add_bos", - ConfigValueKind::kBoolean, - false, - false, - std::nullopt, - std::nullopt, - {}, - "编码输入时请求添加模型的 BOS 起始 " - "token,须与所选模型的分词约定一致。"}}; +static const ModelDefinition& GeneratedTextEmbeddingDefinition() { + static const ModelDefinition definition = [] { + auto definition = MakeModelDefinition(); + definition.description = + "Experimental text vectors from greedy generated-token hidden states; " + "last/mean pooling, not encoder embeddings"; + definition.required_protocol = ExecutionProtocol::kGeneratedTokenEmbedding; + definition.config_fields = { + {"embedding_dim", + ConfigValueKind::kInteger, + true, + nullptr, + 1.0, + 65536.0, + {}, + "生成 token 的隐藏向量维数,必须与 Backend 返回的每个 token " + "向量维度一致。"}, + {"max_tokens", + ConfigValueKind::kInteger, + false, + 1, + 1.0, + 64.0, + {}, + "用于生成嵌入的 token 数上限;控制参与 last/mean 池化的生成 " + "token,不是输入文本长度。"}, + {"pooling", + ConfigValueKind::kString, + false, + "last", + std::nullopt, + std::nullopt, + {"last", "mean"}, + "last 取最后一个生成 token 的向量;mean 对全部生成 token " + "向量求均值。"}, + {"prefix", + ConfigValueKind::kString, + false, + "", + std::nullopt, + std::nullopt, + {}, + "直接拼接在输入文本之前的模型提示词,不自动插入分隔符。"}, + {"suffix", + ConfigValueKind::kString, + false, + "", + std::nullopt, + std::nullopt, + {}, + "直接拼接在输入文本之后的模型提示词,不自动插入分隔符。"}, + {"add_bos", + ConfigValueKind::kBoolean, + false, + false, + std::nullopt, + std::nullopt, + {}, + "编码输入时请求添加模型的 BOS 起始 " + "token,须与所选模型的分词约定一致。"}}; + return definition; + }(); return definition; -}(); +} REGISTER_MODEL_WITH_DEFINITION(GeneratedTextEmbeddingModel, - kGeneratedTextEmbeddingDefinition); + GeneratedTextEmbeddingDefinition()); } // namespace llm_edgeflow diff --git a/src/engine/models/qwen_causal_lm/qwen_causal_lm_model.cpp b/src/engine/models/qwen_causal_lm/qwen_causal_lm_model.cpp index 9fce8ee1..f2eab037 100644 --- a/src/engine/models/qwen_causal_lm/qwen_causal_lm_model.cpp +++ b/src/engine/models/qwen_causal_lm/qwen_causal_lm_model.cpp @@ -15,6 +15,9 @@ #include "engine/text/utf8.h" namespace llm_edgeflow { + +static const ModelDefinition& QwenCausalLmModelDefinition(); + namespace { uint64_t MixSeed(uint64_t seed, uint32_t req_id, uint32_t sub_id) noexcept { @@ -48,11 +51,15 @@ std::shared_ptr QwenCausalLmModel::Create(const ModelCreateContext& ctx, return nullptr; } - const std::string system_prompt = - ctx.model_config.value("system_prompt", ""); - const bool add_bos = ctx.model_config.value("add_bos", false); - const int64_t random_seed = - ctx.model_config.value("random_seed", int64_t{-1}); + const std::string system_prompt = ConfigValueOrDefault( + ctx.model_config, QwenCausalLmModelDefinition().config_fields, + "system_prompt"); + const bool add_bos = ConfigValueOrDefault( + ctx.model_config, QwenCausalLmModelDefinition().config_fields, + "add_bos"); + const int64_t random_seed = ConfigValueOrDefault( + ctx.model_config, QwenCausalLmModelDefinition().config_fields, + "random_seed"); if (random_seed < -1) { if (diagnostic) *diagnostic = "random_seed must be -1 or non-negative"; return nullptr; @@ -167,41 +174,45 @@ int QwenCausalLmModel::GenerateOne(const TraceableItem& prompt, } } -static const ModelDefinition kQwenCausalLmModelDefinition = [] { - auto definition = MakeModelDefinition(); - definition.description = - "Qwen ChatML model using the unified text-generation protocol"; - definition.required_protocol = ExecutionProtocol::kTextGeneration; - definition.config_fields = { - {"system_prompt", - ConfigValueKind::kString, - false, - "", - std::nullopt, - std::nullopt, - {}, - "Qwen ChatML 的 system 角色内容;空字符串时省略该角色。"}, - {"add_bos", - ConfigValueKind::kBoolean, - false, - false, - std::nullopt, - std::nullopt, - {}, - "分词时请求添加 BOS 起始 token,须与所选权重的分词约定一致。"}, - {"random_seed", - ConfigValueKind::kInteger, - false, - -1, - -1.0, - static_cast(std::numeric_limits::max()), - {}, - "-1 不显式指定随机种子;非负值结合 req_id/sub_id " - "派生每条输入的采样种子。"}, - }; +static const ModelDefinition& QwenCausalLmModelDefinition() { + static const ModelDefinition definition = [] { + auto definition = MakeModelDefinition(); + definition.description = + "Qwen ChatML model using the unified text-generation protocol"; + definition.required_protocol = ExecutionProtocol::kTextGeneration; + definition.config_fields = { + {"system_prompt", + ConfigValueKind::kString, + false, + "", + std::nullopt, + std::nullopt, + {}, + "Qwen ChatML 的 system 角色内容;空字符串时省略该角色。"}, + {"add_bos", + ConfigValueKind::kBoolean, + false, + false, + std::nullopt, + std::nullopt, + {}, + "分词时请求添加 BOS 起始 token,须与所选权重的分词约定一致。"}, + {"random_seed", + ConfigValueKind::kInteger, + false, + -1, + -1.0, + static_cast(std::numeric_limits::max()), + {}, + "-1 不显式指定随机种子;非负值结合 req_id/sub_id " + "派生每条输入的采样种子。"}, + }; + return definition; + }(); return definition; -}(); +} -REGISTER_MODEL_WITH_DEFINITION(QwenCausalLmModel, kQwenCausalLmModelDefinition); +REGISTER_MODEL_WITH_DEFINITION(QwenCausalLmModel, + QwenCausalLmModelDefinition()); } // namespace llm_edgeflow diff --git a/src/engine/models/vision_document/vision_document_model.cpp b/src/engine/models/vision_document/vision_document_model.cpp index 8d2ef6ca..35fac0e5 100644 --- a/src/engine/models/vision_document/vision_document_model.cpp +++ b/src/engine/models/vision_document/vision_document_model.cpp @@ -6,13 +6,17 @@ #include "engine/models/vision_document/image_decode.h" namespace llm_edgeflow { + +static const ModelDefinition& VisionDocumentDefinition(); + namespace { constexpr char kPrompt[] = "Read all text visible in this image. Return only the transcribed text."; bool ValidateVisionConfig(const nlohmann::json& config, std::string* diagnostic) { - const auto prompt = config.value("prompt", std::string(kPrompt)); + const auto prompt = ConfigValueOrDefault( + config, VisionDocumentDefinition().config_fields, "prompt"); if (prompt.empty() || prompt.find('\0') != std::string::npos) { SetDiagnosticNoexcept( diagnostic, "Field 'prompt' must be non-empty and contain no NUL"); @@ -37,10 +41,18 @@ std::shared_ptr VisionDocumentModel::Create( } auto model = std::make_shared(); model->session_ = std::move(session); - model->prompt_ = context.model_config.value("prompt", std::string(kPrompt)); - model->patch_size_ = context.model_config.value("patch_size", 16); - const int max_pixels = context.model_config.value("max_pixels", 4194304); - model->options_.max_tokens = context.model_config.value("max_tokens", 512); + model->prompt_ = ConfigValueOrDefault( + context.model_config, VisionDocumentDefinition().config_fields, + "prompt"); + model->patch_size_ = ConfigValueOrDefault( + context.model_config, VisionDocumentDefinition().config_fields, + "patch_size"); + const int max_pixels = ConfigValueOrDefault( + context.model_config, VisionDocumentDefinition().config_fields, + "max_pixels"); + model->options_.max_tokens = ConfigValueOrDefault( + context.model_config, VisionDocumentDefinition().config_fields, + "max_tokens"); if (model->patch_size_ < 1 || model->patch_size_ > 256 || max_pixels < 1 || max_pixels > 16777216 || model->options_.max_tokens < 1 || model->options_.max_tokens > 4096) { @@ -103,48 +115,51 @@ int VisionDocumentModel::Recognize(const ImageRefBatch& images, outputs, diagnostic); } -static const ModelDefinition kVisionDocumentDefinition = [] { - auto definition = MakeModelDefinition(); - definition.description = - "Image-to-text document recognition; text only, no detected boxes"; - definition.required_protocol = ExecutionProtocol::kImageTextGeneration; - definition.validate_config = ValidateVisionConfig; - definition.config_fields = { - {"prompt", - ConfigValueKind::kString, - false, - kPrompt, - std::nullopt, - std::nullopt, - {}, - "图像转文本的识别指令;须为非空文本,识别输出作为文档全文。"}, - {"patch_size", - ConfigValueKind::kInteger, - false, - 16, - 1.0, - 256.0, - {}, - "图像块边长,单位为像素;解码后宽高补齐到其整数倍,须符合图像模型约定" - "。"}, - {"max_pixels", - ConfigValueKind::kInteger, - false, - 4194304, - 1.0, - 16777216.0, - {}, - "允许的图像总像素数上限,同时检查原图与按 patch_size 补齐后的图像。"}, - {"max_tokens", - ConfigValueKind::kInteger, - false, - 512, - 1.0, - 4096.0, - {}, - "每张图像识别时最多生成的文本 token 数;该路径使用贪心生成。"}}; +static const ModelDefinition& VisionDocumentDefinition() { + static const ModelDefinition definition = [] { + auto definition = MakeModelDefinition(); + definition.description = + "Image-to-text document recognition; text only, no detected boxes"; + definition.required_protocol = ExecutionProtocol::kImageTextGeneration; + definition.validate_config = ValidateVisionConfig; + definition.config_fields = { + {"prompt", + ConfigValueKind::kString, + false, + kPrompt, + std::nullopt, + std::nullopt, + {}, + "图像转文本的识别指令;须为非空文本,识别输出作为文档全文。"}, + {"patch_size", + ConfigValueKind::kInteger, + false, + 16, + 1.0, + 256.0, + {}, + "图像块边长,单位为像素;解码后宽高补齐到其整数倍,须符合图像模型约定" + "。"}, + {"max_pixels", + ConfigValueKind::kInteger, + false, + 4194304, + 1.0, + 16777216.0, + {}, + "允许的图像总像素数上限,同时检查原图与按 patch_size 补齐后的图像。"}, + {"max_tokens", + ConfigValueKind::kInteger, + false, + 512, + 1.0, + 4096.0, + {}, + "每张图像识别时最多生成的文本 token 数;该路径使用贪心生成。"}}; + return definition; + }(); return definition; -}(); -REGISTER_MODEL_WITH_DEFINITION(VisionDocumentModel, kVisionDocumentDefinition); +} +REGISTER_MODEL_WITH_DEFINITION(VisionDocumentModel, VisionDocumentDefinition()); } // namespace llm_edgeflow diff --git a/src/engine/models/whisper_asr/whisper_asr_model.cpp b/src/engine/models/whisper_asr/whisper_asr_model.cpp index 2c082bed..21ec1e57 100644 --- a/src/engine/models/whisper_asr/whisper_asr_model.cpp +++ b/src/engine/models/whisper_asr/whisper_asr_model.cpp @@ -11,6 +11,9 @@ #include "engine/text/utf8.h" namespace llm_edgeflow { + +static const ModelDefinition& WhisperAsrModelDefinition(); + namespace { inline std::string TrimAscii(std::string_view text) { @@ -35,12 +38,15 @@ std::shared_ptr WhisperAsrModel::Create( "whisper_asr requires an IAudioTranscriptionSession with batch " "policy {1, 0}"); } - const std::string language = - context.model_config.value("language", std::string("zh")); - const int max_audio_seconds = - context.model_config.value("max_audio_seconds", 30); - const int max_output_bytes = - context.model_config.value("max_output_bytes", 65536); + const std::string language = ConfigValueOrDefault( + context.model_config, WhisperAsrModelDefinition().config_fields, + "language"); + const int max_audio_seconds = ConfigValueOrDefault( + context.model_config, WhisperAsrModelDefinition().config_fields, + "max_audio_seconds"); + const int max_output_bytes = ConfigValueOrDefault( + context.model_config, WhisperAsrModelDefinition().config_fields, + "max_output_bytes"); if (language != "zh" && language != "en" && language != "auto") { throw std::runtime_error("Invalid language for whisper_asr: " + language); @@ -184,39 +190,42 @@ int WhisperAsrModel::Transcribe(const AudioPcmBatch& audio, TextBatch* outputs, } } -static const ModelDefinition kWhisperAsrModelDefinition = [] { - auto definition = MakeModelDefinition(); - definition.description = - "Whisper automatic speech recognition model for float32 PCM"; - definition.required_protocol = ExecutionProtocol::kAudioTranscription; - definition.config_fields = { - ConfigFieldDefinition{"language", - ConfigValueKind::kString, - false, - "zh", - std::nullopt, - std::nullopt, - {"zh", "en", "auto"}, - "Target transcription language"}, - ConfigFieldDefinition{"max_audio_seconds", - ConfigValueKind::kInteger, - false, - 30, - 1.0, - 60.0, - {}, - "Maximum audio length in seconds"}, - ConfigFieldDefinition{"max_output_bytes", - ConfigValueKind::kInteger, - false, - 65536, - 1.0, - 65536.0, - {}, - "Maximum transcription output bytes"}}; +static const ModelDefinition& WhisperAsrModelDefinition() { + static const ModelDefinition definition = [] { + auto definition = MakeModelDefinition(); + definition.description = + "Whisper automatic speech recognition model for float32 PCM"; + definition.required_protocol = ExecutionProtocol::kAudioTranscription; + definition.config_fields = { + ConfigFieldDefinition{"language", + ConfigValueKind::kString, + false, + "zh", + std::nullopt, + std::nullopt, + {"zh", "en", "auto"}, + "Target transcription language"}, + ConfigFieldDefinition{"max_audio_seconds", + ConfigValueKind::kInteger, + false, + 30, + 1.0, + 60.0, + {}, + "Maximum audio length in seconds"}, + ConfigFieldDefinition{"max_output_bytes", + ConfigValueKind::kInteger, + false, + 65536, + 1.0, + 65536.0, + {}, + "Maximum transcription output bytes"}}; + return definition; + }(); return definition; -}(); +} -REGISTER_MODEL_WITH_DEFINITION(WhisperAsrModel, kWhisperAsrModelDefinition); +REGISTER_MODEL_WITH_DEFINITION(WhisperAsrModel, WhisperAsrModelDefinition()); } // namespace llm_edgeflow diff --git a/tests/unit/core/test_definition_schema_validation.cpp b/tests/unit/core/test_definition_schema_validation.cpp index a1bf821b..24fe0fe6 100644 --- a/tests/unit/core/test_definition_schema_validation.cpp +++ b/tests/unit/core/test_definition_schema_validation.cpp @@ -1137,3 +1137,29 @@ TEST_F(DefinitionSchemaValidationTest, NodeAndBizRejectEmptyFlowMetadata) { } } // namespace llm_edgeflow + +namespace llm_edgeflow { + +TEST_F(DefinitionSchemaValidationTest, + ConfigValueOrDefaultReadsDeclaredDefault) { + const std::vector fields = { + {"threads", ConfigValueKind::kInteger, false, 2, 1.0, 64.0, {}, ""}, + {"mode", + ConfigValueKind::kString, + false, + "all", + std::nullopt, + std::nullopt, + {}, + ""}}; + const nlohmann::json config = {{"threads", 8}}; + EXPECT_EQ(ConfigValueOrDefault(config, fields, "threads"), 8); + EXPECT_EQ(ConfigValueOrDefault(config, fields, "mode"), "all"); + EXPECT_EQ(ConfigValueOrDefault(nlohmann::json(), fields, "threads"), 2); + EXPECT_THROW(ConfigValueOrDefault(config, fields, "undeclared"), + std::invalid_argument); + EXPECT_THROW(ConfigValueOrDefault(config, fields, "threads"), + nlohmann::json::exception); +} + +} // namespace llm_edgeflow From 1f76aaeea21b023d6bf1e21d8cfc3baf22fcaa67 Mon Sep 17 00:00:00 2001 From: chamsechan Date: Sun, 4 Oct 2026 21:37:47 +0800 Subject: [PATCH 25/29] docs(plans): record why Backend switches stay inside the sources Vendor headers are already confined to guarded blocks, neutral helpers are separate and tested, and moving the split into CMake would require extra gating plus an on/off build matrix for four switches. Co-Authored-By: Claude Opus 5.5 --- plans/ARCHITECTURE_REVIEW.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plans/ARCHITECTURE_REVIEW.md b/plans/ARCHITECTURE_REVIEW.md index 8be8fd4e..cf46e068 100644 --- a/plans/ARCHITECTURE_REVIEW.md +++ b/plans/ARCHITECTURE_REVIEW.md @@ -331,7 +331,7 @@ Definition、Catalog、Validator、节点运行时及工具的解释须一致。 | D1 | Model 身份声明去重 | 第三节·模型执行层 | 已完成:6 个生产 Model 继承 `ModelIdentity`,身份只声明 `kModelType`/`kConcurrency`,能力由接口推导,Definition 用 `MakeModelDefinition`;工厂的实例核验保留,Catalog 逐字节不变。Backend 类型名随 D2 处理 | | D2 | Backend Provider 函数化注册 | 第三节·模型执行层 | 部分完成:4 个生产 Backend 继承 `BackendIdentity`,类型名只在 `kBackendType` 声明,Definition 用 `MakeBackendDefinition`。函数化注册不采纳:Provider 只是一个小类,异常屏障、诊断、Session 所有权和协议检查都在 `Load` 与工厂中,改为函数不会减少这些工作,却要改动 `BackendRegistry` 接口和所有测试替身 | | D3 | Model/Backend 配置声明与读取统一 | 第三节·模型执行层 | 已完成:`contracts` 新增 `ConfigValueOrDefault`,6 个 Model 与 2 个 Backend 的 33 处读取改为取 Definition 中声明的默认值(此前手写默认值均与声明一致),Catalog 逐字节不变。未把 Node 的 `Parameters` 移入 contracts:Model 配置只在创建时读取一次,读取器已消除重复 | -| D4 | Backend 条件编译由 CMake 收敛 | 第三节·模型执行层 | 待评估 | +| D4 | Backend 条件编译由 CMake 收敛 | 第三节·模型执行层 | 不采纳:厂商头文件已只出现在各 Backend 源文件的受保护分支内,始终编译的中性工具已在 `*_detail` 中独立并有测试;每个文件只有 7–13 行预处理指令。改由 CMake 收录会让关闭的 Backend 缺少 Provider 定义,测试与注册还需额外按开关处理,并须验证 4 个开关各自开启和关闭的构建,成本高于可读性收益。新增可选 Backend 或受保护代码明显增多时再处理 | | D5 | 注册表公共操作抽取 | 第三节·横向 | 待评估 | | D6 | 运行时契约与编排实现目录分离 | 第三节·横向 | 待评估 | From 05e9f8ebdacf814315eb033a698042803c070fa6 Mon Sep 17 00:00:00 2001 From: chamsechan Date: Sun, 4 Oct 2026 21:47:47 +0800 Subject: [PATCH 26/29] refactor(registry): share fail-closed conflict state across registries The IoConverter, IoBinding, Model and Backend registries each kept their own conflict flag and message list. They now hold a contracts-level RegistryConflicts guarded by their existing mutex. The Adapter registries previously derived HasConflict from a non-empty message list, so a message lost to an allocation failure reopened the registry; the shared state keeps the conflict and reports a fallback message, covered by an allocation-failure test. NodeRegistry keeps its atomic flag for lock-free reads during registration callbacks. Co-Authored-By: Claude Opus 5.5 --- include/adapter/io_binding_registry.h | 3 +- include/adapter/io_converter_registry.h | 3 +- include/contracts/registry_conflicts.h | 41 ++++++++++ include/engine/backend_registry.h | 4 +- include/engine/model_registry.h | 4 +- plans/ARCHITECTURE_REVIEW.md | 2 +- src/adapter/io_binding_registry.cpp | 27 ++++--- src/adapter/io_converter_registry.cpp | 78 +++++++++---------- src/engine/runtime/backend_registry.cpp | 13 ++-- src/engine/runtime/model_registry.cpp | 13 ++-- src/engine/runtime/registry_support.h | 19 ++--- .../unit/adapter/test_io_binding_registry.cpp | 28 +++++++ 12 files changed, 148 insertions(+), 87 deletions(-) create mode 100644 include/contracts/registry_conflicts.h diff --git a/include/adapter/io_binding_registry.h b/include/adapter/io_binding_registry.h index 86f1f85b..0a5e556c 100644 --- a/include/adapter/io_binding_registry.h +++ b/include/adapter/io_binding_registry.h @@ -7,6 +7,7 @@ #include "adapter/io_binding.h" #include "adapter/io_converter.h" +#include "contracts/registry_conflicts.h" namespace llm_edgeflow { @@ -60,7 +61,7 @@ class IoBindingRegistry { mutable std::mutex mutex_; std::unordered_map bindings_; - std::vector conflict_errors_; + RegistryConflicts conflicts_; }; } // namespace llm_edgeflow diff --git a/include/adapter/io_converter_registry.h b/include/adapter/io_converter_registry.h index c5fe19c2..6790b56f 100644 --- a/include/adapter/io_converter_registry.h +++ b/include/adapter/io_converter_registry.h @@ -6,6 +6,7 @@ #include #include "adapter/io_converter.h" +#include "contracts/registry_conflicts.h" namespace llm_edgeflow { @@ -37,7 +38,7 @@ class IoConverterRegistry { mutable std::mutex mutex_; std::unordered_map input_converters_; std::unordered_map output_converters_; - std::vector conflict_errors_; + RegistryConflicts conflicts_; }; } // namespace llm_edgeflow diff --git a/include/contracts/registry_conflicts.h b/include/contracts/registry_conflicts.h new file mode 100644 index 00000000..c019a77a --- /dev/null +++ b/include/contracts/registry_conflicts.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include + +namespace llm_edgeflow { + +// Fail-closed conflict state of one registry. The registry guards it with its +// own mutex; a conflict stays recorded even if its message cannot be stored. +class RegistryConflicts { + public: + void Record(std::string message) noexcept { + has_conflict_ = true; + try { + messages_.push_back(std::move(message)); + } catch (...) { + } + } + + bool HasConflict() const noexcept { return has_conflict_; } + + // Recorded messages; never empty while a conflict is recorded. + std::vector Messages() const { + if (has_conflict_ && messages_.empty()) { + return {"Registry conflict recorded without a stored message"}; + } + return messages_; + } + + void Clear() noexcept { + has_conflict_ = false; + messages_.clear(); + } + + private: + bool has_conflict_ = false; + std::vector messages_; +}; + +} // namespace llm_edgeflow diff --git a/include/engine/backend_registry.h b/include/engine/backend_registry.h index 785727af..6aa21e53 100644 --- a/include/engine/backend_registry.h +++ b/include/engine/backend_registry.h @@ -9,6 +9,7 @@ #include #include +#include "contracts/registry_conflicts.h" #include "edgeflow/log.h" #include "engine/backend_interface.h" #include "engine/inference_definition.h" @@ -54,8 +55,7 @@ class BackendRegistry { mutable std::mutex mutex_; std::unordered_map entries_; - bool has_conflict_ = false; - std::vector conflict_errors_; + RegistryConflicts conflicts_; }; #define REGISTER_BACKEND_WITH_DEFINITION(BackendClass, ...) \ diff --git a/include/engine/model_registry.h b/include/engine/model_registry.h index 749ec000..5da79836 100644 --- a/include/engine/model_registry.h +++ b/include/engine/model_registry.h @@ -9,6 +9,7 @@ #include #include +#include "contracts/registry_conflicts.h" #include "edgeflow/log.h" #include "engine/backend_interface.h" #include "engine/inference_definition.h" @@ -65,8 +66,7 @@ class ModelRegistry { mutable std::mutex mutex_; std::unordered_map entries_; - bool has_conflict_ = false; - std::vector conflict_errors_; + RegistryConflicts conflicts_; }; #define REGISTER_MODEL_WITH_DEFINITION(ModelClass, ...) \ diff --git a/plans/ARCHITECTURE_REVIEW.md b/plans/ARCHITECTURE_REVIEW.md index cf46e068..1cffa077 100644 --- a/plans/ARCHITECTURE_REVIEW.md +++ b/plans/ARCHITECTURE_REVIEW.md @@ -332,7 +332,7 @@ Definition、Catalog、Validator、节点运行时及工具的解释须一致。 | D2 | Backend Provider 函数化注册 | 第三节·模型执行层 | 部分完成:4 个生产 Backend 继承 `BackendIdentity`,类型名只在 `kBackendType` 声明,Definition 用 `MakeBackendDefinition`。函数化注册不采纳:Provider 只是一个小类,异常屏障、诊断、Session 所有权和协议检查都在 `Load` 与工厂中,改为函数不会减少这些工作,却要改动 `BackendRegistry` 接口和所有测试替身 | | D3 | Model/Backend 配置声明与读取统一 | 第三节·模型执行层 | 已完成:`contracts` 新增 `ConfigValueOrDefault`,6 个 Model 与 2 个 Backend 的 33 处读取改为取 Definition 中声明的默认值(此前手写默认值均与声明一致),Catalog 逐字节不变。未把 Node 的 `Parameters` 移入 contracts:Model 配置只在创建时读取一次,读取器已消除重复 | | D4 | Backend 条件编译由 CMake 收敛 | 第三节·模型执行层 | 不采纳:厂商头文件已只出现在各 Backend 源文件的受保护分支内,始终编译的中性工具已在 `*_detail` 中独立并有测试;每个文件只有 7–13 行预处理指令。改由 CMake 收录会让关闭的 Backend 缺少 Provider 定义,测试与注册还需额外按开关处理,并须验证 4 个开关各自开启和关闭的构建,成本高于可读性收益。新增可选 Backend 或受保护代码明显增多时再处理 | -| D5 | 注册表公共操作抽取 | 第三节·横向 | 待评估 | +| D5 | 注册表公共操作抽取 | 第三节·横向 | 已完成:`contracts` 新增 `RegistryConflicts`,IoConverter、IoBinding、Model、Backend 注册表共用冲突记录、查询与清理,并修复 Adapter 注册表在消息存储失败时丢失冲突的失败开放问题;各表仍用自身锁。NodeRegistry 保留原子标志以便注册回调中无锁读取,ValueType 注册表只有标志、保持不变;不统一完整容器 | | D6 | 运行时契约与编排实现目录分离 | 第三节·横向 | 待评估 | 保留项(自定义 allocator、参数快照、契约元数据检查、Model 来源接口)不列入实施。 diff --git a/src/adapter/io_binding_registry.cpp b/src/adapter/io_binding_registry.cpp index ebbc8a03..996fe628 100644 --- a/src/adapter/io_binding_registry.cpp +++ b/src/adapter/io_binding_registry.cpp @@ -124,30 +124,29 @@ IoBindingRegistry& IoBindingRegistry::Instance() { bool IoBindingRegistry::RegisterBinding(const IoBindingDefinition& def) { std::lock_guard lock(mutex_); if (def.binding_id.empty()) { - conflict_errors_.push_back("Empty binding_id in IoBindingDefinition"); + conflicts_.Record("Empty binding_id in IoBindingDefinition"); return false; } if (def.biz_name.empty()) { - conflict_errors_.push_back("Empty biz_name in IoBindingDefinition: " + - def.binding_id); + conflicts_.Record("Empty biz_name in IoBindingDefinition: " + + def.binding_id); return false; } if (def.input_converter_id.empty()) { - conflict_errors_.push_back( - "Empty input_converter_id in IoBindingDefinition: " + def.binding_id); + conflicts_.Record("Empty input_converter_id in IoBindingDefinition: " + + def.binding_id); return false; } if (def.output_converter_id.empty()) { - conflict_errors_.push_back( - "Empty output_converter_id in IoBindingDefinition: " + def.binding_id); + conflicts_.Record("Empty output_converter_id in IoBindingDefinition: " + + def.binding_id); return false; } auto it = bindings_.find(def.binding_id); if (it != bindings_.end()) { - conflict_errors_.push_back("Duplicate IoBinding registration: " + - def.binding_id); + conflicts_.Record("Duplicate IoBinding registration: " + def.binding_id); return false; } @@ -182,17 +181,17 @@ bool IoBindingRegistry::ValidateBizContract(const std::string& biz_name, bool IoBindingRegistry::HasConflict() const { std::lock_guard lock(mutex_); - return !conflict_errors_.empty(); + return conflicts_.HasConflict(); } std::vector IoBindingRegistry::GetConflictErrors() const { std::lock_guard lock(mutex_); - return conflict_errors_; + return conflicts_.Messages(); } bool IoBindingRegistry::Audit(std::vector* out_errors) const { std::lock_guard lock(mutex_); - std::vector errors = conflict_errors_; + std::vector errors = conflicts_.Messages(); const auto& conv_reg = IoConverterRegistry::Instance(); if (conv_reg.HasConflict()) { @@ -421,12 +420,12 @@ bool IoBindingRegistry::Audit(std::vector* out_errors) const { void IoBindingRegistry::ClearForTesting() { std::lock_guard lock(mutex_); bindings_.clear(); - conflict_errors_.clear(); + conflicts_.Clear(); } void IoBindingRegistry::ResetConflictForTesting() { std::lock_guard lock(mutex_); - conflict_errors_.clear(); + conflicts_.Clear(); } } // namespace llm_edgeflow diff --git a/src/adapter/io_converter_registry.cpp b/src/adapter/io_converter_registry.cpp index c3a1a077..e8a059f6 100644 --- a/src/adapter/io_converter_registry.cpp +++ b/src/adapter/io_converter_registry.cpp @@ -26,58 +26,54 @@ bool IoConverterRegistry::RegisterInputConverter( const InputConverterDefinition& def) { std::lock_guard lock(mutex_); if (def.converter_id.empty()) { - conflict_errors_.push_back( - "Empty converter_id in InputConverterDefinition"); + conflicts_.Record("Empty converter_id in InputConverterDefinition"); return false; } if (!def.decode_fn) { - conflict_errors_.push_back( - "Missing decode_fn in InputConverterDefinition for: " + - def.converter_id); + conflicts_.Record("Missing decode_fn in InputConverterDefinition for: " + + def.converter_id); return false; } if (def.schema_id.empty()) { - conflict_errors_.push_back( - "Empty schema_id in InputConverterDefinition for: " + def.converter_id); + conflicts_.Record("Empty schema_id in InputConverterDefinition for: " + + def.converter_id); return false; } if (def.schema_version < 1) { - conflict_errors_.push_back( - "Invalid schema_version (" + std::to_string(def.schema_version) + - ") in InputConverterDefinition for: " + def.converter_id); + conflicts_.Record("Invalid schema_version (" + + std::to_string(def.schema_version) + + ") in InputConverterDefinition for: " + def.converter_id); return false; } if (def.external_slots.empty()) { - conflict_errors_.push_back( - "Empty external_slots in InputConverterDefinition for: " + - def.converter_id); + conflicts_.Record("Empty external_slots in InputConverterDefinition for: " + + def.converter_id); return false; } for (const auto& slot : def.external_slots) { if (slot.slot_name.empty() || slot.type_id.empty()) { - conflict_errors_.push_back( + conflicts_.Record( "Invalid external_slot (empty slot_name or type_id) in " "InputConverterDefinition for: " + def.converter_id); return false; } if (slot.type_suffix.empty()) { - conflict_errors_.push_back( + conflicts_.Record( "Empty type_suffix for operator slot '" + slot.slot_name + "' in InputConverterDefinition for: " + def.converter_id); return false; } } if (def.logical_ports.empty()) { - conflict_errors_.push_back( - "Empty logical_ports in InputConverterDefinition for: " + - def.converter_id); + conflicts_.Record("Empty logical_ports in InputConverterDefinition for: " + + def.converter_id); return false; } for (const auto& port : def.logical_ports) { if (port.logical_name.empty() || port.type_id.empty()) { - conflict_errors_.push_back( + conflicts_.Record( "Invalid logical_port (empty logical_name or type_id) in " "InputConverterDefinition for: " + def.converter_id); @@ -86,8 +82,8 @@ bool IoConverterRegistry::RegisterInputConverter( } auto it = input_converters_.find(def.converter_id); if (it != input_converters_.end()) { - conflict_errors_.push_back("Duplicate InputConverter registration: " + - def.converter_id); + conflicts_.Record("Duplicate InputConverter registration: " + + def.converter_id); return false; } @@ -103,59 +99,55 @@ bool IoConverterRegistry::RegisterOutputConverter( const OutputConverterDefinition& def) { std::lock_guard lock(mutex_); if (def.converter_id.empty()) { - conflict_errors_.push_back( - "Empty converter_id in OutputConverterDefinition"); + conflicts_.Record("Empty converter_id in OutputConverterDefinition"); return false; } if (!def.encode_fn) { - conflict_errors_.push_back( - "Missing encode_fn in OutputConverterDefinition for: " + - def.converter_id); + conflicts_.Record("Missing encode_fn in OutputConverterDefinition for: " + + def.converter_id); return false; } if (def.schema_id.empty()) { - conflict_errors_.push_back( - "Empty schema_id in OutputConverterDefinition for: " + - def.converter_id); + conflicts_.Record("Empty schema_id in OutputConverterDefinition for: " + + def.converter_id); return false; } if (def.schema_version < 1) { - conflict_errors_.push_back( + conflicts_.Record( "Invalid schema_version (" + std::to_string(def.schema_version) + ") in OutputConverterDefinition for: " + def.converter_id); return false; } if (def.external_slots.empty()) { - conflict_errors_.push_back( + conflicts_.Record( "Empty external_slots in OutputConverterDefinition for: " + def.converter_id); return false; } for (const auto& slot : def.external_slots) { if (slot.slot_name.empty() || slot.type_id.empty()) { - conflict_errors_.push_back( + conflicts_.Record( "Invalid external_slot (empty slot_name or type_id) in " "OutputConverterDefinition for: " + def.converter_id); return false; } if (slot.type_suffix.empty()) { - conflict_errors_.push_back( + conflicts_.Record( "Empty type_suffix for operator slot '" + slot.slot_name + "' in OutputConverterDefinition for: " + def.converter_id); return false; } } if (def.logical_ports.empty()) { - conflict_errors_.push_back( - "Empty logical_ports in OutputConverterDefinition for: " + - def.converter_id); + conflicts_.Record("Empty logical_ports in OutputConverterDefinition for: " + + def.converter_id); return false; } for (const auto& port : def.logical_ports) { if (port.logical_name.empty() || port.type_id.empty()) { - conflict_errors_.push_back( + conflicts_.Record( "Invalid logical_port (empty logical_name or type_id) in " "OutputConverterDefinition for: " + def.converter_id); @@ -164,8 +156,8 @@ bool IoConverterRegistry::RegisterOutputConverter( } auto it = output_converters_.find(def.converter_id); if (it != output_converters_.end()) { - conflict_errors_.push_back("Duplicate OutputConverter registration: " + - def.converter_id); + conflicts_.Record("Duplicate OutputConverter registration: " + + def.converter_id); return false; } @@ -221,24 +213,24 @@ IoConverterRegistry::AllOutputConverters() const { bool IoConverterRegistry::HasConflict() const { std::lock_guard lock(mutex_); - return !conflict_errors_.empty(); + return conflicts_.HasConflict(); } std::vector IoConverterRegistry::GetConflictErrors() const { std::lock_guard lock(mutex_); - return conflict_errors_; + return conflicts_.Messages(); } void IoConverterRegistry::ClearForTesting() { std::lock_guard lock(mutex_); input_converters_.clear(); output_converters_.clear(); - conflict_errors_.clear(); + conflicts_.Clear(); } void IoConverterRegistry::ResetConflictForTesting() { std::lock_guard lock(mutex_); - conflict_errors_.clear(); + conflicts_.Clear(); } } // namespace llm_edgeflow diff --git a/src/engine/runtime/backend_registry.cpp b/src/engine/runtime/backend_registry.cpp index a50b73c0..64b73d5b 100644 --- a/src/engine/runtime/backend_registry.cpp +++ b/src/engine/runtime/backend_registry.cpp @@ -17,8 +17,7 @@ BackendRegistry& BackendRegistry::Instance() { } void BackendRegistry::RecordConflict(std::string_view error) noexcept { - registry_support::RecordConflict(mutex_, has_conflict_, conflict_errors_, - std::move(error)); + registry_support::RecordConflict(mutex_, conflicts_, std::move(error)); } bool BackendRegistry::Register(const BackendDefinition& definition, @@ -73,11 +72,11 @@ bool BackendRegistry::Register(const BackendDefinition& definition, const bool inserted = entries_.emplace(definition.backend_type, std::move(staged)).second; if (!inserted) { - has_conflict_ = true; try { - conflict_errors_.push_back("Duplicate backend registration for type: " + - definition.backend_type); + conflicts_.Record("Duplicate backend registration for type: " + + definition.backend_type); } catch (...) { + conflicts_.Record(std::string()); } ALG_LOG_ERROR("[BackendRegistry] Duplicate backend registration: %s\n", definition.backend_type.c_str()); @@ -160,11 +159,11 @@ std::vector BackendRegistry::ListDefinitions() const { } bool BackendRegistry::HasConflict() const noexcept { - return registry_support::HasConflict(mutex_, has_conflict_); + return registry_support::HasConflict(mutex_, conflicts_); } std::vector BackendRegistry::GetConflictErrors() const { - return registry_support::GetConflictErrors(mutex_, conflict_errors_); + return registry_support::GetConflictErrors(mutex_, conflicts_); } } // namespace llm_edgeflow diff --git a/src/engine/runtime/model_registry.cpp b/src/engine/runtime/model_registry.cpp index 9ef8995a..0a742ee4 100644 --- a/src/engine/runtime/model_registry.cpp +++ b/src/engine/runtime/model_registry.cpp @@ -16,8 +16,7 @@ ModelRegistry& ModelRegistry::Instance() { } void ModelRegistry::RecordConflict(std::string_view error) noexcept { - registry_support::RecordConflict(mutex_, has_conflict_, conflict_errors_, - std::move(error)); + registry_support::RecordConflict(mutex_, conflicts_, std::move(error)); } bool ModelRegistry::Register(const ModelDefinition& definition, @@ -61,11 +60,11 @@ bool ModelRegistry::Register(const ModelDefinition& definition, const bool inserted = entries_.emplace(definition.model_type, std::move(staged)).second; if (!inserted) { - has_conflict_ = true; try { - conflict_errors_.push_back("Duplicate model registration for type: " + - definition.model_type); + conflicts_.Record("Duplicate model registration for type: " + + definition.model_type); } catch (...) { + conflicts_.Record(std::string()); } ALG_LOG_ERROR("[ModelRegistry] Duplicate model registration: %s\n", definition.model_type.c_str()); @@ -148,11 +147,11 @@ std::vector ModelRegistry::ListDefinitions() const { } bool ModelRegistry::HasConflict() const noexcept { - return registry_support::HasConflict(mutex_, has_conflict_); + return registry_support::HasConflict(mutex_, conflicts_); } std::vector ModelRegistry::GetConflictErrors() const { - return registry_support::GetConflictErrors(mutex_, conflict_errors_); + return registry_support::GetConflictErrors(mutex_, conflicts_); } } // namespace llm_edgeflow diff --git a/src/engine/runtime/registry_support.h b/src/engine/runtime/registry_support.h index 04524b5a..52a9233f 100644 --- a/src/engine/runtime/registry_support.h +++ b/src/engine/runtime/registry_support.h @@ -9,20 +9,21 @@ #include #include "contracts/diagnostic.h" +#include "contracts/registry_conflicts.h" namespace llm_edgeflow::registry_support { template -void RecordConflict(Mutex& mutex, bool& has_conflict, - std::vector& errors, +void RecordConflict(Mutex& mutex, RegistryConflicts& conflicts, std::string_view error) noexcept { try { std::lock_guard lock(mutex); - has_conflict = true; + std::string message; try { - errors.emplace_back(error); + message.assign(error); } catch (...) { } + conflicts.Record(std::move(message)); } catch (...) { } } @@ -73,20 +74,20 @@ std::vector ListDefinitions(Mutex& mutex, const EntryMap& entries, } template -bool HasConflict(Mutex& mutex, const bool& has_conflict) noexcept { +bool HasConflict(Mutex& mutex, const RegistryConflicts& conflicts) noexcept { try { std::lock_guard lock(mutex); - return has_conflict; + return conflicts.HasConflict(); } catch (...) { return true; } } template -std::vector GetConflictErrors( - Mutex& mutex, const std::vector& errors) { +std::vector GetConflictErrors(Mutex& mutex, + const RegistryConflicts& conflicts) { std::lock_guard lock(mutex); - return errors; + return conflicts.Messages(); } } // namespace llm_edgeflow::registry_support diff --git a/tests/unit/adapter/test_io_binding_registry.cpp b/tests/unit/adapter/test_io_binding_registry.cpp index 8411fb41..507a24de 100644 --- a/tests/unit/adapter/test_io_binding_registry.cpp +++ b/tests/unit/adapter/test_io_binding_registry.cpp @@ -15,9 +15,11 @@ #include "adapter/io_converter_registry.h" #include "adapter/pipeline_document.h" #include "adapter/shared_algorithm_runtime.h" +#include "contracts/registry_conflicts.h" #include "core/pipeline_catalog.h" #include "core/pipeline_config.h" #include "edgeflow/operator/interface.h" +#include "tests/support/scoped_allocation_failure.h" namespace llm_edgeflow { namespace { @@ -142,6 +144,32 @@ class IoBindingRegistryTest : public ::testing::Test { static inline std::vector saved_outputs_; }; +TEST_F(IoBindingRegistryTest, RegistryConflictsStayRecordedWithoutMessage) { + RegistryConflicts conflicts; + EXPECT_FALSE(conflicts.HasConflict()); + EXPECT_TRUE(conflicts.Messages().empty()); + conflicts.Record("duplicate"); + EXPECT_TRUE(conflicts.HasConflict()); + EXPECT_EQ(conflicts.Messages(), std::vector{"duplicate"}); + conflicts.Clear(); + EXPECT_FALSE(conflicts.HasConflict()); + + // Losing the message to an allocation failure must not reopen the registry. + RegistryConflicts lossy; + std::string message = "lost"; + bool allocation_failed = false; + { + test_support::ScopedAllocationFailure failure(0); + lossy.Record(std::move(message)); + allocation_failed = failure.Triggered(); + } + EXPECT_TRUE(allocation_failed); + EXPECT_TRUE(lossy.HasConflict()); + ASSERT_EQ(lossy.Messages().size(), 1u); + EXPECT_NE(lossy.Messages()[0].find("without a stored message"), + std::string::npos); +} + TEST_F(IoBindingRegistryTest, RegisterAndAuditValidBinding) { auto& reg = IoBindingRegistry::Instance(); From e5c721f6d25fa9f27a96c1c6416485bb345a4858 Mon Sep 17 00:00:00 2001 From: chamsechan Date: Sun, 4 Oct 2026 21:48:16 +0800 Subject: [PATCH 27/29] docs(plans): record why core contracts stay in place Fourteen of eighteen include/core headers are already node-visible contracts and the manifest-driven header views, probes and guards enforce the boundary; moving files would rewrite many includes and the layer documentation without changing a dependency. Co-Authored-By: Claude Opus 5.5 --- plans/ARCHITECTURE_REVIEW.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plans/ARCHITECTURE_REVIEW.md b/plans/ARCHITECTURE_REVIEW.md index 1cffa077..2cedf020 100644 --- a/plans/ARCHITECTURE_REVIEW.md +++ b/plans/ARCHITECTURE_REVIEW.md @@ -333,7 +333,7 @@ Definition、Catalog、Validator、节点运行时及工具的解释须一致。 | D3 | Model/Backend 配置声明与读取统一 | 第三节·模型执行层 | 已完成:`contracts` 新增 `ConfigValueOrDefault`,6 个 Model 与 2 个 Backend 的 33 处读取改为取 Definition 中声明的默认值(此前手写默认值均与声明一致),Catalog 逐字节不变。未把 Node 的 `Parameters` 移入 contracts:Model 配置只在创建时读取一次,读取器已消除重复 | | D4 | Backend 条件编译由 CMake 收敛 | 第三节·模型执行层 | 不采纳:厂商头文件已只出现在各 Backend 源文件的受保护分支内,始终编译的中性工具已在 `*_detail` 中独立并有测试;每个文件只有 7–13 行预处理指令。改由 CMake 收录会让关闭的 Backend 缺少 Provider 定义,测试与注册还需额外按开关处理,并须验证 4 个开关各自开启和关闭的构建,成本高于可读性收益。新增可选 Backend 或受保护代码明显增多时再处理 | | D5 | 注册表公共操作抽取 | 第三节·横向 | 已完成:`contracts` 新增 `RegistryConflicts`,IoConverter、IoBinding、Model、Backend 注册表共用冲突记录、查询与清理,并修复 Adapter 注册表在消息存储失败时丢失冲突的失败开放问题;各表仍用自身锁。NodeRegistry 保留原子标志以便注册回调中无锁读取,ValueType 注册表只有标志、保持不变;不统一完整容器 | -| D6 | 运行时契约与编排实现目录分离 | 第三节·横向 | 待评估 | +| D6 | 运行时契约与编排实现目录分离 | 第三节·横向 | 不采纳:`include/core` 的 18 个头文件中 14 个已是节点可见契约,编排实现只有 `pipeline.h`、`pipeline_validator.h`、`remediation_cause.h`、`thread_pool.h`;`node_core_contracts.txt` 已精确定义视图边界,并由头文件视图、编译探针和守卫脚本执行。物理迁移要改写约 250 处 include 及架构文档的层与目录对照,不改变任何依赖;审查也将目录迁移列为非发布前置条件 | 保留项(自定义 allocator、参数快照、契约元数据检查、Model 来源接口)不列入实施。 From 10e1028ae32e3e33643dc263165041f660f5bfbd Mon Sep 17 00:00:00 2001 From: chamsechan Date: Sun, 4 Oct 2026 21:49:50 +0800 Subject: [PATCH 28/29] docs: summarize extension API changes and close the review plan status List the source-extension changes made while adopting the architecture review and mark every progress item as completed or declined with its reason; the plan is deleted when its last stage merges. Co-Authored-By: Claude Opus 5.5 --- doc/CHANGELOG.md | 12 ++++++++++++ plans/ARCHITECTURE_REVIEW.md | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/doc/CHANGELOG.md b/doc/CHANGELOG.md index ff08903c..e706d32f 100644 --- a/doc/CHANGELOG.md +++ b/doc/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +源码扩展接口整理(随架构审查落地,不涉及 Operator ABI): +- Model 继承 `ModelIdentity`、Backend Provider 继承 `BackendIdentity`, + 身份只声明一次,Definition 从 `MakeModelDefinition` / `MakeBackendDefinition` 开始; + 配置读取使用 `ConfigValueOrDefault`,默认值只写在 `config_fields`。 +- 转换器 `external_type` 留空时由外部槽类型推导;`InputPortBindings` / `OutputPortBindings` + 共用 `PortBindings<方向>`;`ResolvedInputLimits` 更名为 `InputLimits` 且不再出现在部署配置中。 +- 删除 `include/adapter/biz_results.h`、`ModelManager::RegisterModel`(改用 `RegisterBatch`)和 + `RuntimeOptions` 中只写不读的 `biz_type`、`depth_num`、`biz_name`;`NodeBase` 的类写法端口辅助 + 函数移到 `dev_support` 的 `LegacyNodeBase`。 +- Map Node 改在 Batch 运行时上执行,作者写法与 Definition 不变;Batch 端口绑定诊断补充期望与实际 + 类型。注册表冲突状态在消息无法保存时仍保持失败封闭。 + 修复建议移出 Core:remediation 的原因、事实、中文摘要与经 Validator 复核的 JSON Patch 修复改由 `alg_pipeline_tool` 生成,不再编入 SDK,也不在 Create 校验失败时执行。`PipelineValidator::Explain` 删除;Validator 只返回中性诊断,未注册类型的相近名称仍在 `suggestions` 中给出。CLI 与 Studio 的 diff --git a/plans/ARCHITECTURE_REVIEW.md b/plans/ARCHITECTURE_REVIEW.md index 2cedf020..2cf94054 100644 --- a/plans/ARCHITECTURE_REVIEW.md +++ b/plans/ARCHITECTURE_REVIEW.md @@ -2,7 +2,7 @@ | 项 | 内容 | | --- | --- | -| 状态 | 第六节事项已决定;改进项按[实施进度](#七实施进度)逐项落地 | +| 状态 | 第六节事项已决定;[实施进度](#七实施进度)中的各项均已完成或记录不采纳理由,形成的规则已写入现行指南;按 [plans/ 的规则](README.md),最后一个阶段合入主线时删除本文件 | | 基线 | `main@a664e37`(2026-10-03) | | 范围 | 审查四层源码、构建与分层守卫、测试和工具;本次仅修订审查文档,未修改代码 | | 约束 | 保持 Operator 函数表、宿主结构体、槽位名、错误码枚举集合、`.conf` 和 Pipeline JSON 格式;错误码返回语义的调整须单独决策,不能视为内部重构 | From 4430642696689ff880f7028245db8ec66c453c83 Mon Sep 17 00:00:00 2001 From: chamsechan Date: Sun, 4 Oct 2026 22:32:26 +0800 Subject: [PATCH 29/29] fix(architecture): preserve move-only callbacks and correct flow diagnostics --- doc/CHANGELOG.md | 8 +- doc/dev_guide/custom_node_concepts.md | 5 + include/contracts/config_schema.h | 15 +- include/core/pipeline_validator.h | 3 + include/nodes/function_node.h | 18 +- plans/ARCHITECTURE_REVIEW.md | 430 ------------------ src/cli/pipeline_remediation.cpp | 120 ++--- src/core/pipeline_validator.cpp | 169 ++++--- .../test_pipeline_catalog_validator.cpp | 61 +++ .../test_definition_schema_validation.cpp | 30 ++ .../core/test_validated_pipeline_plan.cpp | 166 +++++++ tests/unit/nodes/test_function_node.cpp | 115 +++++ 12 files changed, 556 insertions(+), 584 deletions(-) delete mode 100644 plans/ARCHITECTURE_REVIEW.md diff --git a/doc/CHANGELOG.md b/doc/CHANGELOG.md index e706d32f..7cb8d6e4 100644 --- a/doc/CHANGELOG.md +++ b/doc/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +架构审查回归修复:Map 的回调通过移动交给运行时,支持捕获 `unique_ptr` 等不可复制状态; +流契约错误由 Validator 提供生产者、消费者、有效端口契约与推导出的数量形状,CLI 据此解释 +节点输入、业务出口及 IO 边界错误,保留拆分来源并正确区分逐项配对与出口数量要求。 +`ConfigValueOrDefault` 始终核对字段声明,拒绝数组和标量配置,`null` 仍按省略配置读取声明默认值。 + 源码扩展接口整理(随架构审查落地,不涉及 Operator ABI): - Model 继承 `ModelIdentity`、Backend Provider 继承 `BackendIdentity`, 身份只声明一次,Definition 从 `MakeModelDefinition` / `MakeBackendDefinition` 开始; @@ -17,7 +22,8 @@ 修复建议移出 Core:remediation 的原因、事实、中文摘要与经 Validator 复核的 JSON Patch 修复改由 `alg_pipeline_tool` 生成,不再编入 SDK,也不在 Create 校验失败时执行。`PipelineValidator::Explain` 删除;Validator 只返回中性诊断,未注册类型的相近名称仍在 `suggestions` 中给出。CLI 与 Studio 的 -`validate` / `--explain` 输出不变。 +`validate` / `--explain` 继续提供修复建议;流契约诊断与建议的 `facts` 包含有效声明, +数量错误还附上 Validator 已推导的数量形状。 公开错误码:Operator 门面按失败阶段映射返回码,内部的 Pipeline、Node、Model 错误码不再直接返回给宿主。 Create 阶段的配置、部署与模型/Backend 加载失败返回 `-2`(此前部分为 `-3`);Process 中节点或模型 diff --git a/doc/dev_guide/custom_node_concepts.md b/doc/dev_guide/custom_node_concepts.md index 10ba7245..3f8f560e 100644 --- a/doc/dev_guide/custom_node_concepts.md +++ b/doc/dev_guide/custom_node_concepts.md @@ -214,6 +214,11 @@ Validator 沿 DAG 推导每个数据名在一个请求内有几项:每请求 `N:M` 输出不参与静态推导,由运行时的数量与来源检查兜底;不要为了绕过上述检查而声明 `N:M`。 来源规则 (provenance) 与存活期 (lifetime) 仍独立检查。 +流契约诊断的 `facts` 给出生产者、消费者、实际数据名及有效端口声明;数量错误还给出 +`actual_shape` / `expected_shape`,并在多项形状中保留拆分来源。`actual` / `expected` 的 +cardinality 是端口自身的声明,沿拆分传递的逐项输出仍可能声明 `1:1`,实际形状须看 +`actual_shape`。CLI 使用这些中性事实生成说明,不重复推导数量关系。 + ## 启用并行或共享资源时:并发声明 假设两个调用同时使用相同的节点逻辑。函数局部的提示词和结果各自独立;如果把上次 diff --git a/include/contracts/config_schema.h b/include/contracts/config_schema.h index 9d04454d..6c9bb954 100644 --- a/include/contracts/config_schema.h +++ b/include/contracts/config_schema.h @@ -74,17 +74,22 @@ inline const char* ConfigValueKindName(ConfigValueKind kind) noexcept { // Reads a declared configuration field: the configured value when present, // otherwise the default from its declaration, so each default is written once // in the Definition. Throws for undeclared fields or a configured value of the -// wrong type, like nlohmann::json::value. +// wrong type, like nlohmann::json::value. Null means omitted configuration; +// other non-object values are rejected. template T ConfigValueOrDefault(const nlohmann::json& config, const std::vector& fields, const std::string& name) { - if (config.is_object()) { - const auto it = config.find(name); - if (it != config.end()) return it->template get(); + if (!config.is_object() && !config.is_null()) { + throw std::invalid_argument("Configuration must be an object or null"); } for (const auto& field : fields) { - if (field.name == name) return field.default_value.template get(); + if (field.name != name) continue; + if (config.is_object()) { + const auto it = config.find(name); + if (it != config.end()) return it->template get(); + } + return field.default_value.template get(); } throw std::invalid_argument("Undeclared configuration field: " + name); } diff --git a/include/core/pipeline_validator.h b/include/core/pipeline_validator.h index 012e6ac2..76274a29 100644 --- a/include/core/pipeline_validator.h +++ b/include/core/pipeline_validator.h @@ -47,6 +47,9 @@ struct ValidationDiagnostic { std::vector related_nodes; std::vector suggestions; std::optional remediation; + // Neutral facts established by validation, including inferred item shapes. + // Tooling may explain these facts without repeating Validator rules. + nlohmann::json facts = nlohmann::json::object(); nlohmann::json ToJson() const; }; diff --git a/include/nodes/function_node.h b/include/nodes/function_node.h index 664fa9ff..d88d6356 100644 --- a/include/nodes/function_node.h +++ b/include/nodes/function_node.h @@ -296,7 +296,8 @@ class MapSpec { const std::string& InputName() const noexcept { return in_.name; } const std::string& OutputName() const noexcept { return out_.name; } const Parameters& ParametersSpec() const noexcept { return params_; } - const MapFnT& Function() const noexcept { return fn_; } + const MapFnT& Function() const& noexcept { return fn_; } + MapFnT&& Function() && noexcept { return std::move(fn_); } NodeDefinition BuildDefinition(std::string node_type) const { NodeDefinition def; @@ -1520,12 +1521,12 @@ struct MapInputs { // Runs a Map on the Batch runtime: one required anchor input, one preserved // output and a per-item loop that names the failing item. template -auto MakeMapRuntimeSpec(std::string node_name, const SpecT& map) { +auto MakeMapRuntimeSpec(std::string node_name, SpecT map) { using InputBatch = typename SpecT::InputBatch; using OutputBatch = typename SpecT::OutputBatch; using ParamsT = typename SpecT::ParametersType; using Inputs = MapInputs; - auto run = [fn = map.Function(), name = std::move(node_name)]( + auto run = [fn = std::move(map).Function(), name = std::move(node_name)]( const Inputs& inputs, const ParamsT& params) -> NodeResult { OutputBatch outputs; @@ -1575,15 +1576,16 @@ class AuthorNode> : public AuthorNode(), std::declval< - const MapSpec&>()))> { + MapSpec>()))> { public: using SpecType = MapSpec; using RuntimeSpec = decltype(detail::MakeMapRuntimeSpec( - std::declval(), std::declval())); + std::declval(), std::declval())); - AuthorNode(std::string node_name, const SpecType& spec) - : AuthorNode(node_name, - detail::MakeMapRuntimeSpec(node_name, spec)) {} + AuthorNode(std::string node_name, SpecType spec) + : AuthorNode( + node_name, detail::MakeMapRuntimeSpec(node_name, std::move(spec))) { + } }; // --------------------------------------------------------------------------- diff --git a/plans/ARCHITECTURE_REVIEW.md b/plans/ARCHITECTURE_REVIEW.md deleted file mode 100644 index 2cf94054..00000000 --- a/plans/ARCHITECTURE_REVIEW.md +++ /dev/null @@ -1,430 +0,0 @@ -# 架构审查:结论与优化建议 - -| 项 | 内容 | -| --- | --- | -| 状态 | 第六节事项已决定;[实施进度](#七实施进度)中的各项均已完成或记录不采纳理由,形成的规则已写入现行指南;按 [plans/ 的规则](README.md),最后一个阶段合入主线时删除本文件 | -| 基线 | `main@a664e37`(2026-10-03) | -| 范围 | 审查四层源码、构建与分层守卫、测试和工具;本次仅修订审查文档,未修改代码 | -| 约束 | 保持 Operator 函数表、宿主结构体、槽位名、错误码枚举集合、`.conf` 和 Pipeline JSON 格式;错误码返回语义的调整须单独决策,不能视为内部重构 | - -本文件按 [plans/ 的规则](README.md)存放:它是工作文档,不是现行规则。各项落地后,把形成的规则写进对应的现行指南; -全部完成或转为 RFC 后删除本文件。文中行号以基线为准。 - -**审查标准**(来自需求方): - -1. 对外契约不变:这是平台调用本框架生成的库的固定方式。 -2. 每一层新增内容都方便,例如新增统一接口的 Node、新增 Backend。 -3. 抽象层级之间相对隔离。 -4. 框架处理大部分调度,使用者具备初步 C++ 和少量框架知识即可开发业务。 -5. 框架本身容易理解:没有不必要的概念,没有可以合并或抽象却散落各处的代码。 - -**验证方式**:标"已复现"的两项用现有 `build/` 产物实际运行,步骤见[附录](#附录复现步骤)。 -复核还执行了 10 个相关测试,全部通过,筛选命令见[附录 C](#c-复核测试)。其余判断来自基线代码、现行指南和测试断言。 -这些证据不代表真实厂商运行时或目标硬件验收;`include/platform_mock/` 中的声明只是仓库约定, -真实 SDK 的契约只能在授权内网中核验。 - -## 结论 - -整体架构合理,不需要推倒重来。主线是:Operator 门面 → 校验后不可变的执行计划 → 带类型端口的节点 DAG → -模型能力接口 → 后端执行协议。每一段职责清楚,异常拦截、输出池回滚、厂商头文件隔离都做得扎实。 -应采纳核心问题判断,按采纳条件逐项实施;本文不是已批准的整体重构方案。 - -| 分级 | 项目 | 处理原则 | -| --- | --- | --- | -| 优先修复 | 端口数量关系误判;通用 Node 承担外部响应序列化 | 修复合法流程与职责边界,保留来源检查和现有响应格式 | -| 优先决策 | 错误码的公开语义;致命错误与单条业务无效结果的区别 | 明确行为与验收后再实施,不能按内部重构处理 | -| 后续重构 | Map/Batch 生命周期、端口绑定、配置声明与实例状态等重复 | 逐项证明收益,保持诊断、所有权和并发契约 | -| 保留或暂缓 | 转换器契约元数据、自定义 allocator、原子快照、Model 来源接口 | 不因当前只有测试使用或外层已有锁就删除 | - -新增 Node 已经很顺手;Model、Backend 和外部业务接入仍有简化空间,但扩展步骤多并不等于其中的契约检查冗余。 - -**建议保留的设计**: - -- 门面的 `noexcept` 与双重 catch、先解码再租用输出、租约自动回滚、输出 deleter 只持有弱引用。 -- 校验一次生成不可变计划,Pipeline 只消费不重算;模型批量原子注册;构建状态机。 -- "普通函数 + Spec"的节点写法和 `MakeLlmTextSpec`;签名错误有编译期提示;业务目录源码自动收录。 -- 参数快照的原子发布、失败回滚、旧快照存活和整批配置一致性。 -- Model 与 Backend 分离,经中性执行协议对接;厂商头文件只出现在 Backend 的源文件里。 -- Catalog 作为唯一事实来源,Studio 经 CLI 调用 Validator,不复制规则。 - -## 一、优先处理的问题与契约决策 - -### 1. 内部错误码原样返回给宿主(已复现) - -仓库的 mock 错误码定义了 0、-1…-7、-99、-100([error_codes.h](../include/platform_mock/error_codes.h)), -`Process` 还会把 Pipeline 的返回值原样交给宿主([operator_adapter.cpp:388](../src/adapter/operator/operator_adapter.cpp#L388))。 -这存在数值碰撞和故障分类不清的问题,但不能据此断言真实平台只有上述错误码。 - -- **实测**:一个 `StructuredJsonParseNode(failure_policy=fail)` 的方案,`ops.Process` 返回 **-6102**,不在枚举里(附录 A)。 -- **会撞上已有错误码**: - - Qwen 推理失败返回 -1([qwen_causal_lm_model.cpp:146-149](../src/engine/models/qwen_causal_lm/qwen_causal_lm_model.cpp#L146-L149)), - 经 [model_calls.h:52-59](../include/nodes/model_calls.h#L52-L59) 和 [function_node.h:1637](../include/nodes/function_node.h#L1637) - 原样上传;宿主若只按 mock 枚举解释,会误判为"无效句柄"。 - - 批处理执行器捕获异常返回 -4([fixed_batch_executor.h:122](../include/engine/fixed_batch_executor.h#L122)), - 与门面表示"输出容量不足"的 -4 相同。 - - Create 时配置校验失败、模型加载失败都返回 -3"输入非法" - ([io_binding_resolver.cpp:221](../src/adapter/io_binding_resolver.cpp#L221)、 - [shared_algorithm_runtime.cpp:131](../src/adapter/shared_algorithm_runtime.cpp#L131))。 -- **现行契约**:[宿主调用与生命周期](../doc/dev_guide/operator_output_allocation.md#宿主调用与生命周期)明确允许透传, - 并要求结合诊断判断故障层;[契约测试](../tests/contract/abi/test_adapter_contract_security.cpp#L262)还断言 - 模型错误 -731 原样返回。因此,映射属于公开返回行为的调整,不能称为"不涉及外部契约"的局部修复。 -- **采纳方向**:在接入适配层集中定义公开错误映射,按失败阶段或类别分类,原始码、节点及所在层级保留在 - `GetOperatorLastError()`。不能只按整数映射:模型执行的 -4 与输出容量的 -4 含义不同。 - 现有 mock 枚举没有单独的普通执行失败类别;在保持枚举集合的约束下,目标码及其解释须先决定(见第六节)。 -- **验收**:同步现行指南与错误码契约测试,覆盖 Create、输入转换、节点/模型执行、输出容量、Control 和异常路径; - 核实宿主看到的分类不会因内部错误码碰撞而变化。真实 SDK 对接仍需另行核验,不能承诺仅改一个映射函数即可完成。 - -### 2. 端口数量关系校验会拒绝合法流程(已复现) - -[pipeline_validator.cpp:104-110](../src/core/pipeline_validator.cpp#L104-L110) 要求生产端和消费端的 cardinality -一致(除非一方是 `N:M`,或消费端是 `N:1`)。现有声明混用了节点自身的数量转换关系与边上数据的数量约束, -按字符串直接比较会拒绝可合法执行的组合: - -- **拆分后接不了逐项节点**:`TextChunkNode → TextRuleMatchNode` 报 `PORT_CARDINALITY_MISMATCH`(附录 B)。 - RuleMatch 逐条处理并保留来源编号 (req_id, sub_id),这个流程本来是正确的。 -- **自定义 Map 节点同样受限**:`MakeMapSpec` 把输入写死为 `1:1`([function_node.h:309](../include/nodes/function_node.h#L309)), - 不能放在任何拆分节点之后。按同一逻辑,聚合输出(`N:1`)也接不了逐项节点。 -- **Embedding 能接在拆分之后,是因为它声明了 `N:M`**,跳过了这项 cardinality 比较;类型、来源和存活期检查仍在。 -- **来源和存活期需分别评价**:当前生产消费者多声明 `preserve` 或 `aggregate`,静态 provenance 比较通常放行; - 这不代表运行时来源校验无用。`global` 和 `independent` 暂无生产声明,`session` 则用于语料和 Embedding 缓存, - 不能把整套检查一起删除。 - -**采纳方向**:区分节点的转换关系与边上的数据形状。逐项节点接收任意条目批次,并保留输入的数量、顺序及 -`(req_id, sub_id)`;拆分和聚合显式声明输出关系;出口仍须满足业务要求。不能仅把旧字符串替换为 -"每请求一条、每请求多条、句柄共享"三种标签:数量、来源关系和存活期是不同维度。 - -**设计与验收**:补齐空结果、可选输入、多输入对齐、共享候选、拆分后的逐项变换和聚合后再变换的规则; -保留真正不兼容的出口和来源错误,不能用普遍声明 `N:M` 或关闭检查来绕过。Pipeline JSON 格式保持不变, -Definition、Catalog、Validator、节点运行时及工具的解释须一致。完整形状推导属于跨层设计,不能预先归为局部修复。 -[框架简化计划](FRAMEWORK_SIMPLIFICATION_PLAN.md)第 8 节的阶段 4(跨请求状态)准备在这套字符串上再加 `shared` 和 -`provenance_config_field`,应先明确这里的数量与来源语义,再推进状态工作;无需等待第三节所有重构完成。 - -### 3. 外部响应格式由通用节点生成 - -`TextRuleMatchNode` 自己拼 `match_result_json`([text_rule_match_node.cpp:318-329](../src/common_nodes/text_rule_match_node.cpp#L318-L329)), -两个输出转换器原样写出([keyword_result_output.cpp:22-25](../src/adapter/output/keyword_result_output.cpp#L22-L25)、 -[audio_result_output.cpp:71-72](../src/adapter/output/audio_result_output.cpp#L71-L72))。改节点里一个键名, -就同时改了两个外部契约,违反了 [AGENTS.md](../AGENTS.md) 中"响应组装和序列化留在 Adapter"的规则。 - -`RuleMatchItem`([common_contracts.h:64](../include/core/common_contracts.h#L64))的标量字段、`details` 和序列化字符串 -有信息重叠,但并非三份完全相同的结果:标量字段主要保存首次命中,`details` / JSON 还包含全部 `matches`、 -带 JSON 类型的 slots 常量,以及默认命中的 `raw_query`。只用现有标量字段重建响应会丢失信息。 - -**采纳方向**:Node 输出完整中性匹配结果,由 Adapter 按各自外部契约组装和序列化。内部业务状态与宿主 -`status_code` 的映射也归 Adapter,不要求删除所有内部状态表达。 - -**验收**:覆盖关键词与音频两种外部响应,保持全部字段、类型、默认值和序列化结果;同步检查读取 `details` 的 -内部消费者,覆盖多规则命中、typed slots 和默认命中,不能在搬移序列化时悄悄缩减语义。 - -### 4. 致命错误与单条业务无效结果的区别(待明确) - -任一 Node 返回非零失败,或转换器拒绝结果时,整批不发布新输出;审计结果的 `risk_level` 不在枚举内也会触发 -转换失败。整批回滚和租约归还有[契约测试](../tests/contract/abi/test_operator_safety.cpp#L261)。 -这是当前的致命错误语义,应与单条业务结果无效分开描述。 - -内置成功路径通常产生 `status_code=0`,但框架没有强制它恒为 0:关键词、音频、文档转换器会复制内部状态, -[行编码测试](../tests/unit/adapter/test_io_converters.cpp#L697)明确验证非零状态 23 被保留。 -`StructuredJsonParseNode` 也已有逐项 `is_valid`、`parse_status` 和 `diagnostic`; -`emit_diagnostic` / `configured_fallback` 可使坏项不触发节点失败,见 -[测试](../tests/unit/nodes/test_structured_json_parse_node.cpp#L94)。下游是否接受这些结果仍由具体契约决定。 - -**建议**:保持致命错误的整批原子性,在各业务契约中明确单条无效结果、fallback 和 `status_code` 的含义。 -不能把保留整批回滚等同于"所有 status_code 恒为 0",也不能仅因某业务需要单条失败就认定必须新增框架级错误通道。 -若确有通用部分成功需求,再设计失败项的来源、下游过滤/聚合、输出组装及跨请求状态提交规则,并单独验收。 - -## 二、对照五条标准 - -| 标准 | 现状 | 主要差距 | -| --- | --- | --- | -| 1. 外部契约不变 | ABI 与载体保持稳定;现行返回行为包含内部码透传 | 错误分类易碰撞;调整返回语义需要契约决策(问题 1) | -| 2. 各层新增方便 | 新增 Node 很顺;Model、Backend、业务契约偏重 | 见第四节 | -| 3. 层次隔离 | 编译期隔离很强 | 响应格式在节点;业务边界与提示文案在 Core 可进一步分离;中性来源编号本身不构成越层 | -| 4. 使用者门槛 | 编排和简单节点低;Batch 节点中等;新业务契约高 | 端口三元字符串、`TraceableItem`、Adapter 标识过多 | -| 5. 易理解、无冗余 | 存在重复实现和未使用结构,也有必要的契约与扩展接口 | 见第三节;不能把仅由测试使用的能力一律视为死代码 | - -## 三、简化建议及采纳条件 - -本节区分实现重复、未使用结构与已支持的扩展能力。仅由测试使用不是删除理由;撤销能力、改变并发保证或 -改变配置含义都需要按契约变更评价,不能统一归为低风险内部清理。 - -### 接入适配层 - -- **部署信息有多处转存,可减少重复字段**: - [PreparedDeployment](../src/adapter/deployment_preparation.h#L25) → - [ValidatedIoPlan](../src/adapter/io_binding_resolver.h#L19) → - [ResolvedOperatorConfig](../src/adapter/operator/operator_config_resolver.h#L18) → - [SharedAlgorithmRuntime](../src/adapter/shared_algorithm_runtime.h) 和 - [OperatorHandle](../src/adapter/operator/operator_adapter.cpp#L27)。这些对象分别承担部署准备、校验结果、宿主配置和 - 可变运行资源,不能仅按对象数量判断冗余。`SharedAlgorithmRuntime` 还承担注册审计、Pipeline 构建和异常处理, - 并非只做转发([shared_algorithm_runtime.cpp:24](../src/adapter/shared_algorithm_runtime.cpp#L24))。 - 可研究共享不可变部署数据及更准确的命名,但须保留预检与资源加载的阶段边界、失败原子性和输出池生命周期。 -- **端口绑定实现重复,可抽公共实现**:`InputPortBindings` 和 `OutputPortBindings` - ([io_converter.h:150](../include/adapter/io_converter.h#L150)、[io_converter.h:194](../include/adapter/io_converter.h#L194)) - 的查找与存储逻辑相同。保留输入/输出方向约束及错误诊断即可,不必扩展作者接口。 -- **输入限额检查(B3 已处理)**:值类型校验与转换器各查一次限额,但两处都取自 `biz_input` 常量, - 分别负责读取宿主内存前的安全上限和业务字段语义,保留两层检查。原 `ResolvedInputLimits` 从未被配置, - 已改名为 `InputLimits` 并移出部署配置与 handle;通用载体的 10 MiB 上限也改为具名常量, - 读取范围和错误优先级不变。 -- **契约元数据应保留,可减少重复填写**:`schema_id`、`schema_version`、`external_type`、槽位 `value_type` 和 - `capacity_policy` 实际参与同一业务外部协议的一致性检查,不一致会拒绝接入,见 - [io_binding_registry.cpp:14](../src/adapter/io_binding_registry.cpp#L14)。其中部分已有默认值,并非每项都要手写。 - 可由工厂或类型推导重复的载体信息,但须保留协议身份、版本和容量策略检查,不能把这些字段视为无效元数据。 -- **自定义输出分配器应保留**:当前生产类型没有额外具名 allocator,但注册、参数解析与 `OutputConfigReader` - 是已支持的扩展能力([operator_value_type.h:310](../include/adapter/operator_value_type.h#L310)),用于嵌套布局、 - 数组容量及分配/释放。`allocator`、`params` 也是现有 Pipeline 配置字段,见 - [输出分配方案](../doc/dev_guide/operator_output_allocation.md#选择参数)。测试正在证明这条接入路径。 - 删除属于撤销能力,并非死代码清理;本文不建议删除。 -- **未使用结果结构已清理(B1)**:原 `include/adapter/biz_results.h` 中 6 个结构没有类型引用,已随文件删除; - `KeywordResult` 只用于输出池与自定义 allocator 测试,已改为测试夹具中的 `NestedOutputSource`。 -- **宿主类型集中登记可研究分散注册**:类型清单写在框架头里([io_converter.h:35](../include/adapter/io_converter.h#L35)), - 内置值类型集中在 [operator_builtin_value_types.cpp](../src/adapter/operator/operator_builtin_value_types.cpp), - 新增宿主结构要改这两个集中文件。收敛注册步骤须保留类型与布局核验,不把宿主类型带入下层; - 外部环境只能处理现有 mock 和中性接缝,不能推测真实 SDK 的新类型。 - -### 流程编排层 - -- **业务审计与图校验可进一步分离**:Validator 同时使用 `BizDefinition` 和 Adapter 传入的 `PipelineIoBoundary`,并互相比对 - ([pipeline_validator.cpp:965](../src/core/pipeline_validator.cpp#L965)、[pipeline_validator.cpp:1200](../src/core/pipeline_validator.cpp#L1200))。 - [NodeDefinition::biz_names](../include/core/node_definition.h#L87) 没有生产节点设置限制。 - 可研究 Core 只接收中性 IO 边界,由 Adapter 持有业务定义及一致性审计,但须保留独立业务契约、 - 全量注册审计、Catalog 可见性和 CLI 预检;这是跨层调整,不只是搬文件。 - 不应借此取消[简化计划第 9 节](FRAMEWORK_SIMPLIFICATION_PLAN.md#9-不做的事项及理由)要求保留的独立契约检查。 -- **修复建议生成目前在 Core,可移到工具层**:中文文案与 JSON patch 生成代码位于 - [pipeline_validator.cpp:375-943](../src/core/pipeline_validator.cpp#L375-L943),编进端侧 SDK,校验失败时执行。 - 可使 Core 只输出结构化原因、路径和事实,由 CLI 提供中文建议与 JSON patch。须保留单一 Validator 规则来源, - 工具不得重新实现校验;还需验证 SDK 诊断和 Studio 的修复建议不退化。 -- **Control 有重复解析与 schema 校验,可优化传递方式**:Core 与节点各检查载荷 - ([pipeline.cpp:582](../src/core/pipeline.cpp#L582)、[control_authoring.h:140](../include/nodes/control_authoring.h#L140)), - Node 接收字符串后会再次解析。可研究复用已解析载荷,但 Core 的广播预检与 Node 的独立调用校验各有职责。 - 不能取消预检,让前面的节点已更新后才发现后面节点的 schema 不兼容;语义失败的现行 best-effort 行为也须保持。 -- **JSON 基础检查可共用,入口语义不必合并**:`ConfigFieldDefinition`、 - [control_payload.h](../include/contracts/control_payload.h)、[json_structure.h](../include/contracts/json_structure.h) - 分别承担配置字段、Control schema 和结构检查。可共用类型、范围、枚举等基础规则;不能假定三者的 - 默认值补齐、未知字段处理和错误定位契约完全相同。 -- **测试便利接口须按职责评价**:`RegisterModel`、`UpdateModelRevision`、`GetModelRegistration`、`GetAllModels`、 - `HasModel`、`SetResource`、`GetResource` 当前主要由测试使用([session_context.h:129](../include/core/session_context.h#L129)), - 部分用于模型注入、原子注册、缓存版本及资源所有权验证。无运行时需求的便利封装可移到测试支持; - 不能只按调用者数量删除它们所证明的能力。 - `RuntimeOptions::biz_type` 与 `depth_num` 可核实运行时读者后清理;`has_device_id` 有生产用途, - [pipeline.cpp:43](../src/core/pipeline.cpp#L43)用它决定是否向 Backend 传递设备编号,不能当作死字段删除。 - -### 能力节点层 - -- **Map/Batch 生命周期重复,适合后续重构**:`AuthorNode`([function_node.h:1379](../include/nodes/function_node.h#L1379)) - 与 Batch 版([function_node.h:1539](../include/nodes/function_node.h#L1539))做同样的绑定、参数解析和 Control。 - Map 可研究作为 Batch 的包装,保留现有作者写法;验收空批、逐项失败诊断、数量/顺序/来源、不可复制参数和 Control 回滚。 -- **声明与实例状态可分离,但不能承诺去掉全部类型擦除**:输入、输出、模型、参数各有 - "虚接口 + 实现 + Holder + Clone"。输入/输出声明混入解析后的 key,可移到 AuthorNode 实例,并共享只读声明以减少复制。 - 但参数绑定本来就主要是声明([parameter_binding.h:166](../include/nodes/parameter_binding.h#L166)), - 异构字段类型仍需类型擦除或等价模板机制。先设计清楚状态归属,再判断哪些 Clone 和 Holder 可以删除。 -- **旧类写法辅助函数可收窄到内部运行时与测试**:`NodeBase` 的 `BindPort`、`Require`、`Publish` 等主要由测试使用 - ([node_base.h:246](../include/nodes/node_base.h#L246))。清理时保留所需的运行时测试接缝,业务作者仍只使用 Spec。 -- **模型调用类可减少持有与绑定样板**:5 个 `XxxCall` 类已有 - [InvokeAlignedModel](../include/nodes/model_calls.h#L38)共用执行与对齐逻辑。可以抽公共持有实现; - `Generate`、`Embed`、`Transcribe` 等具名能力方法有助理解,不必为消除少量样板改成一套通用调用语法。 -- **参数快照与原子发布应保留**:Operator 句柄锁只保证该入口串行,不能替代 Node 自身的并发契约。 - [ConfigurationSnapshot](../include/nodes/configuration_snapshot.h#L45)保证 writer 串行、原子发布、旧快照存活和失败回滚; - [并发测试](../tests/unit/nodes/test_function_node.cpp#L2415)直接并发调用 Node 的 Process / Control。 - 普通指针替换会削弱保证,本文不建议改成无同步的"拷贝、校验、替换"。 - -### 模型执行层 - -- **身份声明可减少重复,但保留实际实例核验**:模型类型、能力、并发函数与 Definition 有重复,工厂再逐项比对 - ([model_runtime_factory.cpp:185](../src/engine/runtime/model_runtime_factory.cpp#L185))。Backend 类型名也写三遍。 - 可以由类型化注册或公共实现提供声明,能力由接口推导(已有 [ModelCapabilityTraits](../include/engine/model_capability_traits.h))。 - 但实际能力接口、Session 协议、批次策略及并发一致性仍须核验,不能把全部运行时检查变成仅验证声明自身。 -- **Backend Provider 可研究函数化注册**:现有 `IInferenceBackend` 实现主要作为一次 `Load` 的工厂 - ([backend_interface.h:133](../include/engine/backend_interface.h#L133))。直接注册 Load 函数可以减少 Provider 类, - 但须保持异常屏障、失败诊断、Session 所有权和协议检查;属于可选简化,不阻塞现有接入。 -- **配置声明和读取可以统一到中性工具**:Model/Backend 手写字段声明与 `json.value()`;Node 的 - `Parameters` + `Field` 已有类型绑定。可提取公共配置工具到 `contracts` 等中性位置, - 不能让 Model/Backend 反向依赖 `nodes`。保留语义预检、默认值补齐及资源相关创建验证。 -- **Backend 条件编译可由 CMake 收敛**:源文件始终收录,内部多处 `#ifdef HAVE_X`(例如 - [onnxruntime_backend.cpp](../src/engine/backends/onnxruntime/onnxruntime_backend.cpp))。可将可测的中性工具函数单独成文件, - 厂商实现按开关收录;须验证开关启用/关闭两种构建、Catalog 注册、缺失 Backend 的诊断及 vendor 依赖隔离。 -- **Model 来源接口暂缓修改**:`FixedBatchExecutor` 已统一恢复来源并去 padding - ([fixed_batch_executor.h:148](../include/engine/fixed_batch_executor.h#L148)),Model 作者多数无需自己复制编号。 - 调用门面还用编号检查输出对齐;若按下标直接回填,部分重排错误将失去检测依据。Qwen 也用编号派生种子。 - 只有出现明确接入负担时,再单独设计普通向量接口、顺序保证、种子传递和来源验证的替代机制。 - -### 横向 - -- **注册表公共操作可抽取,完整泛型注册表按需设计**:多个注册表重复实现冲突记录与查询, - [registry_support.h](../src/engine/runtime/registry_support.h)目前只覆盖 Model/Backend。 - 可先共用小型操作;统一容器前须核实锁内操作、回调重入、返回对象寿命及失败封闭行为,不能跨层引入具体组件依赖。 -- **运行时契约与编排实现可物理分开**:`include/core` 同时包含节点可用的中性契约 - ([node_core_contracts.txt](../cmake_ext/node_core_contracts.txt))和编排引擎。 - 迁移可使目录更贴近已存在的 `edgeflow_runtime_contracts` 编译边界,减少清单特例; - 仍须保留头文件视图、编译探针和分层守卫,验证下层没有获得完整 Core 依赖。此项不阻塞功能修复。 - -## 四、各层"新增一个东西"的改动面 - -以下是生产接入步骤的简化方向,不是已实现或已批准的目标接口。测试、文档和分层验证仍按受影响契约补齐。 - -| 新增 | 现在要改的地方 | 可收敛方向与必须保留的工作 | -| --- | --- | --- | -| Node(已有类型) | 1 个文件 + 1 个宏 | 已经很好 | -| 新端口值类型 | 类型声明及 Blackboard 类型特化 | 可就近声明类型特征;共享类型的归属与唯一类型名仍须明确 | -| Model(已有能力) | 目录、CMake 清单、Definition、Create、身份函数、手读 JSON | 减少身份与配置样板;创建、预处理语义和实际能力核验仍需要 | -| Backend(已有协议) | 目录、CMake、依赖脚本、条件编译、Provider 类、Session | 可减少 Provider 与条件编译样板;依赖 pin、隔离、加载/释放及开关验证仍需要 | -| 新模型能力 | 接口、能力名、Call、载荷、类型特征,必要时还有协议与脚手架 | 共用声明与调用基础设施;能力语义和是否需要新协议仍须设计,不能保证只改三个位置 | -| 业务契约(复用宿主结构) | 业务定义、Binding、转换器及协议标识 | 推导重复载体信息、使用已有默认值;保留独立业务契约和外部协议一致性检查 | -| 新宿主结构 | mock 结构、值类型登记、布局分配、转换器、Demo | 可研究局部类型化注册;布局/容量、分配释放、完整转换和 Demo 接入仍需要 | - -## 五、建议顺序 - -1. 先明确第一节 1 的公开错误分类与第一节 2 的数量/来源语义,按跨层及公开行为设计处理; - 分别记录验收与回退方式,不能以"JSON 没变"替代契约审查。 -2. 优先修复端口误判、迁移 RuleMatch 外部序列化;错误码映射在目标语义确定后实施。 - 每项独立验证,覆盖真正不兼容流程、完整响应信息与错误路径,保持外部载体和配置格式。 -3. 明确第一节 4 的致命错误与业务无效结果语义,默认保留整批回滚;有具体需求时再设计部分成功能力。 -4. 清理确认无引用的旧结构,合并端口绑定公共实现;测试支持可按职责迁移,不成批删除扩展接口。 -5. 再做 Map/Batch 生命周期与声明/实例状态分离;Adapter 数据转存、业务审计归属、工具层建议生成各自评估, - 保留阶段边界、独立契约审计和诊断能力。 -6. Model/Backend 注册、配置与条件编译按收益逐项简化;保留来源接口,完整泛型注册表和目录迁移均不作为发布前置条件。 -7. 跨请求状态排在数量/来源语义修正之后,不必等待所有清理和可选重构。 - -每个实施项遵循 [CONTRIBUTING](../CONTRIBUTING.md#3-design-and-current-contracts) 的设计与验收分类; -阶段完成后运行适用检查和唯一交付门禁,更新当前指南。单纯减少行数不能替代行为、边界与扩展能力的验收。 - -## 六、决定事项 - -以下决定于 2026-10-03 作出,替代原待决定事项。 - -1. **错误码公开语义**:接入适配层按失败阶段集中映射。Create 阶段的参数、配置、部署与模型/后端 - 加载失败返回 -2;Process 与 Control 中节点或模型执行失败返回 -100,其含义改为"未分类的运行时失败"; - 门面、输入、输出容量和 Control 自身的校验码不变。原始码、节点及所在层写入 `GetOperatorLastError()`。 - 真实 SDK 的目标码仍须在授权内网核验。 -2. **端口数量与来源**:保留四种字符串与 Pipeline JSON 格式。输入端口声明节点如何消费,接受任意数量的 - 上游数据;输出端口声明产出关系。Validator 沿 DAG 推导每个数据名在请求内的条目数(每请求一项、 - 某个拆分产生的多项、未知),只在 `1:1` 边界收到多项或同一节点的逐项输入无法配对时报错; - 来源与存活期检查不变。 -3. **单条业务无效结果**:保留致命错误的整批回滚;在各业务契约中写明 `status_code`、fallback 与诊断项的 - 含义;不新增框架级部分成功机制。 -4. **可选重构**:逐项评估,收益明确且契约可保持时才实施;不采纳的在下表记录理由。 - -## 七、实施进度 - -每项完成后单独提交,规则写入对应的现行指南。 - -| 编号 | 项目 | 来源 | 状态 | -| --- | --- | --- | --- | -| A1 | 端口数量关系改为形状推导 | 第一节 2 | 已完成:Validator 推导形状;`TextEmbeddingNode` 改为 `1:1`;规则见[数量关系声明](../doc/dev_guide/custom_node_concepts.md#数量关系声明与-validator-检查) | -| A2 | RuleMatch 外部响应序列化移到 Adapter | 第一节 3 | 已完成:`RuleMatchItem` 改为中性的全部命中与带类型槽位;两个转换器共用 Adapter 序列化,字节级契约测试覆盖 | -| A3 | 错误码按阶段映射 | 第一节 1 | 已完成:Adapter 集中映射,`Pipeline::Control` 报告失败阶段;规则见[宿主调用与生命周期](../doc/dev_guide/operator_output_allocation.md#宿主调用与生命周期),真实 SDK 目标码仍待内网核验 | -| A4 | 致命错误与单条无效结果写入业务契约 | 第一节 4 | 已完成:保留整批回滚,各业务约定见[整批失败与单条结果](../doc/dev_guide/business_onboarding.md#整批失败与单条结果) | -| B1 | 清理未使用的结果结构 | 第三节·接入适配层 | 已完成:删除 `biz_results.h`;自定义 allocator 测试改用夹具内的 `NestedOutputSource` | -| B2 | 输入/输出端口绑定共用实现 | 第三节·接入适配层 | 已完成:`PortBindings<方向>` 共用查找,输入/输出仍是不可互换的类型 | -| B3 | 输入限额统一规则来源 | 第三节·接入适配层 | 已完成:限额全部来自 `biz_input` 常量;`InputLimits` 不再伪装成部署配置,两层检查保留 | -| B4 | `RuntimeOptions` 死字段与测试便利接口 | 第三节·流程编排层 | 已完成:删除只写不读的 `biz_type`、`depth_num`、`biz_name`;单模型注册移到测试支持 `RegisterTestModel`。读取接口、`UpdateModelRevision` 与 `SetResource`/`GetResource` 保留,它们证明原子注册、按版本区分缓存和资源类型检查 | -| B5 | `NodeBase` 旧写法辅助函数收窄 | 第三节·能力节点层 | 已完成:`BindPort`/`Require`/`Publish` 等移到 `dev_support` 的 `LegacyNodeBase`,供直接驱动 NodeBase 的测试与作者基准的历史基线使用;`NodeBase` 只保留生命周期与异常屏障 | -| C1 | Map/Batch 生命周期合并 | 第三节·能力节点层 | 已完成:`AuthorNode` 改为 Batch 运行时的包装,作者写法与 Definition 不变;逐项失败诊断、空批和绑定诊断有测试覆盖,Batch 绑定诊断补充期望/实际类型 | -| C2 | 端口声明与实例状态分离 | 第三节·能力节点层 | 不采纳:`REGISTER_FUNCTION_NODE` 每次构造实例都重新求值 Spec,不存在跨实例复制;模型与参数已在 `AuthorNode` 上,绑定内的实例状态只剩解析后的 key。拆出 key 需要为每类绑定增加并行的实例表并改动全部绑定接口,类型擦除仍然需要。Spec 需要跨实例共享或创建成本成为瓶颈时再设计 | -| C3 | 模型调用类共用持有实现 | 第三节·能力节点层 | 已完成:5 个 `XxxCall` 继承 `detail::ModelCallBase`,共用持有、只移动语义和槽位标识;具名能力方法保留 | -| C4 | 修复建议生成移到工具层 | 第三节·流程编排层 | 已完成:remediation 与修复复核移到 `src/cli/pipeline_remediation`,复核仍调用 Validator;未注册类型的相近名称留在 Core 诊断;15 份故障方案的 CLI 输出逐字节不变 | -| C5 | 业务审计与图校验分离 | 第三节·流程编排层 | 不采纳:Init 时的绑定全量审计已检查转换器与业务端口的 key 和类型;Validator 中剩余的交叉检查负责流契约一致性,以及业务可选、但某个转换器必需的输出,需要转换器视图或组合后的边界。把业务定义移到 Adapter 会牵动 Catalog、CLI/Studio 和 Pipeline 文档的 `biz_name`,目前没有缺陷驱动;跨请求状态改变边界语义时再一并设计 | -| C6 | Control 载荷复用 | 第三节·流程编排层 | 不采纳:Control 是低频配置更新,第二次解析只增加微秒级开销;Core 的广播预检(任何节点更新前完成)与 Node 对直接调用的校验职责不同,都需保留。复用解析结果需要修改 `INode::Control` 签名及全部节点与测试,收益不足 | -| C7 | JSON 基础检查共用 | 第三节·流程编排层 | 已完成:Control 载荷校验改用 `json_structure` 的 type/enum/minimum/maximum 判定,诊断文案不变;配置字段使用类型化定义,默认值补齐与错误定位契约不同,保持独立 | -| C8 | 部署信息转存与命名 | 第三节·接入适配层 | 已完成:`PreparedDeployment` 与 `ValidatedIoPlan` 共用 `IoBindingSelection`,逐字段转存改为一次移动,输出池字段同名;handle 不再另存转换器指针,`ResolvedOperatorConfig` 不再重复绑定标识。预检与资源加载的阶段边界不变 | -| C9 | 契约元数据减少重复填写 | 第三节·接入适配层 | 已完成:`external_type` 缺省时由外部槽类型推导,16 个生产转换器删除手写值,Catalog 输出逐字节不变;`schema_id`、版本、槽位 `value_type` 与容量策略检查保留 | -| C10 | 宿主类型分散注册 | 第三节·接入适配层 | 不采纳:宿主结构是多个转换器共用的平台定义(如 `CompanyOperatorEntityInput` 同时用于文本与翻译输入),类型特征和布局每个类型需要唯一所有者,目前两个集中文件就是所有者;按类型拆分只增加文件而不减少步骤。真实 SDK 类型只能在授权内网接入,现在重设该接缝属于推测 | -| D1 | Model 身份声明去重 | 第三节·模型执行层 | 已完成:6 个生产 Model 继承 `ModelIdentity`,身份只声明 `kModelType`/`kConcurrency`,能力由接口推导,Definition 用 `MakeModelDefinition`;工厂的实例核验保留,Catalog 逐字节不变。Backend 类型名随 D2 处理 | -| D2 | Backend Provider 函数化注册 | 第三节·模型执行层 | 部分完成:4 个生产 Backend 继承 `BackendIdentity`,类型名只在 `kBackendType` 声明,Definition 用 `MakeBackendDefinition`。函数化注册不采纳:Provider 只是一个小类,异常屏障、诊断、Session 所有权和协议检查都在 `Load` 与工厂中,改为函数不会减少这些工作,却要改动 `BackendRegistry` 接口和所有测试替身 | -| D3 | Model/Backend 配置声明与读取统一 | 第三节·模型执行层 | 已完成:`contracts` 新增 `ConfigValueOrDefault`,6 个 Model 与 2 个 Backend 的 33 处读取改为取 Definition 中声明的默认值(此前手写默认值均与声明一致),Catalog 逐字节不变。未把 Node 的 `Parameters` 移入 contracts:Model 配置只在创建时读取一次,读取器已消除重复 | -| D4 | Backend 条件编译由 CMake 收敛 | 第三节·模型执行层 | 不采纳:厂商头文件已只出现在各 Backend 源文件的受保护分支内,始终编译的中性工具已在 `*_detail` 中独立并有测试;每个文件只有 7–13 行预处理指令。改由 CMake 收录会让关闭的 Backend 缺少 Provider 定义,测试与注册还需额外按开关处理,并须验证 4 个开关各自开启和关闭的构建,成本高于可读性收益。新增可选 Backend 或受保护代码明显增多时再处理 | -| D5 | 注册表公共操作抽取 | 第三节·横向 | 已完成:`contracts` 新增 `RegistryConflicts`,IoConverter、IoBinding、Model、Backend 注册表共用冲突记录、查询与清理,并修复 Adapter 注册表在消息存储失败时丢失冲突的失败开放问题;各表仍用自身锁。NodeRegistry 保留原子标志以便注册回调中无锁读取,ValueType 注册表只有标志、保持不变;不统一完整容器 | -| D6 | 运行时契约与编排实现目录分离 | 第三节·横向 | 不采纳:`include/core` 的 18 个头文件中 14 个已是节点可见契约,编排实现只有 `pipeline.h`、`pipeline_validator.h`、`remediation_cause.h`、`thread_pool.h`;`node_core_contracts.txt` 已精确定义视图边界,并由头文件视图、编译探针和守卫脚本执行。物理迁移要改写约 250 处 include 及架构文档的层与目录对照,不改变任何依赖;审查也将目录迁移列为非发布前置条件 | - -保留项(自定义 allocator、参数快照、契约元数据检查、Model 来源接口)不列入实施。 - -## 附录:复现步骤 - -在仓库外新建一个临时目录,以下命令都在该目录中执行;`` 为仓库根目录,`build/` 为默认构建目录。 - -### A. 内部错误码穿透到宿主 - -测试后端固定返回非 JSON 文本 `test-generation`,`failure_policy` 设为 `fail` 后解析节点失败。 - -`pipeline.json`: - -```json -{ - "deployment": {"io": {"io_binding": "entity_extract.operator.v1"}}, - "models": [ - {"model_id": "llm", "model_type": "qwen_causal_lm", "backend": "test_causal_lm_backend", - "model_path": "neutral-llm.fixture", "model_config": {}, "backend_config": {}} - ], - "pipeline": [ - {"id": "gen", "node_type": "LlmGenerateNode", "config": {"bind_model": "llm"}, - "inputs": {"prompt": "input_sentences"}, "outputs": {"text": "raw"}}, - {"id": "parse", "node_type": "StructuredJsonParseNode", "config": {"failure_policy": "fail"}, - "inputs": {"text": "raw"}, "outputs": {"document": "extracted_entities"}} - ] -} -``` - -`pipeline.conf`: - -```json -{"pipe_path": "pipeline.json"} -``` - -运行: - -```bash -cp /demo/fixtures/mock/artifacts/neutral-llm.fixture . -printf '张三在北京\n' > data.txt -/build/alg_demo --config pipeline.conf --dataset data.txt --output-dir out -``` - -实际输出: - -```text -[OperatorRunner ERROR] ops.Process failed at chunk starting index 0: code=-6102 (Pipeline::Execute failed -with code -6102: Node 'parse' (StructuredJsonParseNode): JSON parse failed for sample: ...) -``` - -### B. 拆分后接逐项节点被拒绝 - -`chunk_then_rule.json`: - -```json -{ - "models": [], - "pipeline": [ - {"id": "chunk", "node_type": "TextChunkNode", "config": {"chunk_size": 4}, - "inputs": {"text": "input_sentences"}, - "outputs": {"chunks": "sentence_chunks", "chunk_counts": "sentence_chunk_counts"}}, - {"id": "rules_per_chunk", "node_type": "TextRuleMatchNode", - "config": {"categories": {"SYSTEM_INIT": ["初始化"]}}, - "inputs": {"text": "sentence_chunks"}, "outputs": {"matches": "chunk_matches"}}, - {"id": "rules_per_request", "node_type": "TextRuleMatchNode", - "config": {"categories": {"SYSTEM_INIT": ["初始化"]}}, - "inputs": {"text": "input_sentences"}, "outputs": {"matches": "rule_matches"}} - ], - "deployment": {"io": {"io_binding": "keyword_match.operator.v1"}} -} -``` - -运行: - -```bash -/build/alg_pipeline_tool validate chunk_then_rule.json -``` - -实际输出(节选):`ok` 为 `false`,诊断码 `PORT_CARDINALITY_MISMATCH`,路径 `/pipeline/1/inputs/text`,消息为 -`Port cardinality mismatch: producer '1:N' cannot feed consumer '1:1'`。 - -### C. 复核测试 - -下列 10 个测试已用现有 `build/` 产物执行通过,用于确认文中提到的现行契约。它们覆盖 Node 并发快照、 -逐项 fallback、外部协议不一致拒绝、自定义 allocator、内部错误码透传、非零行状态及设备编号传递。 -这不是建议方案已实现或通过验收的证据,也不替代实施后的完整门禁。 - -可用以下筛选器重跑: - -```bash -./build/edgeflow_test_nodes_runner --gtest_filter='FunctionNodeTest.WholeBatchProcessConsistencyDuringControl:FunctionNodeTest.WholeBatchProcessConsistencyDuringControlForBatchSpec:FunctionNodeTest.RapidInterleavedControlsAndConcurrentProcesses:ConfigurationSnapshotTest.ReaderHoldsOldSnapshotWhileWriterPublishes:StructuredJsonParseNodeTest.InvalidDocumentHonorsFallbackAndDiagnostic' -./build/edgeflow_test_adapter_runner --gtest_filter='IoBindingRegistryTest.SameSuffixDifferentCarrierIsRejectedBeforeConversion:AdapterContractSecurityTest.TranslationOperatorGeneratesOnceFromRawQueryAndPacksLiteralOutput:OperatorApiTest.CustomAllocatorStillRequiresItsExplicitLayoutParameters:IoConverterTest.EncodeRowsRestoresOrderAndIdsAndChecksWriterCapacity' -./build/edgeflow_test_core_runner --gtest_filter='ModelBackendPipelineTest.PipelinePassesResolvedPathAndTargetToBackend' -``` diff --git a/src/cli/pipeline_remediation.cpp b/src/cli/pipeline_remediation.cpp index f3dd139c..8e594374 100644 --- a/src/cli/pipeline_remediation.cpp +++ b/src/cli/pipeline_remediation.cpp @@ -70,6 +70,15 @@ DiagnosticIdentity GetDiagnosticIdentity(const ValidationDiagnostic& d) { return id; } +std::string DescribeItemShape(const nlohmann::json& shape) { + const auto kind = shape.value("kind", "unknown"); + if (kind == "per_request") return "每请求一项"; + if (kind == "multi") { + return "由 '" + shape.value("origin", "") + "' 产生的每请求零到多项"; + } + return "每请求项数未知"; +} + void PopulateBasicRemediation(ValidationDiagnostic* diag, const nlohmann::json& root, const PipelineCatalogSnapshot& catalog) { @@ -544,97 +553,32 @@ void PopulateBasicRemediation(ValidationDiagnostic* diag, } else if (diag->code == DiagnosticCode::kPortCardinalityMismatch || diag->code == DiagnosticCode::kPortProvenanceMismatch || diag->code == DiagnosticCode::kPortLifetimeMismatch) { - size_t consumer_idx = static_cast(-1); - if (diag->path.rfind("/pipeline/", 0) == 0 && root.contains("pipeline") && - root["pipeline"].is_array()) { - size_t idx_end = diag->path.find('/', 10); - std::string idx_str = (idx_end == std::string::npos) - ? diag->path.substr(10) - : diag->path.substr(10, idx_end - 10); - try { - consumer_idx = std::stoul(idx_str); - } catch (...) { - consumer_idx = static_cast(-1); - } - } - if (consumer_idx < root.value("pipeline", nlohmann::json::array()).size()) { - const auto& consumer_node = root["pipeline"][consumer_idx]; - std::string consumer_type = consumer_node.value("node_type", ""); - const auto* consumer_def = catalog.FindNode(consumer_type); - std::string port_name = diag->port; - std::string bound_key; - if (consumer_node.contains("inputs") && - consumer_node["inputs"].contains(port_name)) { - bound_key = consumer_node["inputs"][port_name].get(); - } - - PortContract expected_contract; - if (consumer_def) { - for (const auto& inp : consumer_def->inputs) { - if (inp.logical_name == port_name) { - expected_contract = inp; - break; - } - } - } - - std::string producer_id = - diag->related_nodes.empty() ? "" : diag->related_nodes[0]; - PortContract producer_contract; - - if (producer_id == "$ingress") { - const auto* biz = catalog.FindBiz(root.value("biz_name", "")); - if (biz) { - for (const auto& ing : biz->ingress) { - if (ing.blackboard_key == bound_key) { - producer_contract = ing; - break; - } - } - } + // Validator owns direction, effective contracts and transitive shapes. + // A path can identify either a node input, its output or an IO boundary. + if (diag->facts.empty()) return; + ValidationRemediation rem; + rem.cause = RemediationCause::kPortFlowMismatch; + rem.facts = diag->facts; + const auto producer_id = rem.facts.value("producer_id", ""); + const auto consumer_id = rem.facts.value("consumer_id", ""); + rem.summary = "生产者 '" + producer_id + "' 与消费者 '" + consumer_id + + "' 在端口 '" + diag->port + "' 上的流契约不兼容。"; + if (rem.facts.contains("actual_shape") && + rem.facts.contains("expected_shape")) { + const auto actual = DescribeItemShape(rem.facts["actual_shape"]); + const auto expected = DescribeItemShape(rem.facts["expected_shape"]); + if (rem.facts.contains("anchor_port")) { + rem.summary = "节点 '" + consumer_id + "' 的逐项输入 '" + diag->port + + "' 接收" + actual + ",输入 '" + + rem.facts["anchor_port"].get() + "' 接收" + + expected + ",无法逐项配对。"; } else { - for (const auto& p_node : root["pipeline"]) { - if (p_node.value("id", "") == producer_id) { - std::string p_type = p_node.value("node_type", ""); - const auto* p_def = catalog.FindNode(p_type); - if (p_def) { - for (const auto& out : p_def->outputs) { - std::string actual_out_key = out.logical_name; - if (p_node.contains("outputs") && - p_node["outputs"].contains(out.logical_name)) { - actual_out_key = - p_node["outputs"][out.logical_name].get(); - } - if (actual_out_key == bound_key) { - producer_contract = out; - break; - } - } - } - break; - } - } + rem.summary = "生产者 '" + producer_id + "' 在数据 '" + + rem.facts.value("bound_key", "") + "' 上提供" + actual + + ",消费者 '" + consumer_id + "' 要求" + expected + "。"; } - - ValidationRemediation rem; - rem.schema_version = 1; - rem.cause = RemediationCause::kPortFlowMismatch; - rem.summary = "生产者 '" + producer_id + "' 与消费者 '" + diag->node_id + - "' 在端口 '" + port_name + "' 上的流契约不兼容。"; - rem.facts["bound_key"] = bound_key; - rem.facts["producer_id"] = producer_id; - rem.facts["expected"] = { - {"type_id", expected_contract.type_id}, - {"cardinality", expected_contract.cardinality}, - {"provenance_policy", expected_contract.provenance_policy}, - {"lifetime", expected_contract.lifetime}}; - rem.facts["actual"] = { - {"type_id", producer_contract.type_id}, - {"cardinality", producer_contract.cardinality}, - {"provenance_policy", producer_contract.provenance_policy}, - {"lifetime", producer_contract.lifetime}}; - diag->remediation = std::move(rem); } + diag->remediation = std::move(rem); } } diff --git a/src/core/pipeline_validator.cpp b/src/core/pipeline_validator.cpp index 554154dc..8a8d8a25 100644 --- a/src/core/pipeline_validator.cpp +++ b/src/core/pipeline_validator.cpp @@ -27,7 +27,34 @@ void Add(ValidationReport* report, DiagnosticCode code, std::string path, report->diagnostics.push_back({code, std::move(path), std::move(message), "error", std::move(node_id), std::move(port), std::move(related), std::move(suggestions), - std::nullopt}); + std::nullopt, nlohmann::json::object()}); +} + +nlohmann::json PortContractFacts(const PortContract& port) { + return {{"type_id", port.type_id}, + {"cardinality", port.cardinality}, + {"provenance_policy", port.provenance_policy}, + {"lifetime", port.lifetime}}; +} + +void AddPortFlowDiagnostic( + ValidationReport* report, DiagnosticCode code, const std::string& path, + std::string message, const std::string& producer_id, + const std::string& consumer_id, PortDirection location, + const std::string& logical_port, const std::string& bound_key, + const PortContract& producer, const PortContract& consumer) { + // A node input diagnostic belongs to the consumer; boundary diagnostics + // belong to the producer. Preserve these locations and related-node IDs. + const bool boundary = location == PortDirection::kOutput; + Add(report, code, path, std::move(message), + boundary ? producer_id : consumer_id, logical_port, + {boundary ? consumer_id : producer_id}); + report->diagnostics.back().facts = { + {"producer_id", producer_id}, + {"consumer_id", consumer_id}, + {"bound_key", bound_key}, + {"actual", PortContractFacts(producer)}, + {"expected", PortContractFacts(consumer)}}; } NodePortDefinition EffectivePortDefinition( @@ -87,6 +114,18 @@ std::string DescribeShape(const KeyShape& shape) { return "an unknown number of items per request"; } +nlohmann::json ShapeFacts(const KeyShape& shape) { + switch (shape.kind) { + case KeyShape::Kind::kPerRequest: + return {{"kind", "per_request"}}; + case KeyShape::Kind::kMulti: + return {{"kind", "multi"}, {"origin", shape.origin}}; + case KeyShape::Kind::kUnknown: + break; + } + return {{"kind", "unknown"}}; +} + // Biz and IO boundary ports count items per request: "1:1" is exactly one // item, any other declaration is a collection belonging to the request. KeyShape BoundaryShape(const PortContract& port, std::string origin) { @@ -102,19 +141,22 @@ KeyShape NodeOutputShape(const PortContract& output, const KeyShape& item_shape, return KeyShape::Unknown(); } -void ValidateBoundaryShape(const KeyShape& produced, - const PortContract& consumer, - const std::string& path, const std::string& node_id, - const std::string& logical_port, - const std::string& producer_id, - ValidationReport* report) { +void ValidateBoundaryShape( + const KeyShape& produced, const PortContract& producer, + const PortContract& consumer, const std::string& path, + const std::string& producer_id, const std::string& consumer_id, + const std::string& bound_key, ValidationReport* report) { if (consumer.cardinality != "1:1" || produced.kind != KeyShape::Kind::kMulti) return; - Add(report, DiagnosticCode::kPortCardinalityMismatch, path, - "Port cardinality mismatch: '" + logical_port + - "' requires one item per request but receives " + - DescribeShape(produced), - node_id, logical_port, {producer_id}); + AddPortFlowDiagnostic(report, DiagnosticCode::kPortCardinalityMismatch, path, + "Port cardinality mismatch: '" + bound_key + + "' requires one item per request but receives " + + DescribeShape(produced), + producer_id, consumer_id, PortDirection::kOutput, + bound_key, bound_key, producer, consumer); + auto& facts = report->diagnostics.back().facts; + facts["actual_shape"] = ShapeFacts(produced); + facts["expected_shape"] = ShapeFacts(KeyShape::PerRequest()); } bool ProvenanceCompatible(const std::string& producer, @@ -141,26 +183,29 @@ bool LifetimeCompatible(const std::string& producer, return LifetimeRank(producer) >= LifetimeRank(consumer); } -void ValidatePortFlowContract(const PortContract& producer, - const PortContract& consumer, - const std::string& path, - const std::string& node_id, - const std::string& logical_port, - const std::string& producer_id, - ValidationReport* report) { +void ValidatePortFlowContract( + const PortContract& producer, const PortContract& consumer, + const std::string& path, const std::string& producer_id, + const std::string& consumer_id, PortDirection location, + const std::string& logical_port, const std::string& bound_key, + ValidationReport* report) { if (!ProvenanceCompatible(producer.provenance_policy, consumer.provenance_policy)) { - Add(report, DiagnosticCode::kPortProvenanceMismatch, path, - "Port provenance mismatch: producer policy '" + - producer.provenance_policy + "' cannot satisfy consumer policy '" + - consumer.provenance_policy + "'", - node_id, logical_port, {producer_id}); + AddPortFlowDiagnostic(report, DiagnosticCode::kPortProvenanceMismatch, path, + "Port provenance mismatch: producer policy '" + + producer.provenance_policy + + "' cannot satisfy consumer policy '" + + consumer.provenance_policy + "'", + producer_id, consumer_id, location, logical_port, + bound_key, producer, consumer); } if (!LifetimeCompatible(producer.lifetime, consumer.lifetime)) { - Add(report, DiagnosticCode::kPortLifetimeMismatch, path, + AddPortFlowDiagnostic( + report, DiagnosticCode::kPortLifetimeMismatch, path, "Port lifetime mismatch: producer lifetime '" + producer.lifetime + "' is shorter than consumer lifetime '" + consumer.lifetime + "'", - node_id, logical_port, {producer_id}); + producer_id, consumer_id, location, logical_port, bound_key, producer, + consumer); } } @@ -600,13 +645,14 @@ ValidatedPipelinePlan ValidateAndPlanInternal( "', got '" + it->second.type_id + "'", "$io_input", req_in.blackboard_key); } else { - ValidatePortFlowContract(it->second, req_in, "/io/input", - "$io_input", req_in.blackboard_key, - "$ingress", &report); + ValidatePortFlowContract( + it->second, req_in, "/io/input", "$io_input", "$ingress", + PortDirection::kOutput, req_in.blackboard_key, + req_in.blackboard_key, &report); ValidateBoundaryShape( BoundaryShape(it->second, "$ingress." + req_in.blackboard_key), - req_in, "/io/input", "$io_input", req_in.blackboard_key, - "$ingress", &report); + it->second, req_in, "/io/input", "$io_input", "$ingress", + req_in.blackboard_key, &report); } } } @@ -705,6 +751,7 @@ ValidatedPipelinePlan ValidateAndPlanInternal( // single item per request. KeyShape item_shape = KeyShape::PerRequest(); std::string item_shape_port; + std::string item_shape_key; ValidatedNodePlan node_plan; node_plan.node = node; @@ -779,6 +826,7 @@ ValidatedPipelinePlan ValidateAndPlanInternal( input.lifetime, PortDirection::kInput}); bool found = false; std::string source_id; + const PortContract* source_contract = nullptr; auto producer_it = producers.find(actual_key); if (producer_it != producers.end() && producer_it->second.size() == 1 && !ingress.count(actual_key)) { @@ -786,8 +834,10 @@ ValidatedPipelinePlan ValidateAndPlanInternal( if (producer.second.type_id == input.type_id) { found = true; source_id = producer.first; - ValidatePortFlowContract(producer.second, input, input_path, id, - input.logical_name, producer.first, &report); + source_contract = &producer.second; + ValidatePortFlowContract(producer.second, input, input_path, + producer.first, id, PortDirection::kInput, + input.logical_name, actual_key, &report); } } else if (producer_it == producers.end()) { auto root_port = ingress.find(actual_key); @@ -795,8 +845,10 @@ ValidatedPipelinePlan ValidateAndPlanInternal( root_port->second.type_id == input.type_id) { found = true; source_id = "$ingress"; - ValidatePortFlowContract(root_port->second, input, input_path, id, - input.logical_name, "$ingress", &report); + source_contract = &root_port->second; + ValidatePortFlowContract(root_port->second, input, input_path, + "$ingress", id, PortDirection::kInput, + input.logical_name, actual_key, &report); } } if (input.cardinality == "1:1") { @@ -805,15 +857,23 @@ ValidatedPipelinePlan ValidateAndPlanInternal( if (item_shape_port.empty()) { item_shape = shape; item_shape_port = input.logical_name; + item_shape_key = actual_key; } else if (item_shape.Known() && shape.Known() && !(item_shape == shape)) { - Add(&report, DiagnosticCode::kPortCardinalityMismatch, input_path, + AddPortFlowDiagnostic( + &report, DiagnosticCode::kPortCardinalityMismatch, input_path, "Port cardinality mismatch: item-wise input '" + input.logical_name + "' receives " + DescribeShape(shape) + ", but item-wise input '" + item_shape_port + "' receives " + DescribeShape(item_shape) + "; item-wise inputs must pair item by item", - id, input.logical_name, {source_id}); + source_id, id, PortDirection::kInput, input.logical_name, + actual_key, *source_contract, input); + auto& facts = report.diagnostics.back().facts; + facts["actual_shape"] = ShapeFacts(shape); + facts["expected_shape"] = ShapeFacts(item_shape); + facts["anchor_port"] = item_shape_port; + facts["anchor_key"] = item_shape_key; item_shape = KeyShape::Unknown(); } else if (!shape.Known()) { item_shape = KeyShape::Unknown(); @@ -961,11 +1021,12 @@ ValidatedPipelinePlan ValidateAndPlanInternal( continue; } ValidatePortFlowContract(producer_port, consumer, output_path, - producer_id, consumer.blackboard_key, "$egress", + producer_id, "$egress", PortDirection::kOutput, + consumer.blackboard_key, consumer.blackboard_key, &report); - ValidateBoundaryShape(shape_of(consumer.blackboard_key), consumer, - output_path, producer_id, consumer.blackboard_key, - "$egress", &report); + ValidateBoundaryShape(shape_of(consumer.blackboard_key), producer_port, + consumer, output_path, producer_id, "$egress", + consumer.blackboard_key, &report); } } @@ -983,12 +1044,14 @@ ValidatedPipelinePlan ValidateAndPlanInternal( "'", "$ingress", consumer.blackboard_key, {"$io_output"}); } else { - ValidatePortFlowContract(ing_it->second, consumer, "/io/output", - "$ingress", consumer.blackboard_key, - "$io_output", &report); - ValidateBoundaryShape( - shape_of(consumer.blackboard_key), consumer, "/io/output", - "$ingress", consumer.blackboard_key, "$io_output", &report); + ValidatePortFlowContract( + ing_it->second, consumer, "/io/output", "$ingress", + "$io_output", PortDirection::kOutput, consumer.blackboard_key, + consumer.blackboard_key, &report); + ValidateBoundaryShape(shape_of(consumer.blackboard_key), + ing_it->second, consumer, "/io/output", + "$ingress", "$io_output", + consumer.blackboard_key, &report); } continue; } @@ -1012,11 +1075,12 @@ ValidatedPipelinePlan ValidateAndPlanInternal( continue; } ValidatePortFlowContract(producer_port, consumer, "/io/output", - producer_id, consumer.blackboard_key, - "$io_output", &report); - ValidateBoundaryShape(shape_of(consumer.blackboard_key), consumer, - "/io/output", producer_id, consumer.blackboard_key, - "$io_output", &report); + producer_id, "$io_output", + PortDirection::kOutput, consumer.blackboard_key, + consumer.blackboard_key, &report); + ValidateBoundaryShape(shape_of(consumer.blackboard_key), producer_port, + consumer, "/io/output", producer_id, "$io_output", + consumer.blackboard_key, &report); } } @@ -1116,6 +1180,7 @@ nlohmann::json ValidationDiagnostic::ToJson() const { if (!port.empty()) item["port"] = port; if (!related_nodes.empty()) item["related_nodes"] = related_nodes; if (!suggestions.empty()) item["suggestions"] = suggestions; + if (!facts.empty()) item["facts"] = facts; if (remediation.has_value()) { item["remediation"] = remediation->ToJson(); } diff --git a/tests/integration/pipeline/test_pipeline_catalog_validator.cpp b/tests/integration/pipeline/test_pipeline_catalog_validator.cpp index fd6f3b12..706c00cf 100644 --- a/tests/integration/pipeline/test_pipeline_catalog_validator.cpp +++ b/tests/integration/pipeline/test_pipeline_catalog_validator.cpp @@ -1165,6 +1165,59 @@ TEST(PipelineValidatorTest, SplitItemsFeedItemWiseNodeButNotPerRequestEgress) { DiagnosticCode::kPortCardinalityMismatch); EXPECT_EQ(rejected.diagnostics[0].path, "/pipeline/1/outputs/matches"); EXPECT_EQ(rejected.diagnostics[0].port, "rule_matches"); + const auto& egress = rejected.diagnostics[0]; + ASSERT_TRUE(egress.remediation.has_value()); + EXPECT_EQ(egress.remediation->cause, RemediationCause::kPortFlowMismatch); + EXPECT_NE(egress.remediation->summary.find("生产者 'rules_per_chunk'"), + std::string::npos); + EXPECT_NE(egress.remediation->summary.find("消费者 '$egress'"), + std::string::npos); + EXPECT_NE(egress.remediation->summary.find("chunk.chunks"), + std::string::npos); + EXPECT_NE(egress.remediation->summary.find("每请求一项"), std::string::npos); + const nlohmann::json declaration = {{"type_id", "RuleMatchBatch"}, + {"cardinality", "1:1"}, + {"provenance_policy", "preserve"}, + {"lifetime", "request"}}; + const nlohmann::json actual_shape = {{"kind", "multi"}, + {"origin", "chunk.chunks"}}; + const nlohmann::json expected_shape = {{"kind", "per_request"}}; + const auto& facts = egress.remediation->facts; + EXPECT_EQ(facts.at("producer_id"), "rules_per_chunk"); + EXPECT_EQ(facts.at("consumer_id"), "$egress"); + EXPECT_EQ(facts.at("bound_key"), "rule_matches"); + EXPECT_EQ(facts.at("actual"), declaration); + EXPECT_EQ(facts.at("expected"), declaration); + EXPECT_EQ(facts.at("actual_shape"), actual_shape); + EXPECT_EQ(facts.at("expected_shape"), expected_shape); + EXPECT_EQ(egress.facts, facts); + EXPECT_EQ(egress.ToJson().at("facts"), facts); + + PipelineIoBoundary boundary; + boundary.output_consumed_ports = { + BizPortDefinition{"rule_matches", "RuleMatchBatch", true}}; + const auto with_boundary = ValidateWithRemediation(root, &boundary); + const auto* io_output = FindDiagnostic( + with_boundary, DiagnosticCode::kPortCardinalityMismatch, "/io/output"); + ASSERT_NE(io_output, nullptr) << with_boundary.ToJson().dump(); + ASSERT_TRUE(io_output->remediation.has_value()); + EXPECT_NE(io_output->remediation->summary.find("生产者 'rules_per_chunk'"), + std::string::npos); + EXPECT_NE(io_output->remediation->summary.find("消费者 '$io_output'"), + std::string::npos); + EXPECT_NE(io_output->remediation->summary.find("chunk.chunks"), + std::string::npos); + EXPECT_NE(io_output->remediation->summary.find("每请求一项"), + std::string::npos); + const auto& io_facts = io_output->remediation->facts; + EXPECT_EQ(io_facts.at("producer_id"), "rules_per_chunk"); + EXPECT_EQ(io_facts.at("consumer_id"), "$io_output"); + EXPECT_EQ(io_facts.at("bound_key"), "rule_matches"); + EXPECT_EQ(io_facts.at("actual"), declaration); + EXPECT_EQ(io_facts.at("expected"), declaration); + EXPECT_EQ(io_facts.at("actual_shape"), actual_shape); + EXPECT_EQ(io_facts.at("expected_shape"), expected_shape); + EXPECT_EQ(io_output->facts, io_facts); } TEST(PipelineValidatorTest, ExplainReturnsPortFlowMismatchRemediation) { @@ -1218,6 +1271,14 @@ TEST(PipelineValidatorTest, ExplainReturnsPortFlowMismatchRemediation) { EXPECT_TRUE(actual.contains("cardinality")); EXPECT_TRUE(actual.contains("provenance_policy")); EXPECT_TRUE(actual.contains("lifetime")); + EXPECT_EQ(target_diag->remediation->facts.at("consumer_id"), "consumer"); + EXPECT_EQ(expected.at("type_id"), "TextBatch"); + EXPECT_EQ(actual.at("type_id"), "TextBatch"); + EXPECT_EQ(expected.at("lifetime"), "session"); + EXPECT_EQ(actual.at("lifetime"), "request"); + EXPECT_EQ(expected.at("cardinality"), "1:1"); + EXPECT_EQ(actual.at("cardinality"), "1:N"); + EXPECT_EQ(target_diag->facts, target_diag->remediation->facts); } TEST(PipelineValidatorTest, diff --git a/tests/unit/core/test_definition_schema_validation.cpp b/tests/unit/core/test_definition_schema_validation.cpp index 24fe0fe6..cca0dccf 100644 --- a/tests/unit/core/test_definition_schema_validation.cpp +++ b/tests/unit/core/test_definition_schema_validation.cpp @@ -1162,4 +1162,34 @@ TEST_F(DefinitionSchemaValidationTest, nlohmann::json::exception); } +TEST_F(DefinitionSchemaValidationTest, + ConfigValueOrDefaultRejectsSuppliedUndeclaredField) { + const std::vector fields = { + {"threads", ConfigValueKind::kInteger, false, 2}}; + const nlohmann::json config = {{"threads", 8}, {"undeclared", 42}}; + EXPECT_THROW(ConfigValueOrDefault(config, fields, "undeclared"), + std::invalid_argument); + EXPECT_THROW(ConfigValueOrDefault(config, {}, "threads"), + std::invalid_argument); +} + +TEST_F(DefinitionSchemaValidationTest, + ConfigValueOrDefaultRejectsNonObjectExceptNull) { + const std::vector fields = { + {"threads", ConfigValueKind::kInteger, false, 2}}; + const std::vector invalid_configs = { + nlohmann::json::array(), nlohmann::json::array({8}), nlohmann::json(8), + nlohmann::json(2.5), nlohmann::json("text"), nlohmann::json(true), + nlohmann::json(false)}; + for (const auto& config : invalid_configs) { + SCOPED_TRACE(config.dump()); + EXPECT_THROW(ConfigValueOrDefault(config, fields, "threads"), + std::invalid_argument); + } + EXPECT_EQ(ConfigValueOrDefault(nlohmann::json(), fields, "threads"), 2); + EXPECT_EQ( + ConfigValueOrDefault(nlohmann::json::object(), fields, "threads"), + 2); +} + } // namespace llm_edgeflow diff --git a/tests/unit/core/test_validated_pipeline_plan.cpp b/tests/unit/core/test_validated_pipeline_plan.cpp index b6d51adb..1ef9d214 100644 --- a/tests/unit/core/test_validated_pipeline_plan.cpp +++ b/tests/unit/core/test_validated_pipeline_plan.cpp @@ -420,6 +420,18 @@ class PortShapeTest : public ::testing::Test { inline static constexpr char kBiz[] = "port_shape_fixture_biz"; }; +TEST_F(ValidatedPipelinePlanTest, SerializesFactsOnlyWhenPresent) { + ValidationDiagnostic diagnostic; + EXPECT_TRUE(diagnostic.facts.is_object()); + EXPECT_TRUE(diagnostic.facts.empty()); + EXPECT_FALSE(diagnostic.ToJson().contains("facts")); + EXPECT_FALSE(diagnostic.ToJson().contains("remediation")); + + diagnostic.facts = {{"actual_shape", {{"kind", "unknown"}}}}; + EXPECT_EQ(diagnostic.ToJson().at("facts"), diagnostic.facts); + EXPECT_FALSE(diagnostic.ToJson().contains("remediation")); +} + TEST_F(PortShapeTest, ItemWiseNodeAcceptsSplitItems) { const auto report = Validate({Split("split", "parts"), @@ -440,6 +452,22 @@ TEST_F(PortShapeTest, SplitItemsCannotReachOnePerRequestEgress) { EXPECT_EQ(errors[0].port, "result"); EXPECT_EQ(errors[0].related_nodes, std::vector({"$egress"})); EXPECT_NE(errors[0].message.find("'split.flow'"), std::string::npos); + EXPECT_FALSE(errors[0].remediation.has_value()); + const nlohmann::json declaration = {{"type_id", "TextBatch"}, + {"cardinality", "1:1"}, + {"provenance_policy", "preserve"}, + {"lifetime", "request"}}; + EXPECT_EQ(errors[0].facts.at("producer_id"), "pair"); + EXPECT_EQ(errors[0].facts.at("consumer_id"), "$egress"); + EXPECT_EQ(errors[0].facts.at("bound_key"), "result"); + EXPECT_EQ(errors[0].facts.at("actual"), declaration); + EXPECT_EQ(errors[0].facts.at("expected"), declaration); + EXPECT_EQ(errors[0].facts.at("actual_shape"), + (nlohmann::json{{"kind", "multi"}, {"origin", "split.flow"}})); + EXPECT_EQ(errors[0].facts.at("expected_shape"), + (nlohmann::json{{"kind", "per_request"}})); + EXPECT_EQ(errors[0].ToJson().at("facts"), errors[0].facts); + EXPECT_FALSE(errors[0].ToJson().contains("remediation")); } TEST_F(PortShapeTest, CollectionEgressAcceptsSplitItems) { @@ -472,6 +500,18 @@ TEST_F(PortShapeTest, MisalignedItemWiseInputsAreRejected) { EXPECT_EQ(errors[0].node_id, "pair"); EXPECT_EQ(errors[0].port, "right"); EXPECT_EQ(errors[0].related_nodes, std::vector({"split"})); + EXPECT_FALSE(errors[0].remediation.has_value()); + EXPECT_EQ(errors[0].facts.at("producer_id"), "split"); + EXPECT_EQ(errors[0].facts.at("consumer_id"), "pair"); + EXPECT_EQ(errors[0].facts.at("bound_key"), "parts"); + EXPECT_EQ(errors[0].facts.at("anchor_port"), "left"); + EXPECT_EQ(errors[0].facts.at("anchor_key"), "request"); + EXPECT_EQ(errors[0].facts.at("actual").at("cardinality"), "1:N"); + EXPECT_EQ(errors[0].facts.at("expected").at("cardinality"), "1:1"); + EXPECT_EQ(errors[0].facts.at("actual_shape"), + (nlohmann::json{{"kind", "multi"}, {"origin", "split.flow"}})); + EXPECT_EQ(errors[0].facts.at("expected_shape"), + (nlohmann::json{{"kind", "per_request"}})); } TEST_F(PortShapeTest, ItemWiseInputsPairOnlyWithinOneFanOut) { @@ -494,6 +534,132 @@ TEST_F(PortShapeTest, ItemWiseInputsPairOnlyWithinOneFanOut) { const auto errors = CardinalityErrors(different_origins); ASSERT_EQ(errors.size(), 1u) << different_origins.ToJson().dump(); EXPECT_EQ(errors[0].port, "right"); + EXPECT_FALSE(errors[0].remediation.has_value()); + EXPECT_EQ(errors[0].facts.at("producer_id"), "split_b"); + EXPECT_EQ(errors[0].facts.at("consumer_id"), "join"); + EXPECT_EQ(errors[0].facts.at("bound_key"), "parts_b"); + EXPECT_EQ(errors[0].facts.at("anchor_port"), "left"); + EXPECT_EQ(errors[0].facts.at("anchor_key"), "parts_a"); + EXPECT_EQ(errors[0].facts.at("actual_shape"), + (nlohmann::json{{"kind", "multi"}, {"origin", "split_b.flow"}})); + EXPECT_EQ(errors[0].facts.at("expected_shape"), + (nlohmann::json{{"kind", "multi"}, {"origin", "split_a.flow"}})); +} + +TEST_F(PortShapeTest, + DollarPrefixedConsumerIdsKeepNodeInputDiagnosticLocation) { + for (const std::string id : + {"$consumer", "$egress", "$io_output", "$ingress"}) { + SCOPED_TRACE(id); + const auto shape_report = + Validate({Split("split", "parts"), + Node(id, ItemPairNode::kNodeType, + {{"left", "request"}, {"right", "parts"}}, + {{"paired", "paired_parts"}})}); + const auto shape_errors = CardinalityErrors(shape_report); + ASSERT_EQ(shape_errors.size(), 1u) << shape_report.ToJson().dump(); + const auto& shape = shape_errors[0]; + EXPECT_EQ(shape.node_id, id); + EXPECT_EQ(shape.related_nodes, std::vector({"split"})); + EXPECT_EQ(shape.path, "/pipeline/1/inputs/right"); + EXPECT_EQ(shape.port, "right"); + EXPECT_EQ(shape.facts.at("producer_id"), "split"); + EXPECT_EQ(shape.facts.at("consumer_id"), id); + EXPECT_EQ(shape.facts.at("bound_key"), "parts"); + EXPECT_FALSE(shape.remediation.has_value()); + + const auto flow_report = + Validate({Split("split", "parts"), + Node(id, FlowContractConsumerNode::kNodeType, + {{"flow", "parts"}}, nlohmann::json::object())}); + ASSERT_EQ(flow_report.diagnostics.size(), 2u) + << flow_report.ToJson().dump(); + for (const auto& flow : flow_report.diagnostics) { + EXPECT_TRUE(flow.code == DiagnosticCode::kPortProvenanceMismatch || + flow.code == DiagnosticCode::kPortLifetimeMismatch); + EXPECT_EQ(flow.node_id, id); + EXPECT_EQ(flow.related_nodes, std::vector({"split"})); + EXPECT_EQ(flow.path, "/pipeline/1/inputs/flow"); + EXPECT_EQ(flow.port, "flow"); + EXPECT_EQ(flow.facts.at("producer_id"), "split"); + EXPECT_EQ(flow.facts.at("consumer_id"), id); + EXPECT_EQ(flow.facts.at("bound_key"), "parts"); + EXPECT_FALSE(flow.remediation.has_value()); + } + } +} + +TEST_F(PortShapeTest, IoInputMultiShapeReportsNeutralIngressFacts) { + PipelineIoBoundary boundary; + boundary.input_published_ports = {BizPortDefinition{ + "request", "TextBatch", true, "1:N", "generate_sub_id"}}; + const auto report = PipelineValidator::Validate( + {{"biz_name", kBiz}, + {"pipeline", + {Node("probe", PlanTestNode::kNodeType, nlohmann::json::object(), + nlohmann::json::object())}}}, + &boundary); + const auto errors = CardinalityErrors(report); + ASSERT_EQ(errors.size(), 1u) << report.ToJson().dump(); + const auto& diagnostic = errors[0]; + EXPECT_EQ(diagnostic.path, "/io/input"); + EXPECT_EQ(diagnostic.node_id, "$io_input"); + EXPECT_EQ(diagnostic.related_nodes, std::vector({"$ingress"})); + EXPECT_EQ(diagnostic.port, "request"); + EXPECT_EQ(diagnostic.facts.at("producer_id"), "$io_input"); + EXPECT_EQ(diagnostic.facts.at("consumer_id"), "$ingress"); + EXPECT_EQ(diagnostic.facts.at("bound_key"), "request"); + EXPECT_EQ(diagnostic.facts.at("actual"), + (nlohmann::json{{"type_id", "TextBatch"}, + {"cardinality", "1:N"}, + {"provenance_policy", "generate_sub_id"}, + {"lifetime", "request"}})); + EXPECT_EQ(diagnostic.facts.at("expected"), + (nlohmann::json{{"type_id", "TextBatch"}, + {"cardinality", "1:1"}, + {"provenance_policy", "preserve"}, + {"lifetime", "request"}})); + EXPECT_EQ( + diagnostic.facts.at("actual_shape"), + (nlohmann::json{{"kind", "multi"}, {"origin", "$ingress.request"}})); + EXPECT_EQ(diagnostic.facts.at("expected_shape"), + (nlohmann::json{{"kind", "per_request"}})); + EXPECT_FALSE(diagnostic.remediation.has_value()); + EXPECT_EQ(diagnostic.ToJson().at("facts"), diagnostic.facts); + EXPECT_FALSE(diagnostic.ToJson().contains("remediation")); +} + +TEST_F(PortShapeTest, IngressPassthroughReportsNeutralIoOutputFacts) { + PipelineIoBoundary boundary; + boundary.input_published_ports = {BizPortDefinition{ + "request", "TextBatch", true, "1:N", "generate_sub_id"}}; + boundary.output_consumed_ports = { + BizPortDefinition{"request", "TextBatch", true}}; + const auto report = PipelineValidator::Validate( + {{"biz_name", kBiz}, + {"pipeline", + {Node("probe", PlanTestNode::kNodeType, nlohmann::json::object(), + nlohmann::json::object())}}}, + &boundary); + const auto errors = CardinalityErrors(report); + const auto output = + std::find_if(errors.begin(), errors.end(), + [](const auto& d) { return d.path == "/io/output"; }); + ASSERT_NE(output, errors.end()) << report.ToJson().dump(); + EXPECT_EQ(output->node_id, "$ingress"); + EXPECT_EQ(output->related_nodes, std::vector({"$io_output"})); + EXPECT_EQ(output->port, "request"); + EXPECT_EQ(output->facts.at("producer_id"), "$ingress"); + EXPECT_EQ(output->facts.at("consumer_id"), "$io_output"); + EXPECT_EQ(output->facts.at("bound_key"), "request"); + EXPECT_EQ(output->facts.at("actual").at("type_id"), "TextBatch"); + EXPECT_EQ(output->facts.at("expected").at("type_id"), "TextBatch"); + EXPECT_EQ( + output->facts.at("actual_shape"), + (nlohmann::json{{"kind", "multi"}, {"origin", "$ingress.request"}})); + EXPECT_EQ(output->facts.at("expected_shape"), + (nlohmann::json{{"kind", "per_request"}})); + EXPECT_FALSE(output->remediation.has_value()); } TEST_F(PortShapeTest, UnknownShapeIsLeftToRuntimeChecks) { diff --git a/tests/unit/nodes/test_function_node.cpp b/tests/unit/nodes/test_function_node.cpp index 3ce0ba6c..10213a5a 100644 --- a/tests/unit/nodes/test_function_node.cpp +++ b/tests/unit/nodes/test_function_node.cpp @@ -189,6 +189,40 @@ auto UpperSpec() { REGISTER_FUNCTION_NODE(UpperMapNode, UpperSpec()); +int move_only_map_calls = 0; +int move_only_map_instances = 0; + +// The callable owns its immutable resource; parameters remain copyable. +auto MoveOnlyMapSpec() { + auto transform = + [owned = std::make_unique( + "owned:" + std::to_string(++move_only_map_instances) + ":")]( + const std::string& input, const CleanParams& params) { + ++move_only_map_calls; + return params.prefix + *owned + input; + }; + static_assert(!std::is_copy_constructible_v); + return MakeMapSpec(Input("input"), Output("output"), + CleanConfig(), std::move(transform)) + .WithControls({PatchFields(3005, "patch_prefix", {"prefix"})}); +} +REGISTER_FUNCTION_NODE(MoveOnlyMapNode, MoveOnlyMapSpec()); + +auto MoveOnlyResultMapSpec() { + auto transform = [owned = std::make_unique("result:")]( + const std::string& input) -> NodeResult { + if (input == "FAIL") { + return NodeResult::Failure( + NodeErrorKind::kBusinessError, "move-only callback failure", -9998); + } + return NodeResult::Success(*owned + input); + }; + static_assert(!std::is_copy_constructible_v); + return MakeMapSpec(Input("input"), Output("output"), + std::move(transform)); +} +REGISTER_FUNCTION_NODE(MoveOnlyResultMapNode, MoveOnlyResultMapSpec()); + // --------------------------------------------------------------------------- // Batch Fixtures // --------------------------------------------------------------------------- @@ -943,6 +977,87 @@ void ExpectModelCallContract(const char* default_slot) { EXPECT_TRUE(named.ModelId().empty()); } +TEST(FunctionNodeTest, MoveOnlyMapCallbackOwnsResourceAndPreservesProvenance) { + const TextBatch input = { + {1001, 3, "first"}, {1001, 8, "second"}, {2002, 5, "third"}}; + NodeHarness first("MoveOnlyMapNode"); + first.TextInputWithBatch("input", input); + const auto result = first.Run(); + ASSERT_TRUE(result.ok()) << result.diagnostic(); + const auto* output = result.Output("output"); + ASSERT_NE(output, nullptr); + ASSERT_EQ(output->size(), input.size()); + const auto prefix = (*output)[0].data.substr( + 0, (*output)[0].data.size() - input[0].data.size()); + EXPECT_EQ(prefix.find("owned:"), 0u); + for (size_t i = 0; i < input.size(); ++i) { + EXPECT_EQ((*output)[i].req_id, input[i].req_id); + EXPECT_EQ((*output)[i].sub_id, input[i].sub_id); + EXPECT_EQ((*output)[i].data, prefix + input[i].data); + } + + NodeHarness second("MoveOnlyMapNode"); + second.TextInputWithBatch("input", input); + const auto independent = second.Run(); + ASSERT_TRUE(independent.ok()) << independent.diagnostic(); + EXPECT_NE(independent.TextValues("output"), result.TextValues("output")); + + const auto repeated = first.Run(); + ASSERT_TRUE(repeated.ok()) << repeated.diagnostic(); + EXPECT_EQ(repeated.TextValues("output"), result.TextValues("output")); + const auto* original = result.Context()->Read("bk_in_input"); + ASSERT_NE(original, nullptr); + ASSERT_EQ(original->size(), input.size()); + for (size_t i = 0; i < input.size(); ++i) { + EXPECT_EQ((*original)[i].data, input[i].data); + } +} + +TEST(FunctionNodeTest, MoveOnlyMapCallbackSkipsEmptyBatchAndSurvivesControl) { + NodeHarness harness("MoveOnlyMapNode"); + harness.TextInput("input", {"hello"}); + const auto initial = harness.Run(); + ASSERT_TRUE(initial.ok()) << initial.diagnostic(); + const auto initial_values = initial.TextValues("output"); + const int calls_before_empty = move_only_map_calls; + harness.TextInput("input", {}); + const auto empty = harness.Run(); + ASSERT_TRUE(empty.ok()) << empty.diagnostic(); + ASSERT_NE(empty.Output("output"), nullptr); + EXPECT_TRUE(empty.TextValues("output").empty()); + EXPECT_EQ(move_only_map_calls, calls_before_empty); + + const auto control = harness.Control(3005, R"({"prefix":"patched:"})"); + ASSERT_EQ(control.status, NodeControlStatus::kHandled) << control.message; + harness.TextInput("input", {"hello"}); + const auto patched = harness.Run(); + ASSERT_TRUE(patched.ok()) << patched.diagnostic(); + EXPECT_EQ(patched.TextValues("output"), + (std::vector{"patched:" + initial_values[0]})); +} + +TEST(FunctionNodeTest, MoveOnlyMapCallbackSupportsNodeResultAndFailure) { + NodeHarness harness("MoveOnlyResultMapNode"); + const TextBatch input = {{111, 4, "a"}, {222, 9, "b"}}; + harness.TextInputWithBatch("input", input); + const auto success = harness.Run(); + ASSERT_TRUE(success.ok()) << success.diagnostic(); + const auto* output = success.Output("output"); + ASSERT_NE(output, nullptr); + ASSERT_EQ(output->size(), input.size()); + for (size_t i = 0; i < input.size(); ++i) { + EXPECT_EQ((*output)[i].req_id, input[i].req_id); + EXPECT_EQ((*output)[i].sub_id, input[i].sub_id); + EXPECT_EQ((*output)[i].data, "result:" + input[i].data); + } + + harness.TextInput("input", {"a", "FAIL", "b"}); + const auto failure = harness.Run(); + EXPECT_FALSE(failure.ok()); + EXPECT_EQ(failure.process_code(), -9998); + EXPECT_EQ(failure.Output("output"), nullptr); +} + TEST(FunctionNodeTest, ModelCallsShareOwnershipContract) { ExpectModelCallContract("generator"); ExpectModelCallContract("encoder");