'13f4b422c1070732ec5e3ec84a191628f2770c804ff0eb778ab73562aeb3dac6', 'TcmClinicalEvidenceContract' => 'c810681372543091b0ff8073e6b51a6fd5d210521d72ab22f0637caf311453ab', ]; foreach ($snapshots as $class => $hash) { $source = file_get_contents($artifacts . 'v43r25_before_' . $class . '.php.snapshot'); $expect(hash('sha256', $source) === $hash, 'before snapshot exact identity ' . $class); if (hash('sha256', $source) !== $hash) throw new RuntimeException('Unverified before snapshot'); $source = preg_replace('/final class ' . $class . '\b/', 'final class IndependentBefore' . $class, $source, 1, $replaced); $expect($replaced === 1, 'before class loaded under distinct review-only name ' . $class); eval(substr($source, strlen('invoke(null, CompleteTcmCase::rows()); $submission = (new ReflectionMethod(PatientAiReportLogic::class, 'sanitizeSnapshotForUpstream'))->invoke(null, AiRequiredSource::submission($raw)); $ledger = Guard::build(CompleteTcmCase::current(), $submission); $index = Index::build($ledger); $sourceIdentity = hash('sha256', serialize($ledger)); $expect(serialize($ledger) === serialize($beforeGuard::build(CompleteTcmCase::current(), $submission)), 'before/current canonical source ledgers are byte identical'); $expect(hash_file('sha256', __DIR__ . '/fixtures/CompleteTcmCase.php') === 'b54c47ec11f6a99dc87bdda78629e18c8a6f37a64017e42a72db399c727cb1c6', 'immutable source fixture'); $makeHead = static function (array $inputIndex): string { $prefix = "一、四诊\n\n"; foreach (Index::ROLES as $label => $role) { $chosen = null; foreach ($inputIndex['sources'] as $source) { if ($source['scope'] !== 'current') continue; foreach ($source['spans'] as $span) if ($span['role'] === $role && $span['recorded']) { $chosen = [$source, $span]; break 2; } } if ($chosen === null) { $prefix .= $label . ($role === 'palpation' ? ":其他触诊未记录,脉象见独立脉象项。\n\n" : ":未记录\n\n"); } else { [$source, $span] = $chosen; $prefix .= $label . ':' . $source['date'] . '“' . trim($span['quote']) . '”(' . $source['id'] . ")。\n\n"; } } return $prefix . "二、八纲\n\n"; }; $head = $makeHead($index); $expect(Guard::validate($ledger, $head)['valid'] && Contract::validate($index, $head, true)['valid'], 'independent full six-role candidate prefix is valid'); $case = static function (string $name, string $unit, bool $valid, ?array $customLedger = null, bool $streaming = true) use ( $ledger, $index, $head, $makeHead, $beforeGuard, $beforeContract, $expect, &$observations): void { $input = $customLedger ?? $ledger; $inputIndex = $customLedger === null ? $index : Index::build($input); $inputHead = $customLedger === null ? $head : $makeHead($inputIndex); $expect(Guard::validate($input, $inputHead)['valid'] && Contract::validate($inputIndex, $inputHead, true)['valid'], $name . ': fixture prefix itself is valid'); $g = Guard::validate($input, $inputHead . $unit); $c = Contract::validate($inputIndex, $inputHead . $unit, true); $oldG = $beforeGuard::validate($input, $inputHead . $unit); $oldC = $beforeContract::validate($inputIndex, $inputHead . $unit, true); $actual = $g['valid'] && $c['valid']; $expect($actual === $valid, $name . ': composed expected ' . (int) $valid . ', got ' . json_encode([$g, $c])); $entry = ['label' => $name, 'unit_sha256' => hash('sha256', $unit), 'expected_composed_valid' => $valid, 'before_guard_valid' => $oldG['valid'], 'before_contract_valid' => $oldC['valid'], 'before_guard_issues' => $oldG['issues'], 'before_contract_issues' => $oldC['issues'], 'after_guard_valid' => $g['valid'], 'after_contract_valid' => $c['valid'], 'after_guard_issues' => $g['issues'], 'after_contract_issues' => $c['issues']]; if ($streaming) foreach ([1, 13, 47] as $size) { $visible = ''; $reader = new Stream($input, static function (string $delta) use (&$visible): bool { $visible .= $delta; return true; }, null, $inputHead, null, $inputIndex); foreach (mb_str_split($unit . "\n\n", $size, 'UTF-8') as $chunk) if (!$reader->push($chunk)) break; $done = $reader->finish(); $expect($done === $valid && $reader->rejected() === !$valid, $name . ': actual composed stream at chunk ' . $size); $expect($valid ? $visible === $unit . "\n\n" : $visible === '', $name . ': accepted bytes exact / rejected unit unreleased'); } $observations[] = $entry; }; $abstracts = ['不能仅据相关阴性反推为绝对无实', '该限制不等同于证明纯虚无夹杂', '病位不在表,属里证']; foreach ($abstracts as $number => $abstract) { $case('abstract_allowed_' . $number, $abstract . '。', true); foreach ([',患者无固定刺痛。', '且患者无固定刺痛。', '并患者有固定刺痛。', '患者有腰酸耳鸣。', ',脉滑。', ',舌质红绛。', '而患者无急性感染诱因。', ';本次脉细弱有力。', ',口干未记录。'] as $tailNumber => $tail) $case('abstract_keeps_tail_' . $number . '_' . $tailNumber, $abstract . $tail, false); } foreach ([ '并非不能仅据相关阴性反推为绝对无实。', '不能不据相关阴性反推为绝对无实。', '不能仅据相关阴性反推为绝对无实性皮疹。', '不能仅据相关阴性反推为绝对无实且患者无固定刺痛。', '该限制不等同于证明纯虚无夹杂为事实。', '并非该限制不等同于证明纯虚无夹杂。', '该限制不等同于证明纯虚无夹杂(s999)。', '该限制不等同于证明纯虚无夹杂(s0)。', '该限制不等同于证明纯虚无夹杂(2020-05-02,s2)。', '病位不在表,属里证,患者无腰酸耳鸣或固定刺痛。', '病位不在表,属里证,纯虚无夹杂。', ] as $number => $unit) $case('abstract_no_outer_or_clinical_exemption_' . $number, $unit, false); $case('epistemic_annotation_existing_sources', '该限制不等同于证明纯虚无夹杂(s2、s3)。', true); $pulse = '气虚、卫表不固证,依据为动则气短、自汗活动后加重及脉细弱无力(s1、s2、s10)。'; $case('pulse_own_positive_evidence_scope', $pulse, true); $case('pulse_minimal_wrong_historical_source', '脉细弱无力(s14)。', false); $case('pulse_summary_wrong_current_role_source', '卫表不固证,依据为脉细弱无力(s3)。', false); foreach (['脉细弱有力', '脉细弱滑无力', '脉细弱无力及脉结', '脉细弱无力但脉浮', '脉不细弱无力'] as $number => $feature) $case('pulse_no_feature_widening_' . $number, str_replace('脉细弱无力', $feature, $pulse), false); foreach (['患者无气短,依据为脉细弱无力(s10)。', $pulse . '患者无自汗。', $pulse . '患者有固定刺痛。', '卫表不固证,依据为脉细弱无力(2020-05-02,s10)。', '卫表不固证,依据为脉细弱无力(s14)。', '卫表不固证,依据为患者无固定刺痛、脉细弱无力。'] as $number => $unit) $case('pulse_scope_preserves_other_rejection_' . $number, $unit, false); foreach (['weak_only' => '本次医师切诊:脉细弱。', 'missing' => null] as $name => $record) { $variant = CompleteTcmCase::current(); $variant['pulse'] = $record; $case('pulse_no_borrow_' . $name, $pulse, false, Guard::build($variant, $submission)); } $locator = '腰肾相关资料见s2,暂不定肾虚'; $case('verified_exact_locator', $locator . '。', true); foreach (['s0', 's02', 's3', 's10', 's14', 's999', 's2x', 's-2'] as $number => $id) $case('locator_bad_id_' . $number, str_replace('s2', $id, $locator) . '。', false); foreach ([',患者无腰酸耳鸣。', '且患者有腰酸耳鸣。', '并患者有固定刺痛。', '而脉滑。', '(2020-05-02,s2)。'] as $number => $tail) $case('locator_cannot_hide_claim_' . $number, $locator . $tail, false); $noLumbar = CompleteTcmCase::current(); $noLumbar['present_illness'] = '神疲乏力,未记录固定刺痛。'; $case('locator_actual_subject_required', $locator . '。', false, Guard::build($noLumbar, $submission)); $historicalLedger = $ledger; $historicalLedger['sources']['s2']['scope'] = 'historical'; $case('locator_same_date_historical_not_current', $locator . '。', false, $historicalLedger); $future = '适用条件:复核症状仍以活动后乏力、气短、自汗为主,口干少量频饮及少津舌持续存在,且无需要优先处理的急性病因'; $case('future_exact_acute_prerequisite', $future . '。', true); $case('future_without_optional_persistence', '适用条件:复核症状仍以活动后乏力为主,且无需要优先处理的急性病因。', true); foreach ([',患者无固定刺痛。', '并无腰酸耳鸣。', '且患者有固定刺痛。', '患者无胸痛。', ',脉滑。', ',舌质红绛。', '且无急性感染诱因。', ';口干未记录。', '这已被本次排除。'] as $number => $tail) $case('future_keeps_actual_tail_' . $number, $future . $tail, false); foreach ([ str_replace('复核症状', '本次复核症状', $future), str_replace('且无需要', '且本次无需要', $future), str_replace('且无需要', '且已确认无需要', $future), str_replace('复核症状仍以', '复核患者自述仍以', $future), str_replace('口干少量频饮及少津舌', '脉滑', $future), str_replace('口干少量频饮及少津舌', '无固定刺痛', $future), str_replace('活动后乏力、气短、自汗', '患者无固定刺痛', $future), str_replace('活动后乏力、气短、自汗', '脉滑', $future), '并非' . $future, $future . '(s2)', ] as $number => $unit) $case('future_no_current_or_target_leak_' . $number, $unit . '。', false); // Strictly require every original file and full decoded unit; no optional skips. $files = [ 'single_openai_20261024_0_openai' => ['39056e3750c9b385f543db7da351b47657d997c5ac705e48cbd93f088b4c178e', 2, true], 'double_openai_20261024_0_openai' => ['0c03a7a8a764179e4535ea40c0c608e811598a2259ae9aefec3c3bdc3959f543', 1, true], 'double_openai_20261024_1_openai' => ['e1e5a7a26aae0f7443b00c6dc1f4db2ad0f97006fc84fd91bb0171423c21c252', 1, true], 'openai_deadline_20261024_openai_next_full90_openai' => ['084a59af11c14b06d6a9d5664db53a87617c5e0083bcd6640b52c2c8d1d7a21c', 1, true], 'single_qwen_20261024_0_qwen' => ['55ee0125d1cec5ccc344d9fef747e118d9e55b8147fd901e819d8a4a38408b8a', 2, false], 'double_qwen_20261024_0_qwen' => ['2e7cce04ca83526a17c3cda27764b2386e192df35b36ef37f3dd47f622f561ba', 2, false], 'double_qwen_20261024_1_qwen' => ['0d803a89c349f10f1317080c7232379c59b7e1f291e514b373e63960819d4b85', 2, false], ]; $retainedCount = 0; $wideningCount = 0; foreach ($files as $stem => [$hash, $count, $valid]) { $path = $artifacts . 'tcm_complete_v43r24_' . $stem . '.rejected.json'; if (!is_file($path)) throw new RuntimeException('Missing original evidence file'); $expect(hash_file('sha256', $path) === $hash, 'exact original rejection file ' . $stem); $document = json_decode(file_get_contents($path), true, 512, JSON_THROW_ON_ERROR); $expect(count($document['rejections']) === $count, 'all retained units present ' . $stem); foreach ($document['rejections'] as $number => $unit) { ++$retainedCount; $case('original_r24_' . $stem . '_' . $number, $unit['unit'], $valid); $g = Guard::validate($ledger, $unit['unit']); if (in_array('NEGATION_SCOPE_WIDENED', array_column($g['issues'], 'code'), true)) ++$wideningCount; } $expect(hash_file('sha256', $path) === $hash, 'retained artifact unchanged ' . $stem); } $expect($retainedCount === 11 && $wideningCount === 5, 'exact 11 retained units and all five actual Q degree deletions preserved'); $expect(hash('sha256', serialize($ledger)) === $sourceIdentity, 'source ledger unchanged by all validation/replay paths'); $expect(!class_exists('app\\adminapi\\logic\\tcm\\DiagnosisAiLogic', false), 'inference Logic never loaded'); echo json_encode(['scope' => 'independent r25 role/pulse composed offline review', 'assertions' => $checks, 'failures' => $failures, 'observations' => $observations, 'retained_units' => $retainedCount, 'qwen_real_degree_loss_still_rejected' => $wideningCount], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), "\n"; exit($failures === [] ? 0 : 1);