Files
zyt/server/tests/TcmR25bFinalIndependentRoleReviewTest.php
T
2026-10-27 14:03:35 +08:00

232 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
// Independent offline review. No inference Logic, framework/bootstrap, config,
// provider, network or runtime lifecycle file is used.
require dirname(__DIR__) . '/vendor/autoload.php';
require __DIR__ . '/fixtures/CompleteTcmCase.php';
use app\adminapi\logic\tcm\PatientAiReportLogic;
use app\adminapi\service\AiRequiredSource;
use app\adminapi\service\TcmClinicalEvidenceIndex as Index;
use app\adminapi\service\TcmClinicalEvidenceContract as Contract;
use app\adminapi\service\TcmClinicalFactStateGuard as Guard;
use app\adminapi\service\TcmClinicalReportStream as Stream;
$artifacts = dirname(__DIR__, 2) . '/app/artifacts/ai_concurrency/';
$checks = 0; $failures = []; $observations = [];
$expect = static function (bool $ok, string $message) use (&$checks, &$failures): void { ++$checks; if (!$ok) $failures[] = $message; };
$snapshots = [
'TcmClinicalFactStateGuard' => '71d788f9ede5ee2b3a7259ac9493716c7b635f93cfa6a81ef1d6a29b4d755b63',
'TcmClinicalEvidenceContract' => 'c25e88fcc0633e957cf3a7aca4b417140b9ce9766e745c0d7b1bc2528aaaa509',
];
foreach ($snapshots as $class => $hash) {
$source = file_get_contents($artifacts . 'v43r25a_' . $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('<?php')));
}
$beforeGuard = 'app\\adminapi\\service\\IndependentBeforeTcmClinicalFactStateGuard';
$beforeContract = 'app\\adminapi\\service\\IndependentBeforeTcmClinicalEvidenceContract';
$raw = (new ReflectionMethod(PatientAiReportLogic::class, 'buildSourceSnapshotFromRows'))->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);
// NEW independent r25b local-list/citation boundaries, declared before replay.
foreach ([
'脉细弱,无力(s10)。', '脉细弱,按之无力(s10)。',
'舌质淡红,少津(s9)。', '舌质淡红(s9),少津(s9)。',
'脉细弱(s10),无力(s10)。', '脉细弱无力(s10),舌质淡红(s9)。',
'舌质淡红、脉细弱无力(s9、s10)。',
] as $number => $unit) $case('r25b_valid_own_source_' . $number, $unit, true);
foreach ([
'脉细弱,无力(s14)。', '脉细弱,无力(s3)。',
'舌质淡红,少津(s3)。', '舌质淡红(s9),少津(s3)。',
'脉不浮,不滑,不紧(s14)。',
'脉细弱,无力(s14),口干(s10)。',
'脉细弱(s10),无力(s14)。',
'脉细弱,按之无力(s14)。',
'舌质淡红、脉细弱无力(s9)。', '舌质淡红、脉细弱无力(s10)。',
'脉细弱无力(s3、s14),舌质淡红(s9)。',
'舌质淡红(s9),脉细弱无力(s3、s14)。',
'本次脉细弱无力(2020-05-02,s10)。',
'脉细弱无力(s10),按之有力(s10)。',
] as $number => $unit) $case('r25b_wrong_local_source_or_state_' . $number, $unit, false);
$extraSource = $ledger;
$extraSource['sources']['s40'] = $ledger['sources']['s10'];
$extraSource['sources']['s40']['id'] = 's40';
foreach ([
'historical_scope' => ['scope' => 'historical', 'path' => 'history.0.pulse'],
'wrong_date' => ['date' => '2020-05-02'],
'opposite_state' => ['quote' => '本次医师切诊:不细、不弱,有力。'],
'unknown_record' => ['quote' => '未记录'],
'historical_clause' => ['quote' => '既往医师切诊:脉细弱无力。'],
] as $name => $delta) {
$variantLedger = $extraSource;
$variantLedger['sources']['s40'] = array_replace($variantLedger['sources']['s40'], $delta);
$case('r25b_source_cannot_launder_' . $name, '本次脉细弱无力(s40)。', false, $variantLedger);
}
$split = CompleteTcmCase::current();
$split['pulse'] = '本次医师切诊:脉细弱。';
$split['pulse_condition'] = '本次医师切诊:按之无力。';
$splitLedger = Guard::build($split);
$ids = [];
foreach ($splitLedger['sources'] as $source) if (in_array($source['field'], ['pulse', 'pulse_condition'], true)) $ids[$source['field']] = $source['id'];
$case('r25b_real_split_alias_union', '脉细弱无力(' . $ids['pulse'] . '、' . $ids['pulse_condition'] . ')。', true, $splitLedger);
$case('r25b_alias_field_not_feature_support', '脉细弱无力(' . $ids['pulse_condition'] . ')。', false, $splitLedger);
$case('r25b_cited_pulse_then_actual_force_alias', '脉细弱(' . $ids['pulse'] . '),按之无力(' . $ids['pulse_condition'] . ')。', true, $splitLedger);
$case('r25b_force_cannot_borrow_prior_cited_weak_source', '脉细弱(' . $ids['pulse'] . '),按之无力(' . $ids['pulse'] . ')。', false, $splitLedger);
foreach (['既往脉细弱无力(s10)。', '既往脉细弱无力(2020-05-02,s10)。',
'既往脉细弱无力(s10),本次脉细弱无力(s10)。'] as $number => $unit)
$case('r25b_historical_claim_cannot_cite_current_' . $number, $unit, false);
$validHistorical = $extraSource;
$validHistorical['sources']['s40'] = array_replace($validHistorical['sources']['s40'],
['scope' => 'historical', 'path' => 'history.0.pulse', 'date' => '2020-05-02', 'quote' => '2020-05-02医师切诊:脉细弱无力。']);
$case('r25b_valid_historical_own_source', '既往脉细弱无力(2020-05-02,s40)。', true, $validHistorical);
// 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 r25b final citation/role 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);