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

199 lines
13 KiB
PHP
Raw Permalink 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);
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\TcmClinicalEvidenceContract as Contract;
use app\adminapi\service\TcmClinicalEvidenceIndex as Index;
use app\adminapi\service\TcmClinicalFactStateGuard as Guard;
$count = 0;
$failures = [];
$expect = static function (bool $ok, string $message) use (&$count, &$failures): void {
++$count;
if (!$ok) $failures[] = $message;
};
$snapshot = (new ReflectionMethod(PatientAiReportLogic::class, 'buildSourceSnapshotFromRows'))->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('<?php')));
$referenceClass = 'app\\adminapi\\service\\R22RoleBoundaryBaseline';
$expect(serialize($referenceClass::build(CompleteTcmCase::current(), $submission)) === $ledgerBytes,
'Full immutable ledger equals frozen source construction.');
foreach (['口干。[患者有心悸];【患者无固定刺痛】;〔舌质红绛〕。',
'口干[s0];【来源:s999】;〔s06〕;无明显胸痛。',
'[口干](https://example.test/reference);未记录固定刺痛。'] as $sourceText) {
$sourceRecord = ['diagnosis_date' => '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";