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); $sourceHash = hash('sha256', json_encode($ledger, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); $expect($sourceHash === '2ebbec0b558347bf4ee128dfb5a9e63fc4e355d58f7226486a2ae96098bd57f6', 'Immutable complete source projection, including all original IDs, qualifiers, scopes and dates.'); $expect(hash_file('sha256', __DIR__ . '/fixtures/CompleteTcmCase.php') === 'b54c47ec11f6a99dc87bdda78629e18c8a6f37a64017e42a72db399c727cb1c6', 'Immutable fixture bytes.'); $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, 'A recorded role has its actual current source.'); [$source, $quote] = $sourceSpan; $first .= $label . ':' . $source['date'] . '“' . $quote . '”(' . $source['id'] . ")。\n\n"; } $head = $first . "二、八纲\n\n"; $check = static function (string $text, bool $valid, string $code = '', bool $contract = false) use ($ledger, $index, $head, $expect): array { $original = $text; $result = $contract ? Contract::validate($index, $head . $text, true) : Guard::validate($ledger, $text); $expect($result['valid'] === $valid, ($contract ? 'Contract ' : 'Guard ') . json_encode([$text, $result], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); if ($code !== '') $expect(in_array($code, array_column($result['issues'], 'code'), true), 'Expected ' . $code . ': ' . $text); $expect($text === $original, 'Every original report byte survives pure validation.'); return $result; }; $medication = '现用二甲双胍0.5g、每日2次,既有医嘱,相关记录见s6,不擅停换。'; $inference = '不能仅据相关阴性记录反推为纯虚无实。'; $question = '适用前须核实:是否存在近期新发发热、恶寒、咳痰、胸闷或腹胀纳差等可能提示外邪或邪实未清的表现?'; $future = '适用条件为复核后仍以活动后气短、自汗、乏力、脉无力和少津为核心,且不存在更需优先处理的急性代谢异常、感染或器质性心肺问题。'; $trigger = '如症状突然加重或出现意识异常,应及时按急症评估。'; $reproductions = ['medication' => $medication, 'inference' => $inference, 'question' => $question, 'future' => $future, 'trigger' => $trigger]; if (in_array('--reproduce', $argv, true)) { foreach ($reproductions as $label => $text) { echo json_encode(['case' => $label, 'text' => $text, 'guard' => Guard::validate($ledger, $text), 'contract' => Contract::validate($index, $head . $text, true)], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; } $clauses = new ReflectionMethod(Guard::class, 'reportClauses'); foreach (['tcm_complete_v43r21_cap8_openai_deadline_20261024_openai_next_full90_openai', 'tcm_complete_v43r21_cap8_single_openai_20261024_0_openai', 'tcm_complete_v43r21_cap8_mixed_c4_20261024_3_openai'] as $stem) { $path = dirname(__DIR__, 2) . '/app/artifacts/ai_concurrency/' . $stem . '.rejected.json'; if (!is_file($path)) continue; $saved = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR); foreach ($saved['rejections'] as $n => $rejection) { echo json_encode(['retained' => $stem . '#' . $n, 'sha256' => hash('sha256', $rejection['unit']), 'guard' => Guard::validate($ledger, $rejection['unit']), 'contract' => Contract::validate($index, $head . $rejection['unit'], true)], JSON_UNESCAPED_UNICODE) . "\n"; foreach ($clauses->invoke(null, $ledger, $rejection['unit']) as $entry) { $result = Guard::validate($ledger, $entry['text']); if (!$result['valid'] || $entry['annotation_invalid']) echo json_encode(['held_view' => $entry, 'guard' => $result], JSON_UNESCAPED_UNICODE) . "\n"; } } } exit($failures === [] ? 0 : 1); } foreach ($reproductions as $text) { $check($text, true); $check($text, true, '', true); } // Exact original retained units: do not alter snapshots or infer a successful live result. $retained = [ ['tcm_complete_v43r21_cap8_mixed_c4_20261024_3_openai', 1, 'dba0a7500da83bfbcf4c6c6845f7e4ad7aa78d701f9e6054a5fac4c004691617'], ['tcm_complete_v43r21_cap8_openai_deadline_20261024_openai_next_full90_openai', 0, '492602512704b309ba07b06d0a47d509b3c9b4cdb147e3ef43741247c31dfb9e'], ['tcm_complete_v43r21_cap8_openai_deadline_20261024_openai_next_full90_openai', 1, '0033369090211c54c6af2d62e792dd0cb7b7d776d0f5e547b6070fca8e4a6728'], ['tcm_complete_v43r21_cap8_single_openai_20261024_0_openai', 0, 'bbc1eddd8df19c1b5546990975867b8056d0241e235783e56fdba6de685e2b11'], ]; foreach ($retained as [$stem, $number, $hash]) { $path = dirname(__DIR__, 2) . '/app/artifacts/ai_concurrency/' . $stem . '.rejected.json'; if (!is_file($path)) continue; $saved = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR); $unit = $saved['rejections'][$number]['unit']; if ($hash !== '') $expect(hash('sha256', $unit) === $hash, 'Exact retained source bytes: ' . $stem); $check($unit, true); $check($unit, true, '', true); } $assertions = [ ['患者有心悸', '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'], ]; foreach ([$medication, $inference, $question, $future, $trigger] as $role) { $base = preg_replace('/[。?]$/u', '', $role) ?? $role; foreach ([',', ',', '且', '但', '并', '并且', '同时', '还', '又', '另'] as $joiner) { foreach ($assertions as [$assertion, $code]) { $check($base . $joiner . $assertion . '。', false, $code); } } foreach ($assertions as [$assertion, $code]) $check($assertion . ',' . $base . '。', false, $code); } foreach (['s5', 's30', 's999', 's0'] as $id) $check(str_replace('s6', $id, $medication), false, 'SOURCE_ANNOTATION_INVALID'); foreach ([ '相关记录见s6。', '既有医嘱,相关记录见s6。', '口干,相关记录见s6。', '现用二甲双胍0.6g、每日2次,既有医嘱,相关记录见s6。', '现用二甲双胍0.5g、每日3次,既有医嘱,相关记录见s6。', '现用二甲双胍0.5g、每日2次,已停用,相关记录见s6。', '历史现用二甲双胍0.5g、每日2次,既有医嘱,相关记录见s6。', '2026-10-22现用二甲双胍0.5g、每日2次,既有医嘱,相关记录见s6。', ] as $text) $check($text, false, 'SOURCE_ANNOTATION_INVALID'); foreach ([$question, $future, $medication, $inference, $trigger] as $role) { foreach (['(s999)', '(2026-10-22,s2)', '(2026-13-99,s2)'] as $annotation) { $check((preg_replace('/[。?]$/u', '', $role) ?? $role) . $annotation . '。', false, 'SOURCE_ANNOTATION_INVALID'); } } foreach ([ ['适用条件为仍以乏力为核心,且不存在感染。', 'NEGATION_SCOPE_WIDENED'], ['适用条件为已复核后仍以乏力为核心,且不存在感染。', 'NEGATION_SCOPE_WIDENED'], ['适用条件为复核后仍以患者乏力为核心,且不存在感染。', 'NEGATION_SCOPE_WIDENED'], ['适用条件为复核后仍以当前乏力为核心,且不存在感染。', 'NEGATION_SCOPE_WIDENED'], ['适用条件为复核后仍以口干未记录为核心,且不存在感染。', ''], ['适用条件为复核后仍以乏力为核心,口干未记录,且不存在感染。', 'POSITIVE_TO_UNKNOWN'], ['如症状突然加重或出现意识异常。', 'NEGATIVE_TO_POSITIVE'], ['如症状突然加重或出现意识异常,可考虑观察。', 'NEGATIVE_TO_POSITIVE'], ['如症状突然加重或出现患者有心悸,应及时按急症评估。', 'NEGATIVE_TO_POSITIVE'], ['如症状突然加重或出现意识异常且患者有心悸,应及时按急症评估。', 'NEGATIVE_TO_POSITIVE'], ['如症状突然加重或出现意识异常,口干未记录,应及时按急症评估。', 'POSITIVE_TO_UNKNOWN'], ] as [$text, $code]) $check($text, false, $code); foreach (['表', '里', '寒', '热', '虚', '实', '阴', '阳'] as $category) { $check('不能仅据相关阴性记录反推为纯虚无' . $category . '。', true); $check('不能仅据相关阴性记录反推为纯虚无' . $category . '。', true, '', true); } foreach (['2026-10-23“无固定刺痛”(s2)。', '2026-10-22“无咳嗽咯痰、胸痛心悸、浮肿或腹泻。”(s2)。', '2026-10-23“无咳嗽咯痰、胸痛心悸、浮肿或腹泻。”(s999)。', '2026-10-23“无五心烦热、潮热盗汗,无畏寒肢冷。”(s2)。'] as $denial) { $check($inference . $denial, false, '', true); } foreach (['无明显急性感染诱因', '无明显五心烦热'] as $qualified) { $check($qualified . '。', true); $check(str_replace('明显', '', $qualified) . '。', false, 'NEGATION_SCOPE_WIDENED'); $check('2026-10-23“' . str_replace('明显', '', $qualified) . '”(s2)。', false, '', true); } foreach ([ '不能仅据相关阴性记录反推为患者无固定刺痛。', '不能仅据相关阴性记录反推为纯虚无实且患者无固定刺痛。', '并非不能仅据相关阴性记录反推为纯虚无实。', '不能不据相关阴性记录反推为纯虚无实。', '患者不能仅据相关阴性记录反推为纯虚无实。', ] as $text) $check($text, false, 'UNQUOTED_PATIENT_DENIAL', true); foreach (['患者无固定刺痛。', '患者无腰酸耳鸣。'] as $text) $check($text, false, 'UNKNOWN_TO_NEGATIVE'); $unknown = Guard::build(['diagnosis_date' => '2026-10-23', 'tongue' => '未记录', 'pulse' => '未记录']); foreach (['舌象正常。', '脉象正常。', '舌象无异常。', '脉象无异常。'] as $text) { $result = Guard::validate($unknown, $text); $expect(!$result['valid'], 'Unknown exam never becomes negative or normal: ' . $text); } foreach (['tcm_complete_v43r21_cap8_single_qwen_20261024_0_qwen', 'tcm_complete_v43r21_cap8_mixed_c4_20261024_0_qwen', 'tcm_complete_v43r21_cap8_mixed_c4_20261024_1_qwen', 'tcm_complete_v43r21_cap8_qwen_deadline_20261024_qwen_next_full90_qwen'] as $stem) { $path = dirname(__DIR__, 2) . '/app/artifacts/ai_concurrency/' . $stem . '.rejected.json'; if (!is_file($path)) continue; $saved = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR); foreach ($saved['rejections'] as $entry) $check($entry['unit'], false, 'NEGATION_SCOPE_WIDENED'); } $expect(hash('sha256', json_encode($ledger, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) === $sourceHash, 'Validation preserves every ledger byte.'); $expect(!class_exists('app\\adminapi\\service\\TcmClinicalReportStream', false) && !class_exists('app\\adminapi\\logic\\tcm\\DiagnosisAiLogic', false), 'No peer stream/diagnosis logic is loaded.'); if ($failures !== []) { fwrite(STDERR, 'TCM r21 rejected role: ' . count($failures) . ' failures / ' . $count . " assertions\n" . implode("\n", $failures) . "\n"); exit(1); } echo "TCM r21 rejected role: {$count} assertions passed (pure production Guard/Contract; no model/network).\n";