invoke(null, CompleteTcmCase::rows()); $submission = (new ReflectionMethod(PatientAiReportLogic::class, 'sanitizeSnapshotForUpstream')) ->invoke(null, AiRequiredSource::submission($snapshot)); $ledger = Guard::build(CompleteTcmCase::current(), $submission); $index = Index::build($ledger); $ledgerBytes = serialize($ledger); $expect(hash('sha256', json_encode($ledger, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) === '2ebbec0b558347bf4ee128dfb5a9e63fc4e355d58f7226486a2ae96098bd57f6', 'Every immutable source ledger byte is retained.'); $expect(hash_file('sha256', __DIR__ . '/fixtures/CompleteTcmCase.php') === 'b54c47ec11f6a99dc87bdda78629e18c8a6f37a64017e42a72db399c727cb1c6', 'Original fixture bytes remain unchanged.'); $first = "一、四诊\n\n"; foreach (Index::ROLES as $label => $role) { $sourceSpan = null; foreach ($index['sources'] as $source) { if ($source['scope'] !== 'current') continue; foreach ($source['spans'] as $span) { if ($span['role'] === $role && $span['recorded']) { $sourceSpan = [$source, trim($span['quote'])]; break 2; } } } if ($role === 'palpation' && $sourceSpan === null) { $first .= "切诊:其他触诊未记录,脉象见独立脉象项。\n\n"; continue; } $expect($sourceSpan !== null, 'Every recorded role has an actual source span.'); [$source, $quote] = $sourceSpan; $first .= $label . ':' . $source['date'] . '“' . $quote . '”(' . $source['id'] . ")。\n\n"; } $head = $first . "二、八纲\n\n"; $check = static function (string $label, string $text, bool $expected, string $code = '') use ($ledger, $index, $head, $expect): array { $original = $text; $guard = Guard::validate($ledger, $text); $contract = Contract::validate($index, $head . $text, true); $expect(($guard['valid'] && $contract['valid']) === $expected, json_encode([$label, $text, $guard, $contract], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); if ($code !== '') $expect(!$guard['valid'] && in_array($code, array_column($guard['issues'], 'code'), true), 'Guard retains the concrete rejection ' . $code . ': ' . $text); $expect($text === $original, 'Pure validation never rewrites original report bytes.'); return ['case' => $label, 'text' => $text, 'expected_pair_valid' => $expected, 'guard_valid' => $guard['valid'], 'contract_valid' => $contract['valid'], 'guard_codes' => array_column($guard['issues'], 'code'), 'as_expected' => (($guard['valid'] && $contract['valid']) === $expected)]; }; $roles = [ '现用二甲双胍0.5g、每日2次,既有医嘱,相关记录见s6,不擅停换。', '不能仅据相关阴性记录反推为纯虚无实。', '适用条件为复核后仍以活动后气短、自汗、乏力、脉无力和少津为核心,且不存在更需优先处理的急性代谢异常、感染或器质性心肺问题。', '如症状突然加重或出现意识异常,应及时按急症评估。', ]; $actuals = [ ['患者有心悸', 'NEGATIVE_TO_POSITIVE'], ['患者无固定刺痛', 'UNKNOWN_TO_NEGATIVE'], ['患者有固定刺痛', 'UNKNOWN_TO_POSITIVE'], ['心悸未记录', 'NEGATIVE_TO_UNKNOWN'], ['口干未记录', 'POSITIVE_TO_UNKNOWN'], ['舌质红绛', 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE'], ['脉滑', 'PULSE_FEATURE_NOT_IN_CURRENT_SOURCE'], ['无五心烦热', 'NEGATION_SCOPE_WIDENED'], ]; $trimEnd = static fn (string $text): string => preg_replace('/[。?]$/u', '', $text) ?? $text; // Exact 75-observation independent review: 42 controls plus all 33 named gaps. $observations = []; foreach ($roles as $role) $observations[] = $check('valid_role', $role, true); foreach ($roles as $n => $role) foreach ($actuals as [$actual, $code]) { $observations[] = $check('ordinary_adjacent_' . $n, $trimEnd($role) . ',' . $actual . '。', false, $code); } foreach ([ '现用二甲双胍0.5g、每日2次,相关记录见s6。', '现用二甲双胍0.6g、每日2次,既有医嘱,相关记录见s6。', '现用二甲双胍0.5g、每日2次,既有医嘱,相关记录见s5。', '现用二甲双胍0.5g、每日2次,既有医嘱,相关记录见s6,(2026-10-22,s6)。', '适用条件为复核后仍以已确认脉滑为核心,且不存在感染。', '如症状突然加重或出现意识异常且患者当前有心悸,应及时按急症评估。', ] as $text) $observations[] = $check('closed_negative_control', $text, false); foreach ([ ['如出现意识异常且患者当前有心悸,应及时按急症评估。', 'NEGATIVE_TO_POSITIVE'], ['如出现意识异常且患者已确认有心悸,应及时按急症评估。', 'NEGATIVE_TO_POSITIVE'], ['如出现意识异常且患者口干未记录,应及时按急症评估。', 'POSITIVE_TO_UNKNOWN'], ['如出现意识异常且当前舌质红绛,应及时按急症评估。', 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE'], ['如出现意识异常且查见患者有心悸,应及时按急症评估。', 'NEGATIVE_TO_POSITIVE'], ] as [$text, $code]) $observations[] = $check('nested_trigger_gap', $text, false, $code); foreach ($roles as $n => $role) { foreach (['[患者有心悸]', '[舌质红绛]', '【患者有心悸】', '〔患者有心悸〕'] as $tail) { $observations[] = $check('bracket_gap_' . $n, $trimEnd($role) . ',' . $tail . '。', false); } foreach (['(s0)', '(s06)', '(来源:s999)'] as $tail) { $observations[] = $check('metadata_gap_' . $n, $trimEnd($role) . ',' . $tail . '。', false, 'SOURCE_ANNOTATION_INVALID'); } } $expect(count($observations) === 75, 'The independent review has exactly 75 unchanged observations.'); if (in_array('--reproduce', $argv, true)) { $gaps = array_values(array_filter($observations, static fn (array $row): bool => !$row['as_expected'])); echo json_encode(['observations' => count($observations), 'as_expected' => count($observations) - count($gaps), 'gaps' => count($gaps), 'gap_classes' => array_count_values(array_map(static fn (array $row): string => preg_replace('/_[0-9]+$/', '', $row['case']) ?? $row['case'], $gaps))], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n"; exit(0); // Diagnostic mode reports baseline gaps; it is never an acceptance pass. } // Prospective short triggers stay neutral, but nested actual facts cannot use generic fallback. foreach (['如出现意识异常,应及时按急症评估。', '如出现心悸,应先行评估。', '如出现固定刺痛,建议复核。', '若出现意识异常,应及时评估。', '如出现意识异常,应及时按急症评估,[参考资料]。'] as $text) $check('neutral_trigger', $text, true); foreach (['且', '并', '并且', '同时', '还', '又', '另'] as $joiner) { foreach ($actuals as [$actual, $code]) { $nestedActual = str_starts_with($actual, '患者') ? $actual : '患者' . $actual; $check('nested_short_trigger', '如出现意识异常' . $joiner . $nestedActual . ',应及时按急症评估。', false, $code); } } foreach ($roles as $role) { foreach ([['[', ']'], ['【', '】'], ['〔', '〕']] as [$open, $close]) { foreach ($actuals as [$actual, $code]) { $check('bracket_preserves_actual', $trimEnd($role) . ',' . $open . $actual . $close . '。', false, $code); $check('preceding_bracket_actual', $open . $actual . $close . ',' . $role, false, $code); } foreach (['s0', 's06', '来源:s999', '来源:s0', 's999'] as $bad) { $check('source_shaped_brackets', $trimEnd($role) . ',' . $open . $bad . $close . '。', false, 'SOURCE_ANNOTATION_INVALID'); } } foreach (['(s0,参考资料)', '(来源:s06,参考资料)', '(s999,患者有心悸)'] as $bad) { $check('mixed_invalid_source', $trimEnd($role) . ',' . $bad . '。', false, 'SOURCE_ANNOTATION_INVALID'); } foreach (['[[患者有心悸]]', '【〔患者有心悸〕】', '〔[舌质红绛]〕'] as $nested) { $check('nested_bracket_actual', $trimEnd($role) . ',' . $nested . '。', false); } foreach (['[[s0]]', '【〔来源:s999〕】', '〔[s06]〕'] as $nested) { $check('nested_bracket_source', $trimEnd($role) . ',' . $nested . '。', false, 'SOURCE_ANNOTATION_INVALID'); } } foreach (['**口干**。', '__口干__。', '`口干`。', '[口干]。', '【口干】。', '〔口干〕。', '[参考资料](https://example.test/reference)。', '“本次询问无已知药物过敏史”(s5)。', '口干【来源:现病史】。', '口干(来源:current.present_illness)。', '口干[s2]。', '口干【s2】。', '口干〔s2〕。', '口干[s2](#s2)。', '口干[现病史](#s2)。', '口干[参考](https://example.test/reference)。', '是否存在心悸?', '固定刺痛未记录。'] as $text) $check('valid_markup_or_reference', $text, true); // Guard supports these existing annotation forms; Contract still requires its // original canonical quote/ID protocol. No broader Contract grammar is added. foreach (['“本次询问无已知药物过敏史”(来源:s5)。', '“本次询问无已知药物过敏史”(2026-10-23,[s5])。'] as $text) { $expect(Guard::validate($ledger, $text)['valid'], 'Existing Guard source annotation semantics are retained.'); } foreach (['[患者有心悸](https://example.test/reference)。', '口干[s0](#s0)。', '口干[资料](#s999)。', '口干[资料](#s06)。'] as $text) $check('link_preserves_label_or_source', $text, false); foreach (['(s999)', '(s0)', '(s06)', '(来源:s999)', '(来源:s06)', '(2026-10-22,s6)', '(2026-13-99,s6)', '(2026-10-23,2026-10-22,s6)'] as $bad) { $check('source_date_scope', $roles[0] . $bad . '。', false, 'SOURCE_ANNOTATION_INVALID'); } foreach (['无五心烦热。', '[无五心烦热]。', '【无急性感染诱因】。'] as $text) { $check('qualifier_widening', $text, false, 'NEGATION_SCOPE_WIDENED'); } foreach (['[患者无固定刺痛]。', '【患者无腰酸耳鸣】。'] as $text) $check('missing_stays_unknown', $text, false, 'UNKNOWN_TO_NEGATIVE'); $unknown = Guard::build(['tongue' => '未记录', 'pulse' => '未记录']); foreach (['[舌象正常]。', '【脉象无异常】。', '〔舌象无异常〕。'] as $text) { $expect(!Guard::validate($unknown, $text)['valid'], 'Bracketed missing exam cannot become normal/negative.'); } // Compare the actual source builder and shared source cleanup against frozen r22a. $baselinePath = dirname(__DIR__, 2) . '/app/artifacts/ai_concurrency/v43r22a_before_boundary_TcmClinicalFactStateGuard.php.snapshot'; if (is_file($baselinePath)) { $baselineSource = (string) file_get_contents($baselinePath); $expect(hash('sha256', $baselineSource) === '66023aee7f0a7a724b45a8bf9a7042c95db0c408a1e216b62154cc7bad133065', 'Exact r22a baseline snapshot.'); $reference = str_replace('final class TcmClinicalFactStateGuard', 'final class R22RoleBoundaryBaseline', $baselineSource); eval(substr($reference, strlen(' '2026-10-23', 'present_illness' => $sourceText, 'tongue' => '舌淡红,苔少。', 'pulse' => '脉细弱。']; $expect(serialize(Guard::build($sourceRecord)) === serialize($referenceClass::build($sourceRecord)), 'Bracket/link/source-shaped clinical source ledger is byte-identical to r22a.'); $expect((new ReflectionMethod(Guard::class, 'cleanText'))->invoke(null, $sourceText) === (new ReflectionMethod($referenceClass, 'cleanText'))->invoke(null, $sourceText), 'Shared source cleanText behavior is unchanged.'); } } $expect(serialize($ledger) === $ledgerBytes, 'Every validation preserves the complete original ledger.'); $expect(!class_exists('app\\adminapi\\service\\TcmClinicalReportStream', false) && !class_exists('app\\adminapi\\logic\\tcm\\DiagnosisAiLogic', false), 'Pure test loads no stream/diagnosis logic.'); if ($failures !== []) { fwrite(STDERR, 'TCM r22 boundary: ' . count($failures) . ' failures / ' . $count . " assertions\n" . implode("\n", $failures) . "\n"); exit(1); } echo "TCM r22 boundary: {$count} assertions passed; all 75 independent observations correct (pure Guard/Contract; immutable source ledger).\n";