|
|
@@ -0,0 +1,322 @@
|
|
|
+package com.etotem.cfc.unit;
|
|
|
+
|
|
|
+import com.etotem.cfc.constant.AttributionLowSideRules;
|
|
|
+import com.etotem.cfc.constant.AttributionWeights;
|
|
|
+import com.etotem.cfc.service.attribution.FiveDimAttributionEngine;
|
|
|
+import com.etotem.cfc.service.attribution.model.IndicatorReading;
|
|
|
+import com.etotem.cfc.service.attribution.model.MemberAttribution;
|
|
|
+import com.etotem.cfc.service.attribution.model.MemberProfile;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+
|
|
|
+import java.lang.reflect.Method;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.LinkedHashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.Set;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+import static org.junit.jupiter.api.Assertions.fail;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 契约级单测:五维 no_path 映射(能力键 {@code guhe.l4.noPathMap},单元 l4)。
|
|
|
+ *
|
|
|
+ * <p><b>需求/设计锚点</b>:
|
|
|
+ * {@code docs/superpowers/plans/2026-11-08-浠艾福平台程序修改计划.md} §三任务分解
|
|
|
+ * §T14(P1,L4-4):菌群观测对「富/行」自动标 {@code no_path};心/智介导链自动阶梯并锁
|
|
|
+ * 关联措辞;低侧口径归身维主战场。(对齐 v1.1 验收 7/9/10)</p>
|
|
|
+ *
|
|
|
+ * <p><b>平台现状探测(C 档 4 项,会话实现前自主探测)</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li><b>Adapter 形态(#1)</b>:报告解析侧存在 {@code ReportParserDispatchService} /
|
|
|
+ * {@code LangGraphReportParseService};guhe_v2 采集为 Python 侧 {@code extract_guhe_v2.py},
|
|
|
+ * Java 侧以 {@code HealthGutFlora} + {@code AttributionInput.gutReportIds} 进入归因,
|
|
|
+ * L4 段消费与采集 Adapter 解耦。</li>
|
|
|
+ * <li><b>KB 加载方式(#2)</b>:{@code 统一知识库.json}(Python/LangGraph 侧,{@code +guhe2026.2}),
|
|
|
+ * Java 侧 L4 归因不直接读 KB,L2 查询走 LangGraph——T14 不涉及 KB 装载。</li>
|
|
|
+ * <li><b>L4 引擎存在性(#3)</b>:{@link FiveDimAttributionEngine} 已存在(纯 Java 确定性规则引擎,
|
|
|
+ * {@code attribute()} 计算五维 0-100 相对刻度)→ T14 为「补规则」而非「新建引擎」。</li>
|
|
|
+ * <li><b>合规门禁挂载点(#4)</b>:{@code AttributionComplianceGate} + {@code AttributionLowSideRules}
|
|
|
+ * (G-1 association_only,措辞白名单)已存在,L4 结论前 enforce → 心/智介导链措辞锁定复用白名单。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>被校验的契约行为(T14:L4-4 五维 no_path 映射)</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li>菌群观测({@code source=gut_flora})对「富/行」两维自动标 {@code no_path}:菌群为生物观测,
|
|
|
+ * 与「富(经济)/行(行为习惯)」维度无直接归因路径,异常不应对该两维产生扣分/弱维。</li>
|
|
|
+ * <li>菌群观测对「心/智」走介导链(mediation chain,如肠脑轴):自动生成阶梯式表述,
|
|
|
+ * 且每级措辞锁定为关联措辞(命中 {@link AttributionLowSideRules#WORDING_WHITELIST} 白名单,
|
|
|
+ * 禁因果/禁补菌桥接)。</li>
|
|
|
+ * <li>菌群低侧(偏低)判读口径归「身」维主战场:其五维弱维不应出现「富/行」。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>宿主契约(TDD-RED 锚点)</b>:{@code com.etotem.cfc.service.attribution.FiveDimAttributionEngine}
|
|
|
+ * —— 引擎类已存在(ATT-S2-B1 合入),但 T14 的 no_path 映射 <b>尚未实现</b>(cfclub 全仓
|
|
|
+ * {@code no_path}/{@code noPath} 零命中)。本测试通过反射锚定契约方法(避免直接 import 不存在方法
|
|
|
+ * 导致编译失败),运行时契约方法缺失即断言失败(红);GREEN 阶段实现方按
|
|
|
+ * 「契约方法签名」落地后转为验证真实行为(绿)。</p>
|
|
|
+ *
|
|
|
+ * <p><b>契约方法签名(实现方按此落地,落在 {@code FiveDimAttributionEngine} 上)</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li>{@code Map<String, Set<String>> noPathMap()} —— 数据源 → no_path 维度集合;
|
|
|
+ * 契约期望含 {@code "gut_flora" → {"富","行"}}</li>
|
|
|
+ * <li>{@code boolean isNoPath(String source, String dimension)} —— 指定数据源/维度是否 no_path;
|
|
|
+ * 期望 {@code isNoPath("gut_flora","富")==true}、{@code isNoPath("gut_flora","行")==true},
|
|
|
+ * {@code isNoPath("gut_flora","身")==false}(身维为主战场)、
|
|
|
+ * {@code isNoPath("gut_flora","心")==false}(心走介导链非 no_path)</li>
|
|
|
+ * <li>{@code List<String> mediationChain(String source, String dimension)} —— 心/智介导链阶梯表述;
|
|
|
+ * 期望非空、≥2 级,且每级措辞命中关联措辞白名单</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>测试范围</b>:纯契约级单测,无 Spring / 无 DB / 无外部服务;直接实例化纯规则引擎
|
|
|
+ * 或反射调用契约方法(与同批 TeamProfitHookContractTest / TeamStarMultiplierContractTest 风格一致)。</p>
|
|
|
+ *
|
|
|
+ * <p><b>RED 预期</b>:契约方法 {@code noPathMap()}/{@code isNoPath()}/{@code mediationChain()}
|
|
|
+ * 未实现 → 用例在反射探测处失败(红);另 3 个真实引擎用例断言现有 {@code attribute()} 对
|
|
|
+ * 菌群读数的富/行扣分行为违反 no_path 契约 → 断言失败(红)。</p>
|
|
|
+ */
|
|
|
+class GuheNoPathMapContractTest {
|
|
|
+
|
|
|
+ /** 宿主契约类(已存在,ATT-S2-B1 合入;no_path 映射规则未落地)。 */
|
|
|
+ private static final String HOST_CLASS = "com.etotem.cfc.service.attribution.FiveDimAttributionEngine";
|
|
|
+
|
|
|
+ // ==================== 契约行为 1:菌群观测对「富/行」自动标 no_path ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void noPathMap_gutFlora_wealthAndActionMarked() {
|
|
|
+ Object host = contractHost();
|
|
|
+ Map<?, ?> map = callMap(host, "noPathMap");
|
|
|
+ assertNotNull(map, "noPathMap() 应返回数据源 → no_path 维度集合");
|
|
|
+ assertTrue(map.containsKey("gut_flora"), "noPathMap 应含 gut_flora 菌群数据源");
|
|
|
+ Object dims = map.get("gut_flora");
|
|
|
+ assertTrue(dims instanceof Set, "gut_flora 的 no_path 维度应为 Set");
|
|
|
+ Set<?> set = (Set<?>) dims;
|
|
|
+ assertTrue(set.contains("富"), "菌群观测对「富」应自动标 no_path(无直接归因路径)");
|
|
|
+ assertTrue(set.contains("行"), "菌群观测对「行」应自动标 no_path(无直接归因路径)");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void isNoPath_gutFloraWealth_true() {
|
|
|
+ Object host = contractHost();
|
|
|
+ assertTrue(callBoolean(host, "isNoPath", "gut_flora", "富"),
|
|
|
+ "菌群观测 → 富维应判定为 no_path");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void isNoPath_gutFloraAction_true() {
|
|
|
+ Object host = contractHost();
|
|
|
+ assertTrue(callBoolean(host, "isNoPath", "gut_flora", "行"),
|
|
|
+ "菌群观测 → 行维应判定为 no_path");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void isNoPath_gutFloraBody_false() {
|
|
|
+ Object host = contractHost();
|
|
|
+ assertFalse(callBoolean(host, "isNoPath", "gut_flora", "身"),
|
|
|
+ "身维是菌群(低侧口径)主战场,不应标 no_path");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void isNoPath_gutFloraMind_false() {
|
|
|
+ Object host = contractHost();
|
|
|
+ assertFalse(callBoolean(host, "isNoPath", "gut_flora", "心"),
|
|
|
+ "心维走介导链(非 no_path,措辞锁关联)");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 契约行为 2:心/智介导链自动阶梯 + 锁关联措辞 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void mediationChain_gutFloraMind_ladderLockedWording() {
|
|
|
+ Object host = contractHost();
|
|
|
+ List<String> ladder = callListString(host, "mediationChain", "gut_flora", "心");
|
|
|
+ assertNotNull(ladder, "介导链应返回阶梯表述列表");
|
|
|
+ assertTrue(ladder.size() >= 2, "心介导链应为阶梯式自动生成(≥2 级),实际 " + ladder.size());
|
|
|
+ for (String step : ladder) {
|
|
|
+ assertTrue(usesWhitelistWording(step),
|
|
|
+ "介导链每级措辞必须锁定为关联措辞(白名单),实际: " + step);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void mediationChain_gutFloraWisdom_ladderLockedWording() {
|
|
|
+ Object host = contractHost();
|
|
|
+ List<String> ladder = callListString(host, "mediationChain", "gut_flora", "智");
|
|
|
+ assertNotNull(ladder, "介导链应返回阶梯表述列表");
|
|
|
+ assertTrue(ladder.size() >= 2, "智介导链应为阶梯式自动生成(≥2 级),实际 " + ladder.size());
|
|
|
+ for (String step : ladder) {
|
|
|
+ assertTrue(usesWhitelistWording(step),
|
|
|
+ "介导链每级措辞必须锁定为关联措辞(白名单),实际: " + step);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 契约行为 3:低侧口径归身维主战场(真实引擎违约断言) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void attribute_gutFloraReading_wealthNoPenalty_red() {
|
|
|
+ // 菌群源读数(显示名命中「富」关键词「饮食」;注意读数必须不命中更靠前的身/心/智/行关键词);
|
|
|
+ // 契约要求按 source=gut_flora 对「富」标 no_path → 不按关键词归因扣分(保持基数)
|
|
|
+ MemberAttribution attr = new MemberAttribution();
|
|
|
+ attr.setMemberId(1L);
|
|
|
+ attr.setReadings(Collections.singletonList(reading("gut_flora", "外食饮食习惯观测",
|
|
|
+ IndicatorReading.STATUS_LOW, IndicatorReading.BAND_VERY_LOW)));
|
|
|
+ Map<String, List<String>> evidence = new LinkedHashMap<>();
|
|
|
+ evidence.put("富", Collections.singletonList("外食饮食习惯观测"));
|
|
|
+ attr.setDimensionEvidence(evidence);
|
|
|
+
|
|
|
+ FiveDimAttributionEngine.FiveDimResult result =
|
|
|
+ new FiveDimAttributionEngine().attribute(
|
|
|
+ Collections.singletonList(attr), Collections.singletonList(profile(1L)));
|
|
|
+
|
|
|
+ Double wealth = result.attributions.get(0).getFiveDim().get("富");
|
|
|
+ double baseWealth = AttributionWeights.BASE_SCORE.getOrDefault("富", 78.0);
|
|
|
+ assertEquals(baseWealth, wealth, 1e-9,
|
|
|
+ "菌群观测对「富」维 no_path:异常菌群读数不应使富维扣分(保持基数 " + baseWealth + "),实际 " + wealth);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void attribute_gutFloraReading_actionNoPenalty_red() {
|
|
|
+ // 菌群源读数(显示名命中「行」关键词「运动」;不命中更靠前的身/心/智关键词);
|
|
|
+ // 契约要求按 source=gut_flora 对「行」标 no_path → 不按关键词归因扣分(保持基数)
|
|
|
+ MemberAttribution attr = new MemberAttribution();
|
|
|
+ attr.setMemberId(2L);
|
|
|
+ attr.setReadings(Collections.singletonList(reading("gut_flora", "运动作息习惯观测",
|
|
|
+ IndicatorReading.STATUS_LOW, IndicatorReading.BAND_VERY_LOW)));
|
|
|
+ Map<String, List<String>> evidence = new LinkedHashMap<>();
|
|
|
+ evidence.put("行", Collections.singletonList("运动作息习惯观测"));
|
|
|
+ attr.setDimensionEvidence(evidence);
|
|
|
+
|
|
|
+ FiveDimAttributionEngine.FiveDimResult result =
|
|
|
+ new FiveDimAttributionEngine().attribute(
|
|
|
+ Collections.singletonList(attr), Collections.singletonList(profile(2L)));
|
|
|
+
|
|
|
+ Double action = result.attributions.get(0).getFiveDim().get("行");
|
|
|
+ double baseAction = AttributionWeights.BASE_SCORE.getOrDefault("行", 70.0);
|
|
|
+ assertEquals(baseAction, action, 1e-9,
|
|
|
+ "菌群观测对「行」维 no_path:异常菌群读数不应使行维扣分(保持基数 " + baseAction + "),实际 " + action);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void attribute_lowSideGutFlora_weakExcludesWealthAndAction_red() {
|
|
|
+ // 低侧口径归身维主战场:多条菌群低侧读数(身/行/富三路关键词各一)→
|
|
|
+ // no_path 契约下弱维(<阈值)只应在身维;现有关键词引擎会把行/富误打成弱维
|
|
|
+ MemberAttribution attr = new MemberAttribution();
|
|
|
+ attr.setMemberId(3L);
|
|
|
+ attr.setReadings(Arrays.asList(
|
|
|
+ reading("gut_flora", "大肠杆菌偏低", IndicatorReading.STATUS_LOW, IndicatorReading.BAND_VERY_LOW),
|
|
|
+ reading("gut_flora", "运动作息习惯观测", IndicatorReading.STATUS_LOW, IndicatorReading.BAND_VERY_LOW),
|
|
|
+ reading("gut_flora", "外食饮食习惯观测", IndicatorReading.STATUS_LOW, IndicatorReading.BAND_VERY_LOW)));
|
|
|
+ Map<String, List<String>> evidence = new LinkedHashMap<>();
|
|
|
+ evidence.put("身", Collections.singletonList("大肠杆菌偏低"));
|
|
|
+ evidence.put("行", Collections.singletonList("运动作息习惯观测"));
|
|
|
+ evidence.put("富", Collections.singletonList("外食饮食习惯观测"));
|
|
|
+ attr.setDimensionEvidence(evidence);
|
|
|
+
|
|
|
+ FiveDimAttributionEngine.FiveDimResult result =
|
|
|
+ new FiveDimAttributionEngine().attribute(
|
|
|
+ Collections.singletonList(attr), Collections.singletonList(profile(3L)));
|
|
|
+
|
|
|
+ List<String> weak = result.attributions.get(0).getWeakDimensions();
|
|
|
+ assertNotNull(weak, "应输出五维短板标签");
|
|
|
+ assertTrue(weak.contains("身"), "低侧菌群判读主战场应为身维,weak=" + weak);
|
|
|
+ assertFalse(weak.contains("富"), "非身维遮挡:菌群低侧读数不应使「富」成弱维(no_path),weak=" + weak);
|
|
|
+ assertFalse(weak.contains("行"), "非身维遮挡:菌群低侧读数不应使「行」成弱维(no_path),weak=" + weak);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 反射辅助(锚定宿主契约方法,避免编译期依赖不存在方法) ====================
|
|
|
+
|
|
|
+ /** 措辞是否命中关联措辞白名单(G-1 关联非因果口径)。 */
|
|
|
+ private static boolean usesWhitelistWording(String text) {
|
|
|
+ if (text == null) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ for (String wl : AttributionLowSideRules.WORDING_WHITELIST) {
|
|
|
+ if (text.contains(wl)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static IndicatorReading reading(String source, String displayName, String status, String band) {
|
|
|
+ IndicatorReading r = new IndicatorReading();
|
|
|
+ r.setSource(source);
|
|
|
+ r.setCode(displayName);
|
|
|
+ r.setDisplayName(displayName);
|
|
|
+ r.setStatus(status);
|
|
|
+ r.setBand(band);
|
|
|
+ return r;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static MemberProfile profile(long memberId) {
|
|
|
+ MemberProfile p = new MemberProfile();
|
|
|
+ p.setMemberId(memberId);
|
|
|
+ p.setName("成员" + memberId);
|
|
|
+ p.setRole("mom");
|
|
|
+ return p;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Object contractHost() {
|
|
|
+ try {
|
|
|
+ Class<?> clazz = Class.forName(HOST_CLASS);
|
|
|
+ return clazz.getDeclaredConstructor().newInstance();
|
|
|
+ } catch (ClassNotFoundException e) {
|
|
|
+ fail("RED[guhe.l4.noPathMap] 契约宿主 FiveDimAttributionEngine 未实现(实现未落地):"
|
|
|
+ + e.getMessage());
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
+ } catch (Exception e) {
|
|
|
+ fail("契约宿主 FiveDimAttributionEngine 实例化失败:" + e.getMessage());
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 按方法名 + 参数个数匹配并调用,契约方法缺失即断言失败(红)。 */
|
|
|
+ private Object call(Object host, String method, Object... args) {
|
|
|
+ for (Method m : host.getClass().getMethods()) {
|
|
|
+ if (m.getName().equals(method) && m.getParameterCount() == args.length) {
|
|
|
+ try {
|
|
|
+ return m.invoke(host, args);
|
|
|
+ } catch (Exception e) {
|
|
|
+ fail("契约方法 " + HOST_CLASS + "#" + method + " 调用失败:" + e.getMessage());
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ fail("RED[guhe.l4.noPathMap] 契约方法 " + HOST_CLASS + "#"
|
|
|
+ + method + " 未实现(T14 五维 no_path 映射未落地)");
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean callBoolean(Object host, String method, Object... args) {
|
|
|
+ Object r = call(host, method, args);
|
|
|
+ if (r instanceof Boolean) {
|
|
|
+ return (Boolean) r;
|
|
|
+ }
|
|
|
+ fail("契约方法 " + method + " 应返回布尔,实际 " + r);
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
+ }
|
|
|
+
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private Map<?, ?> callMap(Object host, String method) {
|
|
|
+ Object r = call(host, method);
|
|
|
+ if (r instanceof Map) {
|
|
|
+ return (Map<?, ?>) r;
|
|
|
+ }
|
|
|
+ fail("契约方法 " + method + " 应返回 Map,实际 " + r);
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
+ }
|
|
|
+
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private List<String> callListString(Object host, String method, Object... args) {
|
|
|
+ Object r = call(host, method, args);
|
|
|
+ if (r instanceof List) {
|
|
|
+ return (List<String>) r;
|
|
|
+ }
|
|
|
+ fail("契约方法 " + method + " 应返回 List<String>,实际 " + r);
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
+ }
|
|
|
+}
|