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/.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/.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/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 eef29dda..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&, @@ -137,8 +138,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/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/doc/CHANGELOG.md b/doc/CHANGELOG.md index a7ff02cd..7cb8d6e4 100644 --- a/doc/CHANGELOG.md +++ b/doc/CHANGELOG.md @@ -2,6 +2,46 @@ ## Unreleased +架构审查回归修复:Map 的回调通过移动交给运行时,支持捕获 `unique_ptr` 等不可复制状态; +流契约错误由 Validator 提供生产者、消费者、有效端口契约与推导出的数量形状,CLI 据此解释 +节点输入、业务出口及 IO 边界错误,保留拆分来源并正确区分逐项配对与出口数量要求。 +`ConfigValueOrDefault` 始终核对字段声明,拒绝数组和标量配置,`null` 仍按省略配置读取声明默认值。 + +源码扩展接口整理(随架构审查落地,不涉及 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 的 +`validate` / `--explain` 继续提供修复建议;流契约诊断与建议的 `facts` 包含有效声明, +数量错误还附上 Validator 已推导的数量形状。 + +公开错误码: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` 改由输出转换器序列化, +字段、类型、默认命中与字节内容保持不变。 + +端口数量关系:输入端口的 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/business_onboarding.md b/doc/dev_guide/business_onboarding.md index 89a3a863..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` 注册 | @@ -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/doc/dev_guide/custom_node_concepts.md b/doc/dev_guide/custom_node_concepts.md index 00f5b6be..3f8f560e 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 不加载模型文件或创建厂商运行时。 下面几个字段承担不同职责: @@ -190,6 +191,34 @@ 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) 仍独立检查。 + +流契约诊断的 `facts` 给出生产者、消费者、实际数据名及有效端口声明;数量错误还给出 +`actual_shape` / `expected_shape`,并在多项形状中保留拆分来源。`actual` / `expected` 的 +cardinality 是端口自身的声明,沿拆分传递的逐项输出仍可能声明 `1:1`,实际形状须看 +`actual_shape`。CLI 使用这些中性事实生成说明,不重复推导数量关系。 + ## 启用并行或共享资源时:并发声明 假设两个调用同时使用相同的节点逻辑。函数局部的提示词和结果各自独立;如果把上次 @@ -288,7 +317,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..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`)。 规则不同时显式填写。 以下配置来自参与编译的 @@ -139,7 +140,7 @@ RegisterOperatorValueType(binding); 容量结构的顺序是默认值、最大值。成员声明同时用于配置校验、预算、分配和 重置;标量回调必须 `noexcept`,只重置标量,不能覆盖嵌套指针。输入对应使用 -`MakeTypedInputBinding`,回调直接接收 `const T&` 和 `ResolvedInputLimits`。 +`MakeTypedInputBinding`,回调直接接收 `const T&` 和 `InputLimits`。 完整可执行示例见[输出池测试](../../tests/unit/operator/test_operator_output_pool.cpp)。 特殊嵌套布局的输出方案提供以下行为: @@ -230,9 +231,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/doc/developer_guide.md b/doc/developer_guide.md index 6ca09f6b..6de1967c 100644 --- a/doc/developer_guide.md +++ b/doc/developer_guide.md @@ -169,8 +169,14 @@ Node 不依赖自定义实现。编写、构建和复用步骤见 Model 自注册需实现 `IModel` 的某一强类型能力并声明所需协议;Backend 实现 `IInferenceBackend` 并只返回中性 `IBackendSession`。二者分别提供完整 `ModelDefinition` / `BackendDefinition` 并使用对应 `REGISTER_*_WITH_DEFINITION` 宏。 - -Model 的字段类型、默认值和范围由 `config_fields` 声明;字段之间或文本内容的额外规则 +Model 继承 [`ModelIdentity`](../include/engine/model_identity.h),只声明 +`kModelType` 与 `kConcurrency`,能力由接口推导;Definition 用 `MakeModelDefinition()` +取得同一身份后再补协议和配置字段。Backend Provider 同样继承 +[`BackendIdentity`](../include/engine/backend_identity.h),Definition 从 +`MakeBackendDefinition()` 开始。创建时工厂仍逐项核对实例、Session 与 Definition。 + +Model 的字段类型、默认值和范围由 `config_fields` 声明,创建时用 `ConfigValueOrDefault` 读取, +默认值只写在声明中;字段之间或文本内容的额外规则 放在可选的 `ModelDefinition::validate_config` 中。Validator 与 ModelRuntimeFactory 均先完成字段校验和默认值补齐,再调用此纯函数;失败时不创建 Backend 或加载模型。 该函数只检查配置,不读文件或创建会话。直接 Model 创建入口应复用相同语义检查; 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/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/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.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/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/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/include/contracts/config_schema.h b/include/contracts/config_schema.h index 55c3f6c6..6c9bb954 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,27 @@ 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. 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() && !config.is_null()) { + throw std::invalid_argument("Configuration must be an object or null"); + } + for (const auto& field : fields) { + 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); +} + } // namespace llm_edgeflow 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/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/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/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/core/pipeline_validator.h b/include/core/pipeline_validator.h index e22d3111..76274a29 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; @@ -45,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; }; @@ -93,10 +98,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/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/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/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_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/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/include/nodes/function_node.h b/include/nodes/function_node.h index a6f84ee1..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; @@ -462,12 +463,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 +731,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 +1377,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 +1511,83 @@ 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, SpecT map) { + using InputBatch = typename SpecT::InputBatch; + using OutputBatch = typename SpecT::OutputBatch; + using ParamsT = typename SpecT::ParametersType; + using Inputs = MapInputs; + auto run = [fn = std::move(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< + MapSpec>()))> { + public: + using SpecType = MapSpec; + using RuntimeSpec = decltype(detail::MakeMapRuntimeSpec( + std::declval(), std::declval())); + + AuthorNode(std::string node_name, SpecType spec) + : AuthorNode( + node_name, detail::MakeMapRuntimeSpec(node_name, std::move(spec))) { + } +}; + // --------------------------------------------------------------------------- // LLM Text Shortcut Spec Factory // --------------------------------------------------------------------------- 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/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/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/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/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/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/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/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_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_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/io_converter_registry.cpp b/src/adapter/io_converter_registry.cpp index 8d3884cb..e8a059f6 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; @@ -11,64 +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); - return false; - } - if (def.external_type.empty()) { - conflict_errors_.push_back( - "Empty external_type 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); @@ -77,12 +82,16 @@ 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; } - 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; } @@ -90,65 +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_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( + 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); @@ -157,12 +156,16 @@ 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; } - 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; } @@ -210,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/adapter/operator/operator_adapter.cpp b/src/adapter/operator/operator_adapter.cpp index 01a54e04..61cbfa4f 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" @@ -27,9 +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; - llm_edgeflow::ResolvedInputLimits input_limits; std::unordered_map> output_pools; @@ -184,8 +182,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. 组装运行时参数 @@ -195,10 +196,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; @@ -207,9 +206,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. 预分配输出内存池 @@ -219,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); @@ -254,9 +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->input_limits = resolved_conf.input_limits; handle_instance->output_pools = std::move(pools); handle_instance->runtime = std::move(runtime); @@ -317,17 +314,21 @@ 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, h->input_limits, &in_view, &in_err); + inputs, *input_converter, llm_edgeflow::InputLimits{}, &in_view, + &in_err); if (in_ret != 0) { SetLastError(in_err); return in_ret; @@ -338,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; @@ -350,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"); @@ -387,15 +387,17 @@ 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. 执行统一输出编码 (将结果写入已租用的外部结构块) 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); @@ -409,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()) { @@ -476,10 +477,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; @@ -593,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_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..0d117b96 100644 --- a/src/adapter/operator/operator_config_resolver.cpp +++ b/src/adapter/operator/operator_config_resolver.cpp @@ -339,13 +339,10 @@ 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)); - 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..46b63edc 100644 --- a/src/adapter/operator/operator_config_resolver.h +++ b/src/adapter/operator/operator_config_resolver.h @@ -19,10 +19,7 @@ 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; - 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_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/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/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..a2e08968 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, @@ -92,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 778a1099..287a5d0c 100644 --- a/src/adapter/output/keyword_result_output.cpp +++ b/src/adapter/output/keyword_result_output.cpp @@ -5,9 +5,9 @@ #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" #include "adapter/result_validation.h" #include "edgeflow/operator/types.h" @@ -22,7 +22,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, @@ -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/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/adapter/output/structured_document_output.cpp b/src/adapter/output/structured_document_output.cpp index b02b4b92..83226b25 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" @@ -44,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/src/adapter/shared_algorithm_runtime.cpp b/src/adapter/shared_algorithm_runtime.cpp index 6959a1ef..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); @@ -146,19 +145,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/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/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/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..8e594374 --- /dev/null +++ b/src/cli/pipeline_remediation.cpp @@ -0,0 +1,845 @@ +#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; +} + +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) { + 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) { + // 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 { + rem.summary = "生产者 '" + producer_id + "' 在数据 '" + + rem.facts.value("bound_key", "") + "' 上提供" + actual + + ",消费者 '" + consumer_id + "' 要求" + expected + "。"; + } + } + 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/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/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/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.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/src/core/pipeline_validator.cpp b/src/core/pipeline_validator.cpp index a9114989..8a8d8a25 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 = {}, @@ -76,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( @@ -101,12 +79,84 @@ 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)}; + } + 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 consumer == "N:1"; + 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) { + 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& 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; + 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, @@ -133,32 +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) { - 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}); - } +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); } } @@ -314,634 +361,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) { @@ -949,9 +368,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(); }; @@ -993,18 +409,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) { @@ -1122,10 +546,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; @@ -1219,9 +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), + it->second, req_in, "/io/input", "$io_input", "$ingress", + req_in.blackboard_key, &report); } } } @@ -1301,12 +732,26 @@ 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; + std::string item_shape_key; ValidatedNodePlan node_plan; node_plan.node = node; @@ -1380,22 +825,58 @@ ValidatedPipelinePlan ValidateAndPlanInternal( input.cardinality, input.provenance_policy, 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)) { const auto& producer = producer_it->second.front(); if (producer.second.type_id == input.type_id) { found = true; - ValidatePortFlowContract(producer.second, input, input_path, id, - input.logical_name, producer.first, &report); + source_id = producer.first; + 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); if (root_port != ingress.end() && root_port->second.type_id == input.type_id) { found = true; - ValidatePortFlowContract(root_port->second, input, input_path, id, - input.logical_name, "$ingress", &report); + source_id = "$ingress"; + 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") { + const KeyShape shape = + found ? shape_of(actual_key) : KeyShape::Unknown(); + 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)) { + 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", + 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(); } } if (!found && biz && !has_only_unresolved_source(actual_key)) { @@ -1499,6 +980,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); @@ -1538,8 +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), producer_port, + consumer, output_path, producer_id, "$egress", + consumer.blackboard_key, &report); } } @@ -1557,9 +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); + 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; } @@ -1583,8 +1075,12 @@ ValidatedPipelinePlan ValidateAndPlanInternal( continue; } ValidatePortFlowContract(producer_port, 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); } } @@ -1684,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(); } @@ -1714,248 +1211,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/src/engine/backends/kite_llm/kite_llm_backend.cpp b/src/engine/backends/kite_llm/kite_llm_backend.cpp index 192239da..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) { @@ -463,11 +468,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 { @@ -513,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)) { @@ -600,32 +601,34 @@ std::shared_ptr KiteLlmBackend::Load( } #ifdef HAVE_KITELLM -static const BackendDefinition kKiteLlmBackendDefinition = [] { - BackendDefinition definition; - definition.backend_type = KiteLlmBackend::kBackendType; - 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/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..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, @@ -551,11 +555,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 { @@ -594,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); @@ -704,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)) { @@ -731,52 +736,55 @@ std::shared_ptr OnnxRuntimeBackend::Load( } #ifdef HAVE_ONNXRUNTIME -static const BackendDefinition kOnnxRuntimeBackendDefinition = [] { - BackendDefinition def; - def.backend_type = OnnxRuntimeBackend::kBackendType; - 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/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; diff --git a/src/engine/models/bge_embedding/bge_embedding_model.cpp b/src/engine/models/bge_embedding/bge_embedding_model.cpp index f77f6f4b..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) || @@ -192,20 +204,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, @@ -404,80 +402,82 @@ int BgeEmbeddingModel::RawEmbedSlice( } } -static const ModelDefinition kBgeEmbeddingModelDefinition = [] { - ModelDefinition def; - def.model_type = BgeEmbeddingModel::kModelType; - def.capability = BgeEmbeddingModel::kCapability; - 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, - 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_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..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"; @@ -137,20 +149,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 { @@ -315,70 +313,72 @@ int BgeRerankerModel::RawScoreSlice(const QueryCandidatesBatch& all_inputs, } } -static const ModelDefinition kBgeRerankerModelDefinition = [] { - ModelDefinition def; - def.model_type = BgeRerankerModel::kModelType; - def.capability = BgeRerankerModel::kCapability; - 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, - 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/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..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"); @@ -54,17 +65,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, @@ -146,70 +146,71 @@ int GeneratedTextEmbeddingModel::Embed(const TextBatch& inputs, outputs, diagnostic); } -static const ModelDefinition kGeneratedTextEmbeddingDefinition = [] { - ModelDefinition definition; - definition.model_type = "generated_text_embedding"; - definition.capability = "embedding"; - 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, - 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/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..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; @@ -78,20 +85,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; @@ -181,44 +174,45 @@ int QwenCausalLmModel::GenerateOne(const TraceableItem& prompt, } } -static const ModelDefinition kQwenCausalLmModelDefinition = [] { - ModelDefinition definition; - definition.model_type = QwenCausalLmModel::kModelType; - definition.capability = QwenCausalLmModel::kCapability; - 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, - 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/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..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) { @@ -59,17 +71,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 { @@ -114,51 +115,51 @@ int VisionDocumentModel::Recognize(const ImageRefBatch& images, outputs, diagnostic); } -static const ModelDefinition kVisionDocumentDefinition = [] { - ModelDefinition definition; - definition.model_type = "vision_document"; - definition.capability = "ocr"; - 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", - 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/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..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); @@ -71,20 +77,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(); @@ -198,42 +190,42 @@ int WhisperAsrModel::Transcribe(const AudioPcmBatch& audio, TextBatch* outputs, } } -static const ModelDefinition kWhisperAsrModelDefinition = [] { - ModelDefinition definition; - definition.model_type = "whisper_asr"; - definition.capability = "asr"; - 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, - 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/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; 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/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 63d7d4cf..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) @@ -215,7 +216,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/contract/abi/test_adapter_contract_security.cpp b/tests/contract/abi/test_adapter_contract_security.cpp index b2c6c7bf..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" @@ -100,6 +99,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 +279,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 = "你好"; @@ -1311,4 +1337,166 @@ 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"); +} + +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/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/demo/test_demo_runner.cpp b/tests/integration/demo/test_demo_runner.cpp index e47ba851..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&, @@ -47,7 +48,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/integration/operator/test_operator_api.cpp b/tests/integration/operator/test_operator_api.cpp index c53f2836..50d2e322 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 : @@ -1018,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 @@ -2373,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) { @@ -2604,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); } diff --git a/tests/integration/pipeline/test_pipeline_catalog_validator.cpp b/tests/integration/pipeline/test_pipeline_catalog_validator.cpp index f4d27a3e..706c00cf 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; @@ -1133,31 +1130,121 @@ 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 = 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 = ValidateWithRemediation(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"); + 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); +} - const auto report = PipelineValidator::Validate(root); +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 = ValidateWithRemediation(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 +1255,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"); @@ -1184,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, @@ -1205,7 +1300,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/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. 关键词库运行时动态热更新与立即生效测试 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/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/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/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(); diff --git a/tests/unit/adapter/test_io_converters.cpp b/tests/unit/adapter/test_io_converters.cpp index bbc32df4..0ff692c0 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) { @@ -136,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 = ""; @@ -164,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)); diff --git a/tests/unit/core/test_definition_schema_validation.cpp b/tests/unit/core/test_definition_schema_validation.cpp index a1bf821b..cca0dccf 100644 --- a/tests/unit/core/test_definition_schema_validation.cpp +++ b/tests/unit/core/test_definition_schema_validation.cpp @@ -1137,3 +1137,59 @@ 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); +} + +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_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..4ca08484 100644 --- a/tests/unit/core/test_node_base_contracts.cpp +++ b/tests/unit/core/test_node_base_contracts.cpp @@ -11,12 +11,14 @@ #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" #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" @@ -242,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 { @@ -306,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&, @@ -336,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 = @@ -345,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(); } @@ -561,8 +564,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/core/test_validated_pipeline_plan.cpp b/tests/unit/core/test_validated_pipeline_plan.cpp index 56748c3b..1ef9d214 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,303 @@ 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(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"), + 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); + 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) { + 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"})); + 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) { + 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"); + 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) { + 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, 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 f09d6ef2..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_; @@ -232,10 +234,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 +269,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 @@ -580,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); @@ -896,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"); @@ -951,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"}}, @@ -1059,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"}, @@ -1105,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; @@ -1146,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}, @@ -1182,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}}"}) { @@ -1219,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); @@ -1255,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; @@ -1338,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; @@ -1365,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_function_node.cpp b/tests/unit/nodes/test_function_node.cpp index 56cdd651..10213a5a 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" @@ -159,6 +160,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; @@ -174,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 // --------------------------------------------------------------------------- @@ -913,6 +962,110 @@ 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, 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"); + ExpectModelCallContract("transcriber"); + ExpectModelCallContract("detector"); + ExpectModelCallContract("reranker"); +} + TEST(FunctionNodeTest, EmptyBatchReturnsEmptyWithoutCallingFunction) { NodeHarness harness("CleanTextMapNode"); harness.TextInput("input", {}); @@ -934,6 +1087,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 +1745,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); 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); } 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")); } } }; 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) 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);