Bladeren bron

fix(ac15): 修正 6 处 harness 缺陷,全量 145 条跑通(DEFECT=0)

1. 探针 SET timestamp 改整数直拼(绑定成字符串会 1232)
2. 探针输出加 ##AC15_JSON_BEGIN## 标记,Go 按标记切(原来按最后一个 '{' 会切进嵌套对象)
3. 造数之后重读权威输入 inputsEffective(否则 target 用造数前的 cost/reward_type)
4. 规则读取挪进事务、造数之后(否则 target 用旧规则)
5. Δincome 用毫(1e-3)判断是否为 0(用分会把 0.003 的收敛误判成 DEFECT)
6. 逐行比对只在 PHP 真的重写过时进行(perRowRewritten),避免与历史值比较

结果:150 结果 = MATCH 122 / PRECISION-CONVERGENCE 21 / ROUNDING-CONVENTION 7,
其余四类(DEFECT/DEGRADED/PATH-DIVERGENCE/ENV-FAILURE)均为 0;幂等 145/145 一致。
Q 7 uur geleden
bovenliggende
commit
2ef5cd76ea

+ 27 - 8
internal/academic/dualrun/attribution.go

@@ -5,6 +5,15 @@ import (
 	"math"
 )
 
+// milliOf 把金额折算成"毫"(1e-3 元)。PHP 侧金额精度是 3 位小数,
+// 判断"基数有没有变"必须在这个精度上做,用"分"会漏掉 0.001~0.005 的收敛。
+func milliOf(v float64) int64 {
+	if v < 0 {
+		return -int64(math.Round(-v * 1000))
+	}
+	return int64(math.Round(v * 1000))
+}
+
 // severity 把归因折算成"严重度",用于把一条 case 的多行结论合成一条 case 结论。
 //
 // 取最大值(越严重越优先),保证"有一条真缺陷"不会被"其余都 MATCH"淹没。
@@ -93,7 +102,10 @@ func Attribute(in AttrInput) ([]DiffRow, Attribution, *Step1Income) {
 	var rows []DiffRow
 	attr := AttrMatch
 	actualDetail := int64(obs.Quote.DetailID)
-	if in.PredictedDetailID != 0 && actualDetail != in.PredictedDetailID {
+	// 造数 case 的"复刻判定"是在**造数之前**的数据上算的,与 SP 实返不可比(必然不同),
+	// 这两项只对非造数 case 生效;造数 case 的正确性由 ExpectTier → 实返规则字段自证。
+	skipPathCheck := len(c.Mutations) > 0
+	if !skipPathCheck && in.PredictedDetailID != 0 && actualDetail != in.PredictedDetailID {
 		rows = append(rows, DiffRow{
 			CaseID: c.ID, Table: "t_course_payment_detail", PK: c.RecordID, Column: "detail_id",
 			Legacy: fmt.Sprintf("%d", actualDetail), Target: fmt.Sprintf("%d", in.PredictedDetailID),
@@ -101,7 +113,7 @@ func Attribute(in AttrInput) ([]DiffRow, Attribution, *Step1Income) {
 		})
 		attr = worse(attr, AttrDefect)
 	}
-	if c.ExpectTier != "" && in.PredictedTier != "" && c.ExpectTier != in.PredictedTier {
+	if !skipPathCheck && c.ExpectTier != "" && in.PredictedTier != "" && c.ExpectTier != in.PredictedTier {
 		rows = append(rows, DiffRow{
 			CaseID: c.ID, Table: "path", PK: c.RecordID, Column: "tier",
 			Legacy: in.PredictedTier, Target: c.ExpectTier, Delta: "-", Attribution: AttrPathDivergence,
@@ -121,7 +133,9 @@ func Attribute(in AttrInput) ([]DiffRow, Attribution, *Step1Income) {
 		Delta:  fmt.Sprintf("%+.3f", tgt.Reward-legacyReward),
 	}
 
-	deltaIncomeCents := centsOf(targetIncome) - centsOf(legacyIncome)
+	// Δincome 必须在**毫(1e-3)**上判断是否为 0:PHP 在 3 位小数上工作,
+	// 用"分"判断会把 349.347 → 349.35(Δ=+0.003,分位相同)误当成"基数没变"。
+	deltaIncomeMilli := milliOf(targetIncome) - milliOf(legacyIncome)
 	if centsOf(legacyReward) == centsOf(tgt.Reward) {
 		rewardRow.Attribution = AttrMatch
 		rows = append(rows, rewardRow)
@@ -135,17 +149,17 @@ func Attribute(in AttrInput) ([]DiffRow, Attribution, *Step1Income) {
 		tolerance := math.Max(0.015, math.Abs(expectedDelta)+0.015) // 1.5 分容差
 		proportional := math.Abs(actualDelta-expectedDelta) <= tolerance
 		switch {
-		case deltaIncomeCents != 0 && proportional:
+		case deltaIncomeMilli != 0 && proportional:
 			rewardRow.Attribution = AttrPrecisionConvergence
-			rewardRow.Column += fmt.Sprintf("(Δincome=%+d分, Δ预期 %+.4f, 实测 %+.4f)", deltaIncomeCents, expectedDelta, actualDelta)
+			rewardRow.Column += fmt.Sprintf("(Δincome=%+.3f, Δ预期 %+.4f, 实测 %+.4f)", s1.Delta, expectedDelta, actualDelta)
 			attr = worse(attr, AttrPrecisionConvergence)
-		case deltaIncomeCents == 0:
+		case deltaIncomeMilli == 0:
 			rewardRow.Attribution = AttrDefect
 			rewardRow.Column += fmt.Sprintf("(Δincome=0 而 Δreward=%+.4f ⇒ 基数相同、算法不同)", actualDelta)
 			attr = worse(attr, AttrDefect)
 		default:
 			rewardRow.Attribution = AttrDefect
-			rewardRow.Column += fmt.Sprintf("(Δincome=%+d分, Δ预期 %+.4f, 实测 %+.4f, 不成比例)", deltaIncomeCents, expectedDelta, actualDelta)
+			rewardRow.Column += fmt.Sprintf("(Δincome=%+.3f, Δ预期 %+.4f, 实测 %+.4f, 不成比例)", s1.Delta, expectedDelta, actualDelta)
 			attr = worse(attr, AttrDefect)
 		}
 		rows = append(rows, rewardRow)
@@ -164,6 +178,11 @@ func attributePerRow(c Case, obs *OracleResult, tgt *TargetResult, caseAttr Attr
 			legacy[s.StudentID] = s.TeacherReward
 		}
 	}
+	if !obs.PerRowRewritten {
+		// PHP 没进分摊分支(recount=false 且 type≠2)⇒ 逐行保持历史值,
+		// 拿历史值比 Go 的分摊结果没有意义,直接跳过(不是通过,也不是差异)。
+		return nil
+	}
 	var rows []DiffRow
 	legacySum := 0.0
 	for _, v := range legacy {
@@ -188,7 +207,7 @@ func attributePerRow(c Case, obs *OracleResult, tgt *TargetResult, caseAttr Attr
 			continue // 逐行相等就不刷行,避免报告噪声
 		}
 		rowAttr := AttrDefect
-		deltaIncomeCents := centsOf(tgt.Income) - centsOf(obs.Inputs.IncomeSumCost3)
+		deltaIncomeCents := milliOf(tgt.Income) - milliOf(obs.Inputs.IncomeSumCost3)
 		switch {
 		case deltaIncomeCents == 0:
 			// Δincome = 0 而逐行不等 ⇒ 只可能是分摊方式不同(R13)

+ 17 - 10
internal/academic/dualrun/model.go

@@ -115,16 +115,23 @@ type PathEvidence struct {
 
 // OracleResult 是 PHP 探针返回的 oracle 观测值。
 type OracleResult struct {
-	OK           bool      `json:"ok"`
-	Error        string    `json:"error"`
-	ErrorDetail  string    `json:"errorDetail"`
-	RecordID     string    `json:"recordId"`
-	IsSupplement bool      `json:"isSupplement"`
-	Inputs       OracleIn  `json:"inputs"`
-	Quote        *RuleRaw  `json:"quote"`
-	Clock        ClockInfo `json:"clock"`
-	Mutations    []string  `json:"mutations"`
-	MutationErrs []struct {
+	OK           bool   `json:"ok"`
+	Error        string `json:"error"`
+	ErrorDetail  string `json:"errorDetail"`
+	RecordID     string `json:"recordId"`
+	IsSupplement bool   `json:"isSupplement"`
+	// Inputs 是**造数之后**重读的权威输入(PHP 结算用的就是这一份)。
+	Inputs OracleIn `json:"inputsEffective"`
+	// InputsBefore 是造数之前的输入,仅用于溯源对比。
+	InputsBefore OracleIn `json:"inputsBeforeMutation"`
+	// StudentsEffective 是造数之后的打卡行(target 侧必须用这一份)。
+	StudentsEffective []StudentRow `json:"studentsEffective"`
+	// PerRowRewritten PHP 是否真的重写了逐行(false 时逐行比对无意义,会与历史值比较)。
+	PerRowRewritten bool      `json:"perRowRewritten"`
+	Quote           *RuleRaw  `json:"quote"`
+	Clock           ClockInfo `json:"clock"`
+	Mutations       []string  `json:"mutations"`
+	MutationErrs    []struct {
 		SQL   string `json:"sql"`
 		Error string `json:"error"`
 	} `json:"mutationErrors"`

+ 7 - 2
internal/academic/dualrun/oracle.go

@@ -65,6 +65,7 @@ func (o *OracleRunner) Run(ctx context.Context, c Case, frozenAt time.Time) (*Or
 	cmd := exec.CommandContext(ctx, o.cfg.PHPBinary,
 		"-d", "date.timezone=Asia/Shanghai",
 		"-d", "opcache.enable_cli=0",
+		"-d", "display_errors=stderr",
 		"-d", "memory_limit=1024M",
 		o.probePath,
 	)
@@ -88,8 +89,12 @@ func (o *OracleRunner) Run(ctx context.Context, c Case, frozenAt time.Time) (*Or
 	}
 
 	raw := bytes.TrimSpace(stdout.Bytes())
-	// 探针可能被 PHP 的 notice 污染 stdout,取最后一个 '{' 起始的 JSON 对象
-	if i := bytes.LastIndexByte(raw, '{'); i > 0 {
+	// 探针以 ##AC15_JSON_BEGIN## 标记 JSON 起点;老输出没有标记时回退到首个 '{'。
+	// (不能用"最后一个 '{'":JSON 内部有嵌套对象,会切在中间)
+	const marker = "##AC15_JSON_BEGIN##"
+	if i := bytes.Index(raw, []byte(marker)); i >= 0 {
+		raw = bytes.TrimSpace(raw[i+len(marker):])
+	} else if i := bytes.IndexByte(raw, '{'); i > 0 {
 		raw = raw[i:]
 	}
 	var res OracleResult

+ 83 - 22
internal/academic/dualrun/oracle_probe.php

@@ -34,6 +34,8 @@ $mutations = json_decode(getenv('AC15_MUTATIONS') ?: '[]', true) ?: [];
 function out(array $payload): void
 {
     $payload['harness'] = 'ac15-stage1-oracle';
+    // 输出标记:PHP 的 notice/warning 可能混进 stdout,Go 侧靠这个标记定位 JSON 起点
+    echo "##AC15_JSON_BEGIN##";
     echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR);
     exit(0);
 }
@@ -95,8 +97,11 @@ $clock = [];
 try {
     // ---- 冻结时钟(会话级)+ 时区统一 ----
     if ($frozenEpoch > 0) {
-        $db->createCommand('SET SESSION time_zone = :tz', [':tz' => '+08:00'])->execute();
-        $db->createCommand('SET SESSION timestamp = :ts', [':ts' => (string) $frozenEpoch])->execute();
+        // 注意:timestamp 是数值型会话变量,pdO 绑定成字符串会报
+        //   `1232 Incorrect argument type to variable 'timestamp'`
+        // 所以这两个都用整数/字面量直接拼(值来源已强制转 int/固定常量,无注入面)
+        $db->createCommand("SET SESSION time_zone = '+08:00'")->execute();
+        $db->createCommand('SET SESSION timestamp = ' . (int) $frozenEpoch)->execute();
         $clock = [
             'frozenEpoch' => $frozenEpoch,
             'now' => (string) $db->createCommand('SELECT NOW()')->queryScalar(),
@@ -131,34 +136,20 @@ try {
         }
     }
 
-    // SP 命中的原始规则(绕过 helper 缓存,单独取一次,作为"路径证据"的 detail_id 来源)
+    // SP 命中的原始规则:**必须在事务内、造数之后读**(否则拿到的是造数前的旧规则,
+    // 会造成"PHP 用新规则、target 用旧规则"的假 DEFECT)
     $quote = null;
-    try {
-        $quote = CoursePaymentDetailHelper::getTeacherPaymentDetailStoredProcedure(
-            $rec['teacherid'], $rec['schoolid'], $rec['campusid'], $classifyid, $rec['st']
-        );
-    } catch (Throwable $e) {
-        $quote = null;
-    }
     $quoteOut = null;
-    if (is_array($quote)) {
-        $quoteOut = [];
-        foreach (['id', 'course_paymentid', 'gradeid', 'subjectid', 'class_type', 'classifyid',
-                  'percentage', 'min_payment', 'max_payment', 'reward', 'teacher_cert',
-                  'teacher_exp_min', 'teacher_exp_max'] as $k) {
-            if (array_key_exists($k, $quote)) {
-                $quoteOut[$k] = (float) $quote[$k];
-            }
-        }
-    }
 
-    // ---- 事务内:造数 → 结算 → 读回 → 必定回滚 ----
+    // ---- 事务内:造数 → **重读权威输入** → 结算 → 读回 → 必定回滚 ----
     $tx = $db->beginTransaction();
     $exception = null;
     $mutationErrors = [];
     $ret = null;
     $after = null;
     $studentsAfter = [];
+    $inputs = null;
+    $studentsEffective = $studentsBefore;
     try {
         foreach ($mutations as $sql) {
             try {
@@ -167,6 +158,56 @@ try {
                 $mutationErrors[] = ['sql' => $sql, 'error' => $e->getMessage()];
             }
         }
+        // 造数会改 cost / teacher_reward_type 等,**必须重读**:PHP 结算用的是造数之后的世界,
+        // target 侧若用造数之前的值,就会出现"基数不同"的假 DEFECT。
+        $rec2 = $db->createCommand(
+            "SELECT teacher_reward_type, student_number, status, income, teacher_reward
+             FROM t_courses_record WHERE id=:id", [':id' => $recordId])->queryOne();
+        $studentsEffective = $db->createCommand(
+            "SELECT studentid, type, cost, teacher_reward FROM t_courses_record_student WHERE recordid=:r ORDER BY studentid, type",
+            [':r' => $recordId])->queryAll();
+        $incomeEffective = round((float) $db->createCommand(
+            "SELECT IFNULL(SUM(cost),0) FROM t_courses_record_student WHERE recordid=:r", [':r' => $recordId])->queryScalar(), 3);
+        $eligibleEffective = 0;
+        foreach ($studentsEffective as $s) {
+            if ((int) $s['type'] === 1 || (int) $s['type'] === 2) {
+                $eligibleEffective++;
+            }
+        }
+        // 读规则(此时造数已生效,SP 会看到注入的规则)
+        try {
+            $quote = CoursePaymentDetailHelper::getTeacherPaymentDetailStoredProcedure(
+                $rec['teacherid'], $rec['schoolid'], $rec['campusid'], $classifyid, $rec['st']
+            );
+        } catch (Throwable $e) {
+            $quote = null;
+        }
+        if (is_array($quote)) {
+            $quoteOut = [];
+            foreach (['id', 'course_paymentid', 'gradeid', 'subjectid', 'class_type', 'classifyid',
+                      'percentage', 'min_payment', 'max_payment', 'reward', 'teacher_cert',
+                      'teacher_exp_min', 'teacher_exp_max'] as $k) {
+                if (array_key_exists($k, $quote)) {
+                    $quoteOut[$k] = (float) $quote[$k];
+                }
+            }
+        }
+
+        $inputs = [
+            'schoolid' => (int) $rec['schoolid'],
+            'campusid' => (int) $rec['campusid'],
+            'teacherid' => (int) $rec['teacherid'],
+            'coursesid' => (int) $rec['coursesid'],
+            'classifyid' => (int) $classifyid,
+            'teacherExp' => (float) $teacherExp,
+            'teacherRewardType' => (int) $rec2['teacher_reward_type'],
+            'studentNumber' => (int) $rec2['student_number'],
+            'eligibleCount' => $eligibleEffective,
+            'incomeSumCost3' => $incomeEffective,
+            'statusBefore' => (int) $rec2['status'],
+            'teacherRewardBefore' => (float) $rec2['teacher_reward'],
+            'actualStartTime' => $rec['st'],
+        ];
         try {
             $ret = CoursesRecordHelper::settlementExpenses($recordId, $isSupplement);
         } catch (Throwable $e) {
@@ -198,11 +239,27 @@ try {
     $restored = ($db->createCommand("SELECT teacher_reward FROM t_courses_record WHERE id=:id", [':id' => $recordId])->queryScalar() == $rec['teacher_reward'])
         && (json_encode($studentsRestored) === json_encode($studentsBefore));
 
+    // PHP 是否真的重写了逐行(recount 分支是否进入):只有重写过,逐行比对才有意义
+    $perRowRewritten = false;
+    $effMap = [];
+    foreach ($studentsEffective as $s) {
+        $effMap[(int) $s['studentid']] = (float) $s['teacher_reward'];
+    }
+    foreach ($studentsAfter as $s) {
+        $sid = (int) $s['studentid'];
+        if (isset($effMap[$sid]) && abs($effMap[$sid] - (float) $s['teacher_reward']) > 0.0005) {
+            $perRowRewritten = true;
+            break;
+        }
+    }
+
     out([
         'ok' => true,
         'recordId' => $recordId,
         'isSupplement' => $isSupplement,
-        'inputs' => [
+        'perRowRewritten' => $perRowRewritten,
+        'inputsEffective' => $inputs,
+        'inputsBeforeMutation' => [
             'schoolid' => (int) $rec['schoolid'],
             'campusid' => (int) $rec['campusid'],
             'teacherid' => (int) $rec['teacherid'],
@@ -230,6 +287,10 @@ try {
             'studentNumber' => (int) $after['student_number'],
             'actualEndTime' => $after['aet'],
         ],
+        'studentsEffective' => array_map(static function ($r) {
+            return ['studentid' => (int) $r['studentid'], 'type' => (int) $r['type'],
+                    'cost' => (float) $r['cost'], 'teacherReward' => (float) $r['teacher_reward']];
+        }, $studentsEffective),
         'studentsBefore' => array_map(static function ($r) {
             return ['studentid' => (int) $r['studentid'], 'type' => (int) $r['type'],
                     'cost' => (float) $r['cost'], 'teacherReward' => (float) $r['teacher_reward']];

+ 4 - 4
internal/academic/dualrun/target.go

@@ -41,7 +41,7 @@ func ComputeTarget(obs *OracleResult) (*TargetResult, error) {
 
 	// ---- income:按迁移后口径逐行 ROUND 到分再求和 ----
 	var sumCents int64
-	for _, s := range obs.StudentsBefore {
+	for _, s := range obs.StudentsEffective {
 		sumCents += centsOf(s.Cost)
 	}
 	out.Income = centsToYuan(sumCents)
@@ -68,8 +68,8 @@ func ComputeTarget(obs *OracleResult) (*TargetResult, error) {
 	out.Reward = centsToYuan(got.Cents())
 
 	// ---- 逐行分摊:只分摊给 type ∈ {1,2},除数 = 该集合大小 ----
-	eligible := make([]StudentRow, 0, len(obs.StudentsBefore))
-	for _, s := range obs.StudentsBefore {
+	eligible := make([]StudentRow, 0, len(obs.StudentsEffective))
+	for _, s := range obs.StudentsEffective {
 		if s.Type == 1 || s.Type == 2 {
 			eligible = append(eligible, s)
 		}
@@ -91,7 +91,7 @@ func TargetIncomeLegacy(obs *OracleResult) float64 {
 		return 0
 	}
 	var sum float64
-	for _, s := range obs.StudentsBefore {
+	for _, s := range obs.StudentsEffective {
 		sum += s.Cost
 	}
 	return math.Round(sum*1000) / 1000