diff --git a/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/com/wanxiang/huijing/game/module/aigc/enums/AigcConfigTierEnum.java b/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/com/wanxiang/huijing/game/module/aigc/enums/AigcConfigTierEnum.java index f632ef61..37e262d2 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/com/wanxiang/huijing/game/module/aigc/enums/AigcConfigTierEnum.java +++ b/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/com/wanxiang/huijing/game/module/aigc/enums/AigcConfigTierEnum.java @@ -23,7 +23,16 @@ public enum AigcConfigTierEnum { /** 便宜档:AI 轻参与的高质轻量游戏(cheap-worker,MiniMax-M3 路,软停线 cheap_rmb_hard_limit=¥10) */ CHEAP("cheap", "便宜档"), /** 富游戏档:AI 深参与的自治富游戏(tier2 gen-worker,Phaser,软停线 rmb_hard_limit=¥50) */ - TIER2("tier2", "富游戏档"); + TIER2("tier2", "富游戏档"), + /** + * 质量评分档(W-CFG-EXT · 跨档特例):承载 D11 就绪评分参数(四维权重 + 效率预算阈值 + 稳定性两级阈值 + * + 中性子项分),同时给便宜档/富游戏档的游戏打分,不属任何生成档。无 Nacos dataId、无路 A/路 B 外投影—— + * 消费方 {@link com.wanxiang.huijing.game.module.aigc.service.task.ReadinessScorer} 就在 game-cloud、与配置集账本 + * 同库同进程,由 ReadinessConfigProvider 进程内读激活版本行的 content_json.readiness;激活退化成「值域 sanity + + * 账本指针前移」的纯账本收口。故其配置集内容与 routeA/routeB 互斥(codec 激活前置校验),也不参与按 cheap/tier2 + * 拼 Nacos dataId 的下发逻辑。 + */ + READINESS("readiness", "质量评分"); /** 档位码(落库 tier 列) */ private final String tier; diff --git a/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/com/wanxiang/huijing/game/module/aigc/enums/ErrorCodeConstants.java b/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/com/wanxiang/huijing/game/module/aigc/enums/ErrorCodeConstants.java index e4f95ebc..545d07b9 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/com/wanxiang/huijing/game/module/aigc/enums/ErrorCodeConstants.java +++ b/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/com/wanxiang/huijing/game/module/aigc/enums/ErrorCodeConstants.java @@ -91,7 +91,7 @@ public interface ErrorCodeConstants { /** 值域 sanity 校验不通过:负预算/空模型名/阈值出域/max_tokens≤thinking/chat_model_config 不完整等(设计 §3.6,激活前置,拒绝不发任何一路)。占位符带明细。 */ ErrorCode AIGC_CONFIG_ACTIVATE_SANITY_FAILED = new ErrorCode(1_101_004_009, "配置值域校验不通过:{}"); /** 激活内容缺路由:content_json 未声明可激活的 routeA/routeB(无落点可下发)。 */ - ErrorCode AIGC_CONFIG_ACTIVATE_NO_ROUTE = new ErrorCode(1_101_004_010, "配置内容未声明任何可激活的下发路由(routeA/routeB 均缺)"); + ErrorCode AIGC_CONFIG_ACTIVATE_NO_ROUTE = new ErrorCode(1_101_004_010, "配置内容未声明任何可激活的下发路由(routeA/routeB/readiness 均缺)"); /** 双路下发失败:某路 PATCH/publish 失败 → 整批判未生效、已把成功路补偿重推回上一激活版、指针不前移、可重试(设计 §3.6)。占位符带明细。 */ ErrorCode AIGC_CONFIG_ACTIVATE_DISPATCH_FAILED = new ErrorCode(1_101_004_011, "配置激活下发失败(已判未生效并补偿回上一激活版,可重试):{}"); diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigActivationPayload.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigActivationPayload.java index 232c0fb5..21804bd2 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigActivationPayload.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigActivationPayload.java @@ -10,8 +10,10 @@ package com.wanxiang.huijing.game.module.aigc.service.config; *
  • 路 B({@link #pathBJson},可空):publish 进 Nacos 生效 dataId 的 {@code area.key} 扁平 JSON(预算/门阈值)。
  • * * - *

    两路都可空(但不能都空——那样没有可激活的落点):纯参数档可能只有路 B,纯 prompt/模型档可能只有路 A。是否下发某路 - * 由该路是否为空决定({@link #hasPathA()} / {@link #hasPathB()})。 + *

    两路可各自为空:纯参数档可能只有路 B,纯 prompt/模型档可能只有路 A。是否下发某路由该路是否为空决定 + * ({@link #hasPathA()} / {@link #hasPathB()})。两路皆空 = 空载荷:这是 W-CFG-EXT 质量评分配置集的合法形态 + * (readiness 块进程内消费、无外投影),激活时现有 dispatch/reconcile 天然短路成纯账本收口;生成配置集(routeA/routeB) + * 则至少一路非空(codec 对既无路由又无 readiness 的空内容抛 NO_ROUTE)。 * * @param pathA 路 A 载荷(PATCH /agent+/session 的目标与请求体);null = 本版本不下发路 A * @param pathBJson 路 B 载荷(area.key 扁平 JSON 文本,整内容 publish);null = 本版本不下发路 B diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigContentCodec.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigContentCodec.java index 56728afe..8fc06126 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigContentCodec.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigContentCodec.java @@ -44,6 +44,23 @@ import static com.wanxiang.huijing.framework.common.exception.util.ServiceExcept * 预算为正且落合理区间、超时有下限、system_prompt 非空。deeper 的「type 与 credential type 匹配」由 Service PATCH 侧 * {@code _ensure_credential_exists} 运行时兜(凭据在 Service 存储、game-cloud 侧看不到),失败即路 A 下发失败→补偿。 * + *

    第三块 readiness(W-CFG-EXT · D11 质量评分参数,与 routeA/routeB 互斥):除按下发路由分的 routeA/routeB 外, + * content_json 可含一个平级的 {@code readiness} 块,承载 D11 就绪评分的十个标量(四维权重 + 中性分 + 效率预算阈值 + * + 稳定性两级阈值与两级分)。它是跨档、进程内消费、无外投影的特例——不下发路 A/路 B,由 game-cloud 进程内的 + * {@code ReadinessConfigProvider} 直读激活版本行。故本编解码器对只含 readiness 的配置集产出空载荷 + * (pathA=pathB=null),现有 dispatch/reconcile 在空载荷下天然短路成「值域 sanity + 账本收口、无投影、恒一致」。 + * 字段名与默认起步值 = {@link com.wanxiang.huijing.game.module.aigc.service.task.ReadinessScorer} 的回落常量,契约: + *

    + * { "readiness": {
    + *     "playability_weight": 0.50, "first_play_weight": 0.25,
    + *     "stability_weight": 0.15, "efficiency_weight": 0.10,
    + *     "neutral_score": 0.5, "efficiency_budget_rmb": 0.6,
    + *     "stability_repairs_good_max": 0, "stability_repairs_ok_max": 2,
    + *     "stability_ok_score": 0.7, "stability_bad_score": 0.4 } }
    + * 
    + * 激活前置 readiness 值域 sanity(不过即拒):四维权重各 ∈[0,1] 且和 ≈1.0(±0.001)、中性/稳定性两级分 ∈[0,1]、 + * 效率预算 > 0、修复轮 good_max ≤ ok_max;并与 routeA/routeB 互斥(readiness 存在时禁二者,反之亦然)。 + * * @author 绘境AI */ @Slf4j @@ -70,18 +87,96 @@ public final class GenConfigContentCodec { JsonNode root = parseRoot(contentJson); JsonNode routeA = root.get("routeA"); JsonNode routeB = root.get("routeB"); + JsonNode readiness = root.get(ReadinessConfigKeys.BLOCK); boolean hasA = routeA != null && routeA.isObject(); boolean hasB = routeB != null && routeB.isObject() && routeB.size() > 0; - if (!hasA && !hasB) { + boolean hasReadiness = readiness != null && readiness.isObject(); + if (!hasA && !hasB && !hasReadiness) { // 无任何可下发路由:没有落点(设计 §4 步骤3 交付以「双路下发」为核心,至少一路) throw exception(AIGC_CONFIG_ACTIVATE_NO_ROUTE); } + // 互斥(W-CFG-EXT):质量评分块是跨档、进程内消费、无外投影,不与生成下发路(routeA/routeB)共存于同一配置集。 + // 若一个质量评分配置集误混 routeB,激活会按 tier=readiness 拼出伪 dataId(resolveDataId 不认档但仍拼)真 publish; + // 误混 routeA 则 resolveServiceUrl 返 null 下发失败。故激活前置拒,让 enum-value 建模对既有按档下发逻辑安全。 + if (hasReadiness && (hasA || hasB)) { + throw sanity("readiness 块与 routeA/routeB 互斥:质量评分配置集不下发 prompt/模型/预算/门阈值,不得混配生成路"); + } + if (hasReadiness) { + // 质量评分配置集:只做值域 sanity,不产任何外投影载荷。空载荷(pathA=pathB=null)→ 现有 dispatch/reconcile + // 天然短路成「sanity + 账本收口、无投影、恒一致」(激活编排一行不改,见 GenConfigActivationServiceImpl.dispatch)。 + validateReadiness(readiness); + return new GenConfigActivationPayload(null, null); + } GenConfigActivationPayload.PathA pathA = hasA ? buildPathA(prompt, routeA) : null; String pathBJson = hasB ? buildPathB(routeB) : null; return new GenConfigActivationPayload(pathA, pathBJson); } + // ==================== readiness:D11 质量评分参数 值域 sanity(W-CFG-EXT) ==================== + + /** + * 校验 content_json.readiness 块值域(激活前置,不过即拒):四维权重各 ∈[0,1] 且和 ≈1.0(容差 ±0.001)、 + * 中性分与稳定性两级分 ∈[0,1]、效率预算 > 0、修复轮 good_max ≤ ok_max。字段全须齐(缺即拒)——一版完整的 D11 + * 参数集才准激活;运行期个别字段缺失的逐字段回落是 ReadinessConfigProvider 对陈旧/异常激活版的防御,不放宽本处 + * 「新激活须完整」的闸(两个时刻:新激活须完整过闸,已激活版遇 schema 增长靠 provider 逐字段回落存活)。 + * 字段值域与默认起步值语义见 ReadinessScorer 各回落常量。 + */ + private void validateReadiness(JsonNode r) { + double wPlay = requireUnit(r, ReadinessConfigKeys.PLAYABILITY_WEIGHT); + double wFirst = requireUnit(r, ReadinessConfigKeys.FIRST_PLAY_WEIGHT); + double wStab = requireUnit(r, ReadinessConfigKeys.STABILITY_WEIGHT); + double wEff = requireUnit(r, ReadinessConfigKeys.EFFICIENCY_WEIGHT); + double sum = wPlay + wFirst + wStab + wEff; + if (Math.abs(sum - 1.0) > 0.001) { + throw sanity(String.format("readiness 四维权重和=%.4f 偏离 1.0(容差 ±0.001)", sum)); + } + // 中性分 + 稳定性两级分:∈[0,1] + requireUnit(r, ReadinessConfigKeys.NEUTRAL_SCORE); + requireUnit(r, ReadinessConfigKeys.STABILITY_OK_SCORE); + requireUnit(r, ReadinessConfigKeys.STABILITY_BAD_SCORE); + // 效率维预算阈值:须为正 + double budget = requireNumber(r, ReadinessConfigKeys.EFFICIENCY_BUDGET_RMB); + if (budget <= 0) { + throw sanity("readiness." + ReadinessConfigKeys.EFFICIENCY_BUDGET_RMB + "=" + budget + " 须为正"); + } + // 稳定性修复轮阈值:good_max ≤ ok_max(整数) + int goodMax = requireInt(r, ReadinessConfigKeys.STABILITY_REPAIRS_GOOD_MAX); + int okMax = requireInt(r, ReadinessConfigKeys.STABILITY_REPAIRS_OK_MAX); + if (goodMax > okMax) { + throw sanity(String.format("readiness.%s(%d) 须 <= %s(%d)", + ReadinessConfigKeys.STABILITY_REPAIRS_GOOD_MAX, goodMax, + ReadinessConfigKeys.STABILITY_REPAIRS_OK_MAX, okMax)); + } + } + + /** 取必填数值字段(缺失/非数值即 sanity 拒)。 */ + private double requireNumber(JsonNode r, String field) { + JsonNode v = r.get(field); + if (v == null || !v.isNumber()) { + throw sanity("readiness." + field + " 缺失或非数值"); + } + return v.asDouble(); + } + + /** 取必填单位区间 [0,1] 字段(缺失/非数值/越界即拒)。 */ + private double requireUnit(JsonNode r, String field) { + double v = requireNumber(r, field); + if (v < 0 || v > 1) { + throw sanity("readiness." + field + "=" + v + " 越界 [0,1]"); + } + return v; + } + + /** 取必填整数字段(缺失/非整数即拒;浮点如 2.5 也拒——修复轮阈值是整数)。 */ + private int requireInt(JsonNode r, String field) { + JsonNode v = r.get(field); + if (v == null || !v.isIntegralNumber()) { + throw sanity("readiness." + field + " 缺失或非整数"); + } + return v.asInt(); + } + // ==================== 路 A:/agent + /session 请求体组装 + 值域校验 ==================== private GenConfigActivationPayload.PathA buildPathA(String prompt, JsonNode routeA) { diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigService.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigService.java index 586e30a6..365707e4 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigService.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigService.java @@ -96,6 +96,22 @@ public interface GenConfigService { */ GenConfigVersionDO getVersion(Long configSetId, Long versionId); + /** + * 按业务键取「当前激活版本行」(供进程内消费方直读激活配置,如 D11 质量评分参数 W-CFG-EXT)。 + * + *

    与 {@link #getVersion} 的差别:getVersion 按 versionId 精确取任一历史版、不存在即抛;本方法按配置集业务键 + * 定位其当前激活版(active_version_id 指向的那一行),且读不到不抛、返回 null——配置集不存在、从未激活 + * (active_version_id 为 NULL)、或激活版行缺失,都返 null,交调用方回落默认(消费侧非阻断:D11 打分读不到激活版 + * 就用硬编码默认,绝不因配置缺失打挂回调)。 + * + *

    按 setKey(而非 tier)定位:uk_set_key 保证至多一条,寻址无歧义;跨档的质量评分配置集是单例(约定业务键 + * {@link ReadinessConfigKeys#READINESS_SET_KEY}),按 tier 取则一档可有多集、有歧义,故走业务键。 + * + * @param setKey 配置集业务键 + * @return 当前激活版本行 DO;配置集不存在/从未激活/激活版缺失 → null + */ + GenConfigVersionDO getActiveVersionBySetKey(String setKey); + /** * 两版 diff(应用层做):prompt 给「是否变更 + 两侧字节数」,结构化旋钮拍平成点路径逐项比对给增/删/改。 * diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigServiceImpl.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigServiceImpl.java index 37401f94..6ae3f30e 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigServiceImpl.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigServiceImpl.java @@ -210,6 +210,18 @@ public class GenConfigServiceImpl implements GenConfigService { return version; } + @Override + public GenConfigVersionDO getActiveVersionBySetKey(String setKey) { + // 按业务键定位配置集(uk_set_key 至多一条);不存在 → null(消费侧回落,不抛) + GenConfigSetDO set = configSetMapper.selectBySetKey(setKey); + if (set == null || set.getActiveVersionId() == null) { + // 配置集未建 / 从未激活(active_version_id 为 NULL)→ 无当前激活版 + return null; + } + // 取当前激活版指针指向的版本行(理论上必归属本集;缺失则 null,交调用方回落) + return configVersionMapper.selectByIdAndSetId(set.getActiveVersionId(), set.getId()); + } + @Override public GenConfigVersionDiffRespVO diffVersions(Long configSetId, Long versionAId, Long versionBId) { GenConfigVersionDO a = getVersion(configSetId, versionAId); diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/ReadinessConfigKeys.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/ReadinessConfigKeys.java new file mode 100644 index 00000000..45cae6e1 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/ReadinessConfigKeys.java @@ -0,0 +1,57 @@ +package com.wanxiang.huijing.game.module.aigc.service.config; + +/** + * D11 质量评分配置集(W-CFG-EXT)的内容契约常量:content_json 里 {@code readiness} 块的块名与十个字段名, + * 外加质量评分配置集的约定业务键。 + * + *

    为何单独成类:这批字段名是「配置集内容(doc)」与「读取消费(code)」之间的唯一约定—— + * {@link GenConfigContentCodec} 激活前置的 readiness 值域 sanity 与 {@code ReadinessConfigProvider} 的逐字段解析 + * 都引用它,把字段名收进一处避免两边各写一份字符串字面量、漂移无人对账。字段值(默认起步值)不在这里, + * 在 {@link com.wanxiang.huijing.game.module.aigc.service.task.ReadinessScorer} 的回落默认常量里——那里才是 + * 「热源缺失时用哪个数」的权威,本类只管「用哪个键名读」。 + * + *

    字段值域与默认值语义见 {@code ReadinessScorer} 各常量的 Javadoc;字段名采直白 snake_case,与打分器四维/ + * 稳定性两级/中性分/效率预算一一对应。 + * + * @author 绘境AI(W-CFG-EXT · D11 参数外置) + */ +public final class ReadinessConfigKeys { + + private ReadinessConfigKeys() { + } + + /** content_json 里承载 D11 参数的块名(平级于 routeA/routeB,互斥)。 */ + public static final String BLOCK = "readiness"; + + /** + * 质量评分配置集的约定业务键(set_key)。跨档单例:全租户内质量评分配置集用这一个业务键, + * {@code ReadinessConfigProvider} 据它定位当前激活版(uk_set_key 保证至多一条,寻址无歧义)。 + * 运营在配置中心新建质量评分配置集时须用此业务键,否则 provider 找不到激活版、打分器回落硬编码默认。 + */ + public static final String READINESS_SET_KEY = "readiness-default"; + + // ---- 四维权重(和≈1.0)---- + /** 可玩性权重。 */ + public static final String PLAYABILITY_WEIGHT = "playability_weight"; + /** 首局权重。 */ + public static final String FIRST_PLAY_WEIGHT = "first_play_weight"; + /** 稳定性权重。 */ + public static final String STABILITY_WEIGHT = "stability_weight"; + /** 效率权重。 */ + public static final String EFFICIENCY_WEIGHT = "efficiency_weight"; + + // ---- 子项参数 ---- + /** 数据缺失时的中性子项分。 */ + public static final String NEUTRAL_SCORE = "neutral_score"; + /** 效率维预算阈值(成本 ≤ 此值算满分)。 */ + public static final String EFFICIENCY_BUDGET_RMB = "efficiency_budget_rmb"; + /** 稳定性:修复轮 ≤ 此值算满分(1.0)。 */ + public static final String STABILITY_REPAIRS_GOOD_MAX = "stability_repairs_good_max"; + /** 稳定性:修复轮 ≤ 此值算次优(ok 分),超则劣(bad 分)。 */ + public static final String STABILITY_REPAIRS_OK_MAX = "stability_repairs_ok_max"; + /** 稳定性次优分(good < 修复轮 ≤ ok 时取值)。 */ + public static final String STABILITY_OK_SCORE = "stability_ok_score"; + /** 稳定性劣分(修复轮 > ok 时取值)。 */ + public static final String STABILITY_BAD_SCORE = "stability_bad_score"; + +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/ReadinessConfigProvider.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/ReadinessConfigProvider.java new file mode 100644 index 00000000..630973af --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/ReadinessConfigProvider.java @@ -0,0 +1,142 @@ +package com.wanxiang.huijing.game.module.aigc.service.config; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.wanxiang.huijing.game.module.aigc.dal.dataobject.config.GenConfigVersionDO; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * D11 质量评分参数的进程内读取面(W-CFG-EXT):把「当前激活的质量评分配置集」version 行里的 content_json.readiness + * 解析成 {@link ReadinessView},供 {@link com.wanxiang.huijing.game.module.aigc.service.task.ReadinessScorer} 打分时取用。 + * + *

    为何进程内直读账本、不走投影:D11 参数的消费方就是同库同进程的打分器,不像 prompt/模型(路 A PATCH Service) + * 或预算/门阈值(路 B publish Nacos)要投影到外部系统。所以这里直接读 MySQL 账本的激活版本行,是配置控制面阶段二 + * 「消费两路」之外补齐的第三条消费口(game-cloud 进程内读激活版)。 + * + *

    短 TTL 缓存,不接激活失效信号:打分发生在回调落库这条本就 DB-heavy 的路上,一次缓存读可忽略;用短 TTL + * 失效(而非让激活链发进程内失效信号)把配置中心与打分器解耦——激活一版权重后,最迟一个 TTL 内下一次打分即读到新值, + * 不必把 provider 耦合进激活编排。TTL 很短,「改一版即生效」的体感仍在秒级。 + * + *

    永不抛、逐字段回落:未建质量评分配置集 / 未激活 / DB 读异常 / content_json 非法 / 某字段缺失或类型不符—— + * 任一情形都不抛异常打挂回调,退化成「该字段用打分器硬编码默认」。整份读不到 → {@link ReadinessView#empty()} + * (全字段回落);读到但缺某字段 → 仅该字段为 null(仅它回落)。回落方向永远收敛到硬编码默认,保证「配置集出任何 + * 问题,打分退化成现状、绝不更差」。 + * + * @author 绘境AI(W-CFG-EXT · D11 参数外置) + */ +@Slf4j +@Component +public class ReadinessConfigProvider { + + /** content_json 解析器(无共享可变状态,线程安全)。 */ + private static final ObjectMapper JSON = new ObjectMapper(); + + /** 缓存短 TTL 默认值(毫秒):激活一版后最迟这么久下一次打分读到新值。 */ + private static final long DEFAULT_TTL_MS = 5_000L; + + @Resource + private GenConfigService genConfigService; + + /** 缓存 TTL(毫秒);测试可经包内构造器设 0 强制每次重读。 */ + private final long ttlMs; + + /** 缓存视图(volatile 读写;短窗内并发多读一次刷新只多几次 DB,可接受,不加锁保简单)。 */ + private volatile ReadinessView cachedView; + /** 缓存过期时刻(epoch millis);now ≥ 它则重读。 */ + private volatile long cacheExpireAtMs; + + /** Spring 用:默认短 TTL。 */ + public ReadinessConfigProvider() { + this.ttlMs = DEFAULT_TTL_MS; + } + + /** 测试用(包可见):自定义 TTL(设 0 = 每次重读,便于验解析/回落而不受缓存干扰);genConfigService 由测试注入。 */ + ReadinessConfigProvider(GenConfigService genConfigService, long ttlMs) { + this.genConfigService = genConfigService; + this.ttlMs = ttlMs; + } + + /** + * 取当前激活质量评分配置集的 D11 参数视图(短 TTL 缓存;缺/失败逐字段回落)。 + * + * @return 只读视图(永不为 null;读不到则 {@link ReadinessView#empty()}) + */ + public ReadinessView get() { + long now = System.currentTimeMillis(); + ReadinessView cached = cachedView; + if (cached != null && now < cacheExpireAtMs) { + return cached; + } + ReadinessView fresh = load(); + cachedView = fresh; + cacheExpireAtMs = now + ttlMs; + return fresh; + } + + /** 读激活版 + 解析成视图;任何异常/缺失都收敛到全回落(empty),绝不抛。 */ + private ReadinessView load() { + try { + GenConfigVersionDO version = + genConfigService.getActiveVersionBySetKey(ReadinessConfigKeys.READINESS_SET_KEY); + if (version == null) { + // 未建质量评分配置集 / 未激活过 → 全字段回落硬编码默认 + return ReadinessView.empty(); + } + JsonNode readiness = extractReadiness(version.getContentJson()); + if (readiness == null) { + log.warn("[readiness-config] 激活版无 content_json.readiness 块,全回落硬编码默认 versionId={}", version.getId()); + return ReadinessView.empty(); + } + return parse(readiness); + } catch (Exception e) { + // DB 读异常等:退化成全回落,不打挂回调(非阻断哲学) + log.warn("[readiness-config] 读激活质量评分配置集失败,全回落硬编码默认", e); + return ReadinessView.empty(); + } + } + + /** 从 content_json 取 readiness 块(非法 JSON / 无该块 → null)。 */ + private JsonNode extractReadiness(String contentJson) { + if (contentJson == null || contentJson.isBlank()) { + return null; + } + try { + JsonNode root = JSON.readTree(contentJson); + JsonNode readiness = root == null ? null : root.get(ReadinessConfigKeys.BLOCK); + return (readiness != null && readiness.isObject()) ? readiness : null; + } catch (Exception e) { + log.warn("[readiness-config] content_json 非法 JSON,全回落 len={}", contentJson.length()); + return null; + } + } + + /** 逐字段解析成视图:字段缺失/类型不符 → 该字段 null(仅它回落),不影响其余字段。 */ + private ReadinessView parse(JsonNode r) { + return new ReadinessView( + doubleOrNull(r, ReadinessConfigKeys.PLAYABILITY_WEIGHT), + doubleOrNull(r, ReadinessConfigKeys.FIRST_PLAY_WEIGHT), + doubleOrNull(r, ReadinessConfigKeys.STABILITY_WEIGHT), + doubleOrNull(r, ReadinessConfigKeys.EFFICIENCY_WEIGHT), + doubleOrNull(r, ReadinessConfigKeys.NEUTRAL_SCORE), + doubleOrNull(r, ReadinessConfigKeys.EFFICIENCY_BUDGET_RMB), + intOrNull(r, ReadinessConfigKeys.STABILITY_REPAIRS_GOOD_MAX), + intOrNull(r, ReadinessConfigKeys.STABILITY_REPAIRS_OK_MAX), + doubleOrNull(r, ReadinessConfigKeys.STABILITY_OK_SCORE), + doubleOrNull(r, ReadinessConfigKeys.STABILITY_BAD_SCORE)); + } + + /** 取数值字段;缺失或非数值 → null(该字段回落)。 */ + private static Double doubleOrNull(JsonNode node, String field) { + JsonNode v = node.get(field); + return (v != null && v.isNumber()) ? v.asDouble() : null; + } + + /** 取整数字段;缺失或非整数(含浮点)→ null(该字段回落)。 */ + private static Integer intOrNull(JsonNode node, String field) { + JsonNode v = node.get(field); + return (v != null && v.isIntegralNumber()) ? v.asInt() : null; + } + +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/ReadinessView.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/ReadinessView.java new file mode 100644 index 00000000..9d832f5b --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/config/ReadinessView.java @@ -0,0 +1,92 @@ +package com.wanxiang.huijing.game.module.aigc.service.config; + +/** + * D11 质量评分参数的只读视图(W-CFG-EXT):当前激活质量评分配置集里 content_json.readiness 各字段的进程内快照。 + * + *

    逐字段回落语义:每个字段可空——{@code null} 表示激活版里没有该字段(或未激活/读取异常整份为空)。 + * 取值 getter 都要求传入一个回落默认(打分器的硬编码常量),字段非空取激活版值、字段空取回落默认。 + * 这样「某个字段缺失只回落那一个,不因一个字段缺就整份弃用激活版」在一处兑现,调用方(打分器)只管把自己的 + * 常量作为回落默认传进来。{@link #empty()} 是全空视图,任何字段都取回落默认——等价于「无激活版、全用硬编码」, + * 用于 provider 缺席(如未接配置中心的单测)或读取失败。 + * + *

    只读、无副作用:构造后不可变;provider 短 TTL 缓存的就是本视图实例,多线程打分共享读安全。 + * + * @author 绘境AI(W-CFG-EXT · D11 参数外置) + */ +public final class ReadinessView { + + // 四维权重(null=激活版缺该字段→回落) + private final Double playabilityWeight; + private final Double firstPlayWeight; + private final Double stabilityWeight; + private final Double efficiencyWeight; + // 子项参数 + private final Double neutralScore; + private final Double efficiencyBudgetRmb; + private final Integer stabilityRepairsGoodMax; + private final Integer stabilityRepairsOkMax; + private final Double stabilityOkScore; + private final Double stabilityBadScore; + + public ReadinessView(Double playabilityWeight, Double firstPlayWeight, Double stabilityWeight, + Double efficiencyWeight, Double neutralScore, Double efficiencyBudgetRmb, + Integer stabilityRepairsGoodMax, Integer stabilityRepairsOkMax, + Double stabilityOkScore, Double stabilityBadScore) { + this.playabilityWeight = playabilityWeight; + this.firstPlayWeight = firstPlayWeight; + this.stabilityWeight = stabilityWeight; + this.efficiencyWeight = efficiencyWeight; + this.neutralScore = neutralScore; + this.efficiencyBudgetRmb = efficiencyBudgetRmb; + this.stabilityRepairsGoodMax = stabilityRepairsGoodMax; + this.stabilityRepairsOkMax = stabilityRepairsOkMax; + this.stabilityOkScore = stabilityOkScore; + this.stabilityBadScore = stabilityBadScore; + } + + /** 全空视图:任何字段取回落默认(等价「无激活版、全用硬编码常量」)。 */ + public static ReadinessView empty() { + return new ReadinessView(null, null, null, null, null, null, null, null, null, null); + } + + public double playabilityWeight(double fallback) { + return playabilityWeight != null ? playabilityWeight : fallback; + } + + public double firstPlayWeight(double fallback) { + return firstPlayWeight != null ? firstPlayWeight : fallback; + } + + public double stabilityWeight(double fallback) { + return stabilityWeight != null ? stabilityWeight : fallback; + } + + public double efficiencyWeight(double fallback) { + return efficiencyWeight != null ? efficiencyWeight : fallback; + } + + public double neutralScore(double fallback) { + return neutralScore != null ? neutralScore : fallback; + } + + public double efficiencyBudgetRmb(double fallback) { + return efficiencyBudgetRmb != null ? efficiencyBudgetRmb : fallback; + } + + public int stabilityRepairsGoodMax(int fallback) { + return stabilityRepairsGoodMax != null ? stabilityRepairsGoodMax : fallback; + } + + public int stabilityRepairsOkMax(int fallback) { + return stabilityRepairsOkMax != null ? stabilityRepairsOkMax : fallback; + } + + public double stabilityOkScore(double fallback) { + return stabilityOkScore != null ? stabilityOkScore : fallback; + } + + public double stabilityBadScore(double fallback) { + return stabilityBadScore != null ? stabilityBadScore : fallback; + } + +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/task/ReadinessScorer.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/task/ReadinessScorer.java index 1cf2b443..7461a8eb 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/task/ReadinessScorer.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/task/ReadinessScorer.java @@ -1,6 +1,9 @@ package com.wanxiang.huijing.game.module.aigc.service.task; +import com.wanxiang.huijing.game.module.aigc.service.config.ReadinessConfigProvider; +import com.wanxiang.huijing.game.module.aigc.service.config.ReadinessView; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import java.util.Map; @@ -16,7 +19,7 @@ import java.util.Map; * - playability 占半壁(九门全过 = 就绪基线):trace.pass==true → 1.0;否则 0(九门是硬地板)。 * - firstPlay(首局):trace.gatespec 有 driver 且 sevenGateVerdict.guards.H_progress==true → 1.0;否则 0.5。 * - stability(稳定性):repairs==0 → 1.0;repairs<=2 → 0.7;否则 0.4(修复轮越少越就绪)。 - * - efficiency(效率):cost.totalRmb 在预算内(<=0.15) → 1.0;超则线性降到 0(成本可控度)。 + * - efficiency(效率):cost.totalRmb 在预算阈值内(起步 ¥0.6,W-CFG-EXT 可热配) → 1.0;超则线性降到 0(成本可控度)。 * - 任一子项数据缺失(如 cost 取价失败)→ 该子项取中性 0.5,不让缺数据把分打到 0(非阻断哲学)。 * * 【权重占位(执行版 §9-2 待校准)】playability 0.50 / firstPlay 0.25 / stability 0.15 / efficiency 0.10—— @@ -43,12 +46,35 @@ public class ReadinessScorer { /** * 效率子项的预算阈值(成本 totalRmb <= 此值算满分,超则线性降)。 * 起步值 ¥0.6(创始人 2026-07-04 决策5「按推荐」——原 0.15 与便宜档真实成本 ¥0.4–1.2 错配、 - * 效率维恒 0 零区分力,取便宜档成本中位纠正)。当前为硬编码常量:改要动代码 + 换 jar; - * 待随 D11 就绪评分参数(本阈值 + 四维权重)外置进配置中心(决策6「进配置集」涟漪),届时 - * 本常量降为热源缺失时的回落默认、真值由激活版本行下发。 + * 效率维恒 0 零区分力,取便宜档成本中位纠正)。已随 D11 就绪评分参数(本阈值 + 四维权重)外置进配置中心 + * (W-CFG-EXT,决策6「进配置集」涟漪):本常量降为热源缺失时的回落默认,真值由激活的质量评分配置集版本行 + * (content_json.readiness.efficiency_budget_rmb)经 ReadinessConfigProvider 下发,改一版即生效、不换 jar。 */ private static final double BUDGET_RMB = 0.6; + /** 稳定性:修复轮 <= 此值算满分 1.0(回落默认,原内联字面量 r<=0)。 */ + private static final int REPAIRS_GOOD_MAX = 0; + /** 稳定性:修复轮 <= 此值算次优 ok 分,超则劣 bad 分(回落默认,原内联字面量 r<=2)。 */ + private static final int REPAIRS_OK_MAX = 2; + /** 稳定性次优分(good < 修复轮 <= ok 时取值;回落默认,原内联字面量 0.7)。 */ + private static final double STABILITY_OK_SCORE = 0.7; + /** 稳定性劣分(修复轮 > ok 时取值;回落默认,原内联字面量 0.4)。 */ + private static final double STABILITY_BAD_SCORE = 0.4; + + /** + * D11 质量评分参数的进程内读取面(W-CFG-EXT):打分时取当前激活质量评分配置集的四维权重与阈值,读不到逐字段 + * 回落到上面的 static final 常量。可选注入(required=false):Spring 上下文里由 {@link ReadinessConfigProvider} + * bean 注入;单测 {@code new ReadinessScorer()} 时为 null → {@link #view()} 返回 {@link ReadinessView#empty()} → + * 全字段回落硬编码常量(等价外置前行为,故既有单测不受影响)。 + */ + @Autowired(required = false) + private ReadinessConfigProvider configProvider; + + /** 取当前 D11 参数视图:provider 缺席(单测)/为空 → 全回落硬编码常量。 */ + private ReadinessView view() { + return configProvider != null ? configProvider.get() : ReadinessView.empty(); + } + /** * 读 trace 算就绪度分(0-100 整数) * @@ -61,14 +87,16 @@ public class ReadinessScorer { return null; } try { - double playability = scorePlayability(trace); - double firstPlay = scoreFirstPlay(trace); - double stability = scoreStability(trace); - double efficiency = scoreEfficiency(trace); - double weighted = W_PLAYABILITY * playability - + W_FIRST_PLAY * firstPlay - + W_STABILITY * stability - + W_EFFICIENCY * efficiency; + // 一次解析当前激活的 D11 参数视图(短 TTL 缓存);读不到逐字段回落硬编码常量(W-CFG-EXT) + ReadinessView v = view(); + double playability = scorePlayability(trace, v); + double firstPlay = scoreFirstPlay(trace, v); + double stability = scoreStability(trace, v); + double efficiency = scoreEfficiency(trace, v); + double weighted = v.playabilityWeight(W_PLAYABILITY) * playability + + v.firstPlayWeight(W_FIRST_PLAY) * firstPlay + + v.stabilityWeight(W_STABILITY) * stability + + v.efficiencyWeight(W_EFFICIENCY) * efficiency; // 夹取 [0,1] 后映射 0-100(防权重/子项越界,稳健兜底) int score = (int) Math.round(100.0 * clamp01(weighted)); return Math.max(0, Math.min(100, score)); @@ -81,9 +109,13 @@ public class ReadinessScorer { /** 可玩性子项:trace.pass==true → 1.0;明确 false → 0;缺失 → 中性 0.5。 */ private double scorePlayability(Map trace) { + return scorePlayability(trace, view()); + } + + private double scorePlayability(Map trace, ReadinessView v) { Object pass = trace.get("pass"); if (pass == null) { - return NEUTRAL; + return v.neutralScore(NEUTRAL); } return asBool(pass) ? 1.0 : 0.0; } @@ -98,8 +130,12 @@ public class ReadinessScorer { * firstPlay 永卡中性 0.5、永不到 1.0(HTTP/SAA 两路同病)。修后:H_progress 是 Map 时读其 {@code pass} 布尔字段 * (真实形态),同时保留对裸 bool 的向后兼容(老格式/测试夹具)。 */ - @SuppressWarnings("unchecked") private double scoreFirstPlay(Map trace) { + return scoreFirstPlay(trace, view()); + } + + @SuppressWarnings("unchecked") + private double scoreFirstPlay(Map trace, ReadinessView v) { Object gatespec = trace.get("gatespec"); boolean hasDriver = gatespec instanceof Map && ((Map) gatespec).get("driver") != null; Object verdict = trace.get("sevenGateVerdict"); @@ -111,7 +147,7 @@ public class ReadinessScorer { hProgress = guardPass(((Map) guards).get("H_progress")); } } - return (hasDriver && hProgress) ? 1.0 : NEUTRAL; + return (hasDriver && hProgress) ? 1.0 : v.neutralScore(NEUTRAL); } /** @@ -136,37 +172,50 @@ public class ReadinessScorer { /** 稳定性子项:repairs==0 → 1.0;<=2 → 0.7;否则 0.4;缺失 → 中性 0.5。 */ private double scoreStability(Map trace) { + return scoreStability(trace, view()); + } + + private double scoreStability(Map trace, ReadinessView v) { Object repairs = trace.get("repairs"); if (!(repairs instanceof Number)) { - return NEUTRAL; + return v.neutralScore(NEUTRAL); } int r = ((Number) repairs).intValue(); - if (r <= 0) { + if (r <= v.stabilityRepairsGoodMax(REPAIRS_GOOD_MAX)) { return 1.0; } - return r <= 2 ? 0.7 : 0.4; + return r <= v.stabilityRepairsOkMax(REPAIRS_OK_MAX) + ? v.stabilityOkScore(STABILITY_OK_SCORE) + : v.stabilityBadScore(STABILITY_BAD_SCORE); } /** - * 效率子项:cost.totalRmb <= 预算(0.15) → 1.0;超则线性降到 0(totalRmb>=2×预算 时 0); - * cost 缺失/取价失败(无 totalRmb)→ 中性 0.5(worker new-api 取价失败时 cost 段降级,不应把分打到 0)。 + * 效率子项:cost.totalRmb <= 预算阈值 → 1.0;超则线性降到 0(totalRmb>=2×预算 时 0); + * cost 缺失/取价失败(无 totalRmb)→ 中性分(worker new-api 取价失败时 cost 段降级,不应把分打到 0)。 + * 预算阈值取激活的质量评分配置集 {@code efficiency_budget_rmb}(W-CFG-EXT),读不到回落 {@link #BUDGET_RMB}(起步 ¥0.6)。 */ @SuppressWarnings("unchecked") private double scoreEfficiency(Map trace) { + return scoreEfficiency(trace, view()); + } + + @SuppressWarnings("unchecked") + private double scoreEfficiency(Map trace, ReadinessView v) { + double budget = v.efficiencyBudgetRmb(BUDGET_RMB); Object cost = trace.get("cost"); if (!(cost instanceof Map)) { - return NEUTRAL; + return v.neutralScore(NEUTRAL); } Object totalRmb = ((Map) cost).get("totalRmb"); if (!(totalRmb instanceof Number)) { - return NEUTRAL; // 取价失败降级(cost 只剩 error+byModel)→ 中性 + return v.neutralScore(NEUTRAL); // 取价失败降级(cost 只剩 error+byModel)→ 中性 } double rmb = ((Number) totalRmb).doubleValue(); - if (rmb <= BUDGET_RMB) { + if (rmb <= budget) { return 1.0; } // 线性降:预算→2×预算 区间从 1.0 降到 0,超 2×预算 钳到 0 - double ratio = (rmb - BUDGET_RMB) / BUDGET_RMB; + double ratio = (rmb - budget) / budget; return clamp01(1.0 - ratio); } diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigActivationServiceImplTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigActivationServiceImplTest.java index f52acfa5..8347cb8a 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigActivationServiceImplTest.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigActivationServiceImplTest.java @@ -58,6 +58,13 @@ class GenConfigActivationServiceImplTest extends BaseMockitoUnitTest { + "\"routeB\":{\"budget.rmb_hard_limit\":40.0}}"; /** 值域非法内容(负预算)。 */ private static final String BAD_CONTENT = "{\"routeB\":{\"budget.rmb_hard_limit\":-5.0}}"; + /** 质量评分配置集内容(readiness-only,W-CFG-EXT):codec 产空载荷、激活天然短路成纯账本收口。 */ + private static final String READINESS_CONTENT = + "{\"readiness\":{\"playability_weight\":0.50,\"first_play_weight\":0.25," + + "\"stability_weight\":0.15,\"efficiency_weight\":0.10," + + "\"neutral_score\":0.5,\"efficiency_budget_rmb\":0.6," + + "\"stability_repairs_good_max\":0,\"stability_repairs_ok_max\":2," + + "\"stability_ok_score\":0.7,\"stability_bad_score\":0.4}}"; @BeforeEach void setUp() { @@ -208,6 +215,30 @@ class GenConfigActivationServiceImplTest extends BaseMockitoUnitTest { verify(genConfigService, never()).abortActivating(any(), any()); } + // ==================== 质量评分配置集(readiness-only,W-CFG-EXT E3):空载荷天然短路 ==================== + + /** + * E3:质量评分配置集(readiness-only)激活 → codec 产空载荷 → 现有 dispatch 天然短路: + * 账本正常收口(markVersionActivated),但两处投影全程零触碰(Agent Service/Redis 与 Nacos dataId 都不写)——纯账本收口、无外投影。 + * 激活编排一行未改(复用现成 dispatch/收口),本例坐实空载荷短路成立。 + */ + @Test + void testActivate_readinessOnly_pureLedgerNoDispatch() { + stubSet(1L, AigcConfigStatusEnum.PENDING_REVIEW, null, "readiness"); // 跨档质量评分档 + stubVersion(1L, 3000L, null, READINESS_CONTENT); // 无 prompt、只有 readiness 块 + when(genConfigService.beginActivating(1L, 3000L)) + .thenReturn(new GenConfigActivationSnapshot(AigcConfigStatusEnum.PENDING_REVIEW.getStatus(), null)); + + service.activate(1L, 3000L); + + // 纯账本收口:转 ACTIVE + 前移指针(markVersionActivated),不失败复位 + verify(genConfigService).markVersionActivated(1L, 3000L); + verify(genConfigService, never()).abortActivating(any(), any()); + // 两处投影零写入:Agent Service(路A/Redis)与 Nacos(路B/dataId)全程不被触碰 + verify(agentClient, never()).patch(anyString(), any()); + verify(nacosClient, never()).publish(anyString(), any()); + } + // ==================== 前置拒绝:不写账本、不下发 ==================== /** 值域非法(负预算)→ 激活前置拒:不落 ACTIVATING、不发任何一路。 */ diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigContentCodecTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigContentCodecTest.java index 6a33460c..516f64c5 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigContentCodecTest.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigContentCodecTest.java @@ -201,4 +201,108 @@ class GenConfigContentCodecTest { assertTrue(p.hasPathB()); } + // ==================== readiness:D11 质量评分配置集(W-CFG-EXT) ==================== + + /** 合法 readiness 起步值(十字段,值 = ReadinessScorer 常量)。 */ + private static final String VALID_READINESS = + "{\"readiness\":{\"playability_weight\":0.50,\"first_play_weight\":0.25," + + "\"stability_weight\":0.15,\"efficiency_weight\":0.10," + + "\"neutral_score\":0.5,\"efficiency_budget_rmb\":0.6," + + "\"stability_repairs_good_max\":0,\"stability_repairs_ok_max\":2," + + "\"stability_ok_score\":0.7,\"stability_bad_score\":0.4}}"; + + /** 只有 readiness 块(起步值合法)→ 空载荷(两路皆空):现有 dispatch/reconcile 天然短路成纯账本收口。 */ + @Test + void testBuild_readinessOnly_emptyPayload() { + GenConfigActivationPayload p = codec().build(null, VALID_READINESS); + assertFalse(p.hasPathA(), "质量评分配置集不下发路 A"); + assertFalse(p.hasPathB(), "质量评分配置集不下发路 B"); + assertNull(p.pathA()); + assertNull(p.pathBJson()); + } + + /** readiness 四维权重和≠1.0(如 0.5/0.5/0.5/0.5)→ sanity 拒。 */ + @Test + void testReadinessSanity_weightSumNotOne() { + String c = "{\"readiness\":{\"playability_weight\":0.5,\"first_play_weight\":0.5," + + "\"stability_weight\":0.5,\"efficiency_weight\":0.5," + + "\"neutral_score\":0.5,\"efficiency_budget_rmb\":0.6," + + "\"stability_repairs_good_max\":0,\"stability_repairs_ok_max\":2," + + "\"stability_ok_score\":0.7,\"stability_bad_score\":0.4}}"; + assertEquals(AIGC_CONFIG_ACTIVATE_SANITY_FAILED.getCode(), sanityOf(null, c).getCode()); + } + + /** readiness 单权重越界 [0,1](如 1.5)→ sanity 拒。 */ + @Test + void testReadinessSanity_weightOutOfUnit() { + String c = "{\"readiness\":{\"playability_weight\":1.5,\"first_play_weight\":-0.5," + + "\"stability_weight\":0.0,\"efficiency_weight\":0.0," + + "\"neutral_score\":0.5,\"efficiency_budget_rmb\":0.6," + + "\"stability_repairs_good_max\":0,\"stability_repairs_ok_max\":2," + + "\"stability_ok_score\":0.7,\"stability_bad_score\":0.4}}"; + assertEquals(AIGC_CONFIG_ACTIVATE_SANITY_FAILED.getCode(), sanityOf(null, c).getCode()); + } + + /** readiness 预算非正(0)→ sanity 拒。 */ + @Test + void testReadinessSanity_budgetNotPositive() { + String c = VALID_READINESS.replace("\"efficiency_budget_rmb\":0.6", "\"efficiency_budget_rmb\":0.0"); + assertEquals(AIGC_CONFIG_ACTIVATE_SANITY_FAILED.getCode(), sanityOf(null, c).getCode()); + } + + /** readiness 修复轮 good_max > ok_max → sanity 拒。 */ + @Test + void testReadinessSanity_repairsGoodGtOk() { + String c = VALID_READINESS + .replace("\"stability_repairs_good_max\":0", "\"stability_repairs_good_max\":5") + .replace("\"stability_repairs_ok_max\":2", "\"stability_repairs_ok_max\":2"); + assertEquals(AIGC_CONFIG_ACTIVATE_SANITY_FAILED.getCode(), sanityOf(null, c).getCode()); + } + + /** readiness 子项分越界 [0,1](stability_ok_score=1.7)→ sanity 拒。 */ + @Test + void testReadinessSanity_subScoreOutOfUnit() { + String c = VALID_READINESS.replace("\"stability_ok_score\":0.7", "\"stability_ok_score\":1.7"); + assertEquals(AIGC_CONFIG_ACTIVATE_SANITY_FAILED.getCode(), sanityOf(null, c).getCode()); + } + + /** readiness 缺字段(缺 efficiency_budget_rmb)→ sanity 拒(新激活须完整;运行期缺字段回落是 provider 的事)。 */ + @Test + void testReadinessSanity_missingField() { + String c = VALID_READINESS.replace(",\"efficiency_budget_rmb\":0.6", ""); + assertEquals(AIGC_CONFIG_ACTIVATE_SANITY_FAILED.getCode(), sanityOf(null, c).getCode()); + } + + /** readiness 修复轮阈值非整数(2.5)→ sanity 拒。 */ + @Test + void testReadinessSanity_repairsNotInteger() { + String c = VALID_READINESS.replace("\"stability_repairs_ok_max\":2", "\"stability_repairs_ok_max\":2.5"); + assertEquals(AIGC_CONFIG_ACTIVATE_SANITY_FAILED.getCode(), sanityOf(null, c).getCode()); + } + + /** 互斥:readiness 与 routeB 共存 → sanity 拒(质量评分块不得混配生成下发路)。 */ + @Test + void testReadiness_mutexWithRouteB() { + String c = "{\"readiness\":{\"playability_weight\":0.50,\"first_play_weight\":0.25," + + "\"stability_weight\":0.15,\"efficiency_weight\":0.10," + + "\"neutral_score\":0.5,\"efficiency_budget_rmb\":0.6," + + "\"stability_repairs_good_max\":0,\"stability_repairs_ok_max\":2," + + "\"stability_ok_score\":0.7,\"stability_bad_score\":0.4}," + + "\"routeB\":{\"budget.cheap_rmb_hard_limit\":10.0}}"; + assertEquals(AIGC_CONFIG_ACTIVATE_SANITY_FAILED.getCode(), sanityOf(null, c).getCode()); + } + + /** 互斥:readiness 与 routeA 共存 → sanity 拒。 */ + @Test + void testReadiness_mutexWithRouteA() { + String c = "{\"readiness\":{\"playability_weight\":0.50,\"first_play_weight\":0.25," + + "\"stability_weight\":0.15,\"efficiency_weight\":0.10," + + "\"neutral_score\":0.5,\"efficiency_budget_rmb\":0.6," + + "\"stability_repairs_good_max\":0,\"stability_repairs_ok_max\":2," + + "\"stability_ok_score\":0.7,\"stability_bad_score\":0.4}," + + "\"routeA\":{\"agentId\":\"a1\",\"sessionId\":\"s1\",\"session\":{\"chat_model_config\":" + + "{\"type\":\"t\",\"credential_id\":\"c\",\"model\":\"m\",\"parameters\":{}}}}}"; + assertEquals(AIGC_CONFIG_ACTIVATE_SANITY_FAILED.getCode(), sanityOf("sys", c).getCode()); + } + } diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigReconcileServiceImplTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigReconcileServiceImplTest.java index 0236afc7..8920e270 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigReconcileServiceImplTest.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/GenConfigReconcileServiceImplTest.java @@ -60,6 +60,13 @@ class GenConfigReconcileServiceImplTest extends BaseMockitoUnitTest { private static final String DATAID = "gen-hot-params-cheap"; private static final String PB_IN_SYNC = "{\"budget.cheap_rmb_hard_limit\":10.0}"; private static final String PB_DRIFT = "{\"budget.cheap_rmb_hard_limit\":999.0}"; + /** 质量评分配置集内容(readiness-only,W-CFG-EXT):空载荷、无投影可对账。 */ + private static final String READINESS_CONTENT = + "{\"readiness\":{\"playability_weight\":0.50,\"first_play_weight\":0.25," + + "\"stability_weight\":0.15,\"efficiency_weight\":0.10," + + "\"neutral_score\":0.5,\"efficiency_budget_rmb\":0.6," + + "\"stability_repairs_good_max\":0,\"stability_repairs_ok_max\":2," + + "\"stability_ok_score\":0.7,\"stability_bad_score\":0.4}}"; @BeforeEach void setUp() { @@ -257,6 +264,33 @@ class GenConfigReconcileServiceImplTest extends BaseMockitoUnitTest { verify(nacosClient, never()).probeDataId(anyString()); } + /** + * E3:质量评分配置集(readiness-only)对账 → codec 产空载荷 → 两路 applicable=false → IN_SYNC、零漂移, + * 且两路投影零探测(无路A/路B 可对账)。坐实「漂移对账对质量评分配置集报无投影、无漂移」。 + */ + @Test + void testReconcile_readinessOnly_inSyncNoProjection() { + GenConfigSetDO set = new GenConfigSetDO(); + set.setId(1L); + set.setTier("readiness"); // 跨档质量评分档 + set.setStatus(AigcConfigStatusEnum.ACTIVE.getStatus()); + set.setActiveVersionId(300L); + when(genConfigService.getConfigSet(1L)).thenReturn(set); + GenConfigVersionDO v = new GenConfigVersionDO(); + v.setId(300L); + v.setConfigSetId(1L); + v.setContentJson(READINESS_CONTENT); // 只有 readiness 块 → 空载荷 + when(genConfigService.getVersion(1L, 300L)).thenReturn(v); + + GenConfigDriftReportRespVO report = service.reconcile(1L); + + assertEquals("IN_SYNC", report.getVerdict(), "质量评分配置集无投影 → 恒一致"); + assertTrue(report.getDriftItems().isEmpty(), "无投影、零漂移项"); + // 两路投影零探测(无路A/路B 可对账) + verify(agentClient, never()).probeAgentSystemPrompt(anyString(), anyString()); + verify(nacosClient, never()).probeDataId(anyString()); + } + // ==================== 最终一致容忍窗 ==================== /** 路B 首读旧值(漂移)、窗内再读已收敛 → 容忍窗吸收传播延迟、IN_SYNC 不误报。 */ diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/ReadinessConfigProviderTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/ReadinessConfigProviderTest.java new file mode 100644 index 00000000..be72d67c --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/config/ReadinessConfigProviderTest.java @@ -0,0 +1,258 @@ +package com.wanxiang.huijing.game.module.aigc.service.config; + +import com.wanxiang.huijing.game.module.aigc.dal.dataobject.config.GenConfigVersionDO; +import com.wanxiang.huijing.game.module.aigc.service.task.ReadinessScorer; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link ReadinessConfigProvider} 与 {@link ReadinessScorer} 外置读线的单元测试(W-CFG-EXT):把守 + * ①未激活/读失败逐字段回落硬编码默认、②读到激活版按字段生效、③单字段缺失只回落该字段、 + * ④起步值 = ReadinessScorer 常量的 doc↔code 对账机器门、⑤短 TTL 缓存。纯 Mockito,不依赖 DB/Spring。 + * + * @author 绘境AI(W-CFG-EXT · D11 参数外置) + */ +class ReadinessConfigProviderTest { + + /** 起步值 content_json(字面量 = 契约文档里的起步值;doc↔code 断言据此对齐 ReadinessScorer 常量)。 */ + private static final String STARTER_CONTENT = + "{\"readiness\":{\"playability_weight\":0.50,\"first_play_weight\":0.25," + + "\"stability_weight\":0.15,\"efficiency_weight\":0.10," + + "\"neutral_score\":0.5,\"efficiency_budget_rmb\":0.6," + + "\"stability_repairs_good_max\":0,\"stability_repairs_ok_max\":2," + + "\"stability_ok_score\":0.7,\"stability_bad_score\":0.4}}"; + + private static final double SENTINEL = -999.0; + private static final int INT_SENTINEL = -999; + + private GenConfigVersionDO versionWith(String contentJson) { + GenConfigVersionDO v = new GenConfigVersionDO(); + v.setId(1L); + v.setContentJson(contentJson); + return v; + } + + /** ttl=0 的 provider(每次重读,验解析/回落不受缓存干扰)。 */ + private ReadinessConfigProvider providerOver(GenConfigService svc) { + return new ReadinessConfigProvider(svc, 0L); + } + + // ==================== ① 未激活 / 读失败 → 全回落 ==================== + + /** 未建/未激活质量评分配置集(getActiveVersionBySetKey 返 null)→ 全字段回落硬编码默认(sentinel 全部原样返回)。 */ + @Test + void get_noActiveVersion_allFallback() { + GenConfigService svc = mock(GenConfigService.class); + when(svc.getActiveVersionBySetKey(eq(ReadinessConfigKeys.READINESS_SET_KEY))).thenReturn(null); + + ReadinessView v = providerOver(svc).get(); + assertEquals(SENTINEL, v.playabilityWeight(SENTINEL), 1e-9, "未激活应回落(返回 sentinel)"); + assertEquals(SENTINEL, v.efficiencyBudgetRmb(SENTINEL), 1e-9); + assertEquals(INT_SENTINEL, v.stabilityRepairsOkMax(INT_SENTINEL)); + } + + /** DB 读抛异常 → 不抛穿、退化成全回落(非阻断哲学)。 */ + @Test + void get_dbThrows_allFallback() { + GenConfigService svc = mock(GenConfigService.class); + when(svc.getActiveVersionBySetKey(eq(ReadinessConfigKeys.READINESS_SET_KEY))) + .thenThrow(new RuntimeException("DB 不可达")); + + ReadinessView v = providerOver(svc).get(); + assertEquals(SENTINEL, v.playabilityWeight(SENTINEL), 1e-9, "读异常应回落、不抛"); + } + + /** 激活版无 content_json.readiness 块(如生成配置集误挂)→ 全回落。 */ + @Test + void get_noReadinessBlock_allFallback() { + GenConfigService svc = mock(GenConfigService.class); + when(svc.getActiveVersionBySetKey(eq(ReadinessConfigKeys.READINESS_SET_KEY))) + .thenReturn(versionWith("{\"routeB\":{\"budget.cheap_rmb_hard_limit\":10.0}}")); + + ReadinessView v = providerOver(svc).get(); + assertEquals(SENTINEL, v.stabilityOkScore(SENTINEL), 1e-9); + } + + /** content_json 非法 JSON → 全回落、不抛。 */ + @Test + void get_invalidJson_allFallback() { + GenConfigService svc = mock(GenConfigService.class); + when(svc.getActiveVersionBySetKey(eq(ReadinessConfigKeys.READINESS_SET_KEY))) + .thenReturn(versionWith("{不是合法json")); + + ReadinessView v = providerOver(svc).get(); + assertEquals(SENTINEL, v.neutralScore(SENTINEL), 1e-9); + } + + // ==================== ② 读到激活版 → 按字段生效 ==================== + + /** 激活版给非默认权重 → view 逐字段返回激活值(非回落)。 */ + @Test + void get_activeVersion_fieldsTakeEffect() { + String content = "{\"readiness\":{\"playability_weight\":0.60,\"first_play_weight\":0.20," + + "\"stability_weight\":0.10,\"efficiency_weight\":0.10," + + "\"neutral_score\":0.4,\"efficiency_budget_rmb\":1.2," + + "\"stability_repairs_good_max\":1,\"stability_repairs_ok_max\":3," + + "\"stability_ok_score\":0.8,\"stability_bad_score\":0.3}}"; + GenConfigService svc = mock(GenConfigService.class); + when(svc.getActiveVersionBySetKey(eq(ReadinessConfigKeys.READINESS_SET_KEY))) + .thenReturn(versionWith(content)); + + ReadinessView v = providerOver(svc).get(); + assertEquals(0.60, v.playabilityWeight(SENTINEL), 1e-9); + assertEquals(1.2, v.efficiencyBudgetRmb(SENTINEL), 1e-9); + assertEquals(1, v.stabilityRepairsGoodMax(INT_SENTINEL)); + assertEquals(3, v.stabilityRepairsOkMax(INT_SENTINEL)); + assertEquals(0.8, v.stabilityOkScore(SENTINEL), 1e-9); + } + + // ==================== ③ 单字段缺失 → 只回落该字段 ==================== + + /** 激活版缺 efficiency_budget_rmb:只该字段回落 sentinel,其余仍取激活版值。 */ + @Test + void get_missingOneField_onlyThatFieldFallsBack() { + String content = STARTER_CONTENT.replace(",\"efficiency_budget_rmb\":0.6", ""); + GenConfigService svc = mock(GenConfigService.class); + when(svc.getActiveVersionBySetKey(eq(ReadinessConfigKeys.READINESS_SET_KEY))) + .thenReturn(versionWith(content)); + + ReadinessView v = providerOver(svc).get(); + assertEquals(SENTINEL, v.efficiencyBudgetRmb(SENTINEL), 1e-9, "缺失字段回落 sentinel"); + assertEquals(0.50, v.playabilityWeight(SENTINEL), 1e-9, "其余字段仍取激活版值、不受牵连"); + assertEquals(2, v.stabilityRepairsOkMax(INT_SENTINEL), "其余字段仍取激活版值"); + } + + // ==================== ④ doc↔code:起步值 = ReadinessScorer 常量(机器门) ==================== + + /** + * 契约文档里的起步值(STARTER_CONTENT 字面量)逐项 = ReadinessScorer 的回落常量。 + * 谁改了打分器常量却没同步契约起步值(或反之),此断言即红——把「字段名与默认值 = ReadinessScorer 常量」钉成机器门。 + */ + @Test + void docCode_starterValues_equalScorerConstants() throws Exception { + GenConfigService svc = mock(GenConfigService.class); + when(svc.getActiveVersionBySetKey(eq(ReadinessConfigKeys.READINESS_SET_KEY))) + .thenReturn(versionWith(STARTER_CONTENT)); + ReadinessView v = providerOver(svc).get(); + + assertEquals(constD("W_PLAYABILITY"), v.playabilityWeight(SENTINEL), 1e-9); + assertEquals(constD("W_FIRST_PLAY"), v.firstPlayWeight(SENTINEL), 1e-9); + assertEquals(constD("W_STABILITY"), v.stabilityWeight(SENTINEL), 1e-9); + assertEquals(constD("W_EFFICIENCY"), v.efficiencyWeight(SENTINEL), 1e-9); + assertEquals(constD("NEUTRAL"), v.neutralScore(SENTINEL), 1e-9); + assertEquals(constD("BUDGET_RMB"), v.efficiencyBudgetRmb(SENTINEL), 1e-9); + assertEquals(constI("REPAIRS_GOOD_MAX"), v.stabilityRepairsGoodMax(INT_SENTINEL)); + assertEquals(constI("REPAIRS_OK_MAX"), v.stabilityRepairsOkMax(INT_SENTINEL)); + assertEquals(constD("STABILITY_OK_SCORE"), v.stabilityOkScore(SENTINEL), 1e-9); + assertEquals(constD("STABILITY_BAD_SCORE"), v.stabilityBadScore(SENTINEL), 1e-9); + } + + /** + * 端到端等价(行为版 doc↔code):起步值激活版喂进带 provider 的打分器,与无 provider(纯常量)打分器对同一 trace 同分。 + * 若起步值 ≠ 常量,两者分数会偏离 → 红。 + */ + @Test + void docCode_starterConfig_scoresSameAsHardcoded() { + GenConfigService svc = mock(GenConfigService.class); + when(svc.getActiveVersionBySetKey(eq(ReadinessConfigKeys.READINESS_SET_KEY))) + .thenReturn(versionWith(STARTER_CONTENT)); + ReadinessScorer withProvider = new ReadinessScorer(); + ReflectionTestUtils.setField(withProvider, "configProvider", providerOver(svc)); + ReadinessScorer hardcoded = new ReadinessScorer(); // provider=null → 全回落常量 + + Map trace = fullTrace(); + assertEquals(hardcoded.score(trace), withProvider.score(trace), + "起步值激活版与硬编码常量对同一 trace 应同分(起步值=常量)"); + } + + /** 端到端生效:激活版把 playability 权重从 0.50 抬到 0.90,同一 trace 就绪分随之变化(不重编 jar)。 */ + @Test + void endToEnd_activatedWeightChangesScore() { + // 提高可玩性权重、压其余到和为 1.0;对 playability=1.0 而其余非满的 trace,抬 playability 权重会改变合成分 + String content = "{\"readiness\":{\"playability_weight\":0.90,\"first_play_weight\":0.04," + + "\"stability_weight\":0.03,\"efficiency_weight\":0.03," + + "\"neutral_score\":0.5,\"efficiency_budget_rmb\":0.6," + + "\"stability_repairs_good_max\":0,\"stability_repairs_ok_max\":2," + + "\"stability_ok_score\":0.7,\"stability_bad_score\":0.4}}"; + GenConfigService svc = mock(GenConfigService.class); + when(svc.getActiveVersionBySetKey(eq(ReadinessConfigKeys.READINESS_SET_KEY))) + .thenReturn(versionWith(content)); + ReadinessScorer withProvider = new ReadinessScorer(); + ReflectionTestUtils.setField(withProvider, "configProvider", providerOver(svc)); + ReadinessScorer hardcoded = new ReadinessScorer(); + + // trace:playability=1.0(pass=true)、firstPlay=0.5(无 H 门)、stability=1.0(repairs=0)、efficiency 中性 0.5 + Map trace = new HashMap<>(); + trace.put("pass", true); + trace.put("repairs", 0); + // 不给 gatespec/verdict → firstPlay 走中性;不给 cost → efficiency 中性 + assertNotEquals(hardcoded.score(trace), withProvider.score(trace), + "抬高 playability 权重后同一 trace 就绪分应变化"); + } + + // ==================== ⑤ 短 TTL 缓存 ==================== + + /** TTL 内多次 get 只读一次 DB(缓存命中)。 */ + @Test + void get_withinTtl_cachesOneDbRead() { + GenConfigService svc = mock(GenConfigService.class); + when(svc.getActiveVersionBySetKey(eq(ReadinessConfigKeys.READINESS_SET_KEY))) + .thenReturn(versionWith(STARTER_CONTENT)); + ReadinessConfigProvider provider = new ReadinessConfigProvider(svc, 60_000L); // 长 TTL + + provider.get(); + provider.get(); + provider.get(); + verify(svc, times(1)).getActiveVersionBySetKey(eq(ReadinessConfigKeys.READINESS_SET_KEY)); + } + + // ==================== 工具 ==================== + + /** 反射读 ReadinessScorer 的 private static final double 常量。 */ + private static double constD(String name) throws Exception { + Field f = ReadinessScorer.class.getDeclaredField(name); + f.setAccessible(true); + return f.getDouble(null); + } + + /** 反射读 ReadinessScorer 的 private static final int 常量。 */ + private static int constI(String name) throws Exception { + Field f = ReadinessScorer.class.getDeclaredField(name); + f.setAccessible(true); + return f.getInt(null); + } + + /** 九门全过的满信号 trace(playability/firstPlay/stability=1.0、efficiency 满分)。 */ + private static Map fullTrace() { + Map trace = new HashMap<>(); + trace.put("pass", true); + trace.put("repairs", 0); + Map gatespec = new HashMap<>(); + gatespec.put("driver", "click"); + trace.put("gatespec", gatespec); + Map hProgress = new HashMap<>(); + hProgress.put("pass", true); + Map guards = new HashMap<>(); + guards.put("H_progress", hProgress); + Map verdict = new HashMap<>(); + verdict.put("guards", guards); + trace.put("sevenGateVerdict", verdict); + Map cost = new HashMap<>(); + cost.put("totalRmb", 0.3); // <= budget 0.6 → efficiency 1.0 + trace.put("cost", cost); + return trace; + } + +}