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

194 lines
14 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 finite-protocol review: autoload/reflection only; no runtime,
// application bootstrap, configuration, provider or inference Logic.
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;
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 $label) use (&$checks, &$failures): void {
++$checks;
if (!$ok) $failures[] = $label;
};
$pins = [
Guard::class => 'a4b7f13452d8d884699041a8f5daa2889b366ea05985d39febd405873fbcd8fd',
Contract::class => 'c25e88fcc0633e957cf3a7aca4b417140b9ce9766e745c0d7b1bc2528aaaa509',
Index::class => '4597475de68f8a45d14f4d309e3c4fadf9e585dd1dafab39b3df8319ea05f6e7',
Stream::class => '3b0664a0e988e8a60920e2f9c1885bc13b5d8f644156522a9db4a38409ffae5a',
];
foreach ($pins as $class => $hash) {
$path = (new ReflectionClass($class))->getFileName();
$expect(hash_file('sha256', $path) === $hash, 'exact final source pin ' . $class);
}
$before = file_get_contents($artifacts . 'v43r25d_before_TcmClinicalFactStateGuard.php.snapshot');
if (hash('sha256', $before) !== 'e139ff33772ec37afacda14fb1866878c2b1572f4d0788ccc526cb0aab49cc2c') {
throw new RuntimeException('Unverified before snapshot');
}
$before = preg_replace('/final class TcmClinicalFactStateGuard\b/', 'final class IndependentR25eBeforeGuard', $before, 1, $replaced);
$expect($replaced === 1, 'before snapshot isolated class only');
eval(substr($before, strlen('<?php')));
$oldGuard = 'app\\adminapi\\service\\IndependentR25eBeforeGuard';
$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);
$ledgerBytes = serialize($ledger);
$expect($ledgerBytes === serialize($oldGuard::build(CompleteTcmCase::current(), $submission)), 'before/current ledger construction exact');
$expect(hash('sha256', json_encode($ledger, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES))
=== '2ebbec0b558347bf4ee128dfb5a9e63fc4e355d58f7226486a2ae96098bd57f6', 'actual complete source ledger identity');
$expect(hash_file('sha256', __DIR__ . '/fixtures/CompleteTcmCase.php')
=== 'b54c47ec11f6a99dc87bdda78629e18c8a6f37a64017e42a72db399c727cb1c6', 'fixture unchanged');
$headFor = static function (array $index): string {
$head = "一、四诊\n\n";
foreach (Index::ROLES as $label => $role) {
$chosen = null;
foreach ($index['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) {
$head .= $label . ($role === 'palpation' ? ":其他触诊未记录,脉象见独立脉象项。\n\n" : ":未记录\n\n");
} else {
[$source, $span] = $chosen;
$head .= $label . ':' . $source['date'] . '“' . trim($span['quote']) . '”(' . $source['id'] . ")。\n\n";
}
}
return $head . "二、八纲\n\n";
};
$case = static function (string $label, string $unit, bool $expected, ?array $custom = null) use (
$ledger, $headFor, $oldGuard, $expect, &$observations): void {
$input = $custom ?? $ledger;
$inputBytes = serialize($input);
$originalUnit = $unit;
$index = Index::build($input);
$head = $headFor($index);
$expect(Guard::validate($input, $head)['valid'] && Contract::validate($index, $head, true)['valid'], $label . ' valid independent six-role prefix');
$guard = Guard::validate($input, $head . $unit);
$old = $oldGuard::validate($input, $head . $unit);
$contract = Contract::validate($index, $head . $unit, true);
$expect($guard['valid'] === $expected, $label . ' Guard expected state');
$expect(($guard['valid'] && $contract['valid']) === $expected, $label . ' composed expected state');
foreach ([1, 13, 47] as $size) {
$visible = '';
$stream = new Stream($input, static function (string $delta) use (&$visible): bool { $visible .= $delta; return true; }, null, $head, null, $index);
foreach (mb_str_split($unit . "\n\n", $size, 'UTF-8') as $chunk) if (!$stream->push($chunk)) break;
$expect($stream->finish() === $expected && $stream->rejected() === !$expected, $label . ' stream outcome ' . $size);
$expect($visible === ($expected ? $unit . "\n\n" : ''), $label . ' exact accepted / withheld rejected bytes ' . $size);
}
$expect($unit === $originalUnit && serialize($input) === $inputBytes, $label . ' original output and source bytes unchanged');
$observations[] = ['label' => $label, 'unit_sha256' => hash('sha256', $unit), 'expected_valid' => $expected,
'before_guard_valid' => $old['valid'], 'before_guard_issues' => $old['issues'],
'guard_valid' => $guard['valid'], 'contract_valid' => $contract['valid'],
'guard_issues' => $guard['issues'], 'contract_issues' => $contract['issues']];
};
// Exercise every newly closed operator with three operands, rather than only
// the original two-operand counterexamples. No extra conjunction is invented.
foreach (['且', '并且', '而且', '并', '而', '亦', '也'] as $number => $join) {
$case('closed_three_positive_' . $number, '脉细弱(s10),当前细' . $join . '弱' . $join . '无力(s10)。', true);
$case('closed_third_positive_unsupported_' . $number, '脉细弱(s10),当前细' . $join . '弱' . $join . '有力(s10)。', false);
$case('closed_three_mixed_polarity_' . $number, '脉细弱(s10),当前不浮' . $join . '无力' . $join . '不紧(s10)。', true);
$case('closed_third_polarity_not_borrowed_' . $number, '脉细弱(s10),当前不浮' . $join . '无力' . $join . '紧(s10)。', false);
$case('closed_tongue_label_' . $number, '当前舌质淡红' . $join . '少津(2026-10-23,s9)。', true);
$case('closed_tongue_third_unsupported_' . $number, '当前舌质淡红' . $join . '少津' . $join . '胖大(s9)。', false);
}
foreach ([
['shared_group_supports_each_kind', '舌质淡红且少津、脉细弱且无力(s9、s10)。', true],
['shared_group_missing_tongue', '舌质淡红且少津、脉细弱且无力(s10)。', false],
['right_elsewhere_not_local_support', '脉细弱且无力(s10),本次细并且无力(s3)。', false],
['wrong_group_not_repaired_later', '脉细弱且无力(s3),本次脉细弱无力(s10)。', false],
['same_kind_union_valid', '脉细弱(s10),当前细且弱并且无力(s1、s2、s10)。', true],
['current_date_exact', '脉细弱(s10),当前不浮并且无力(2026-10-23,s10)。', true],
['current_date_wrong', '脉细弱(s10),当前不浮并且无力(2020-05-02,s10)。', false],
['historical_role_not_current', '脉细弱(s10),当前细并且无力(s14)。', false],
['finite_tail_cannot_hide_denial', '脉细弱且无力(s10),患者无固定刺痛。', false],
['finite_tail_cannot_hide_pulse', '脉细弱且无力(s10),脉滑(s10)。', false],
['long_late_tight_not_hidden', '脉细弱(s10),当前不浮且' . str_repeat('细', 60) . '紧(s10)。', false],
['long_supported_force', '脉细弱(s10),当前' . str_repeat('弱', 60) . '并且按之无力(s10)。', true],
] as [$label, $unit, $expected]) $case($label, $unit, $expected);
$historical = $ledger;
$historical['sources']['s40'] = array_replace($ledger['sources']['s10'], ['id' => 's40', 'scope' => 'historical',
'path' => 'history.0.pulse', 'date' => '2020-05-02', 'quote' => '2020-05-02医师切诊:脉细弱无力。']);
$case('historical_closed_local_valid', '既往脉细弱(2020-05-02,s40),当年细且无力(s40)。', true, $historical);
$case('historical_closed_cannot_promote', '既往脉细弱(2020-05-02,s40),本次细且无力(s40)。', false, $historical);
$case('historical_closed_cannot_cite_current', '既往脉细弱(2020-05-02,s40),当年细且无力(s10)。', false, $historical);
$unknownSource = $ledger;
$unknownSource['sources']['s40'] = array_replace($ledger['sources']['s10'], ['id' => 's40', 'quote' => '未记录']);
$case('unknown_source_cannot_borrow_known_closed_features', '脉细弱且无力(s40)。', false, $unknownSource);
// The changed parser entry is a separate validation view. Ordinary text,
// quotes, IDs, dates and flags must remain exactly the old entry's values.
$parse = new ReflectionMethod(Guard::class, 'reportClauses');
$oldParse = new ReflectionMethod($oldGuard, 'reportClauses');
$helper = new ReflectionMethod(Guard::class, 'explicitExamReferencesSupported');
$finite = new ReflectionMethod(Guard::class, 'finiteExamConjunctionProse');
$unknownProse = '脉细弱(s10),当前不浮且无力未记录(s10)。';
$expect($finite->invoke(null, 'pulse', '不浮且无力未记录') === null, 'reporting unknown prose is outside the closed feature conjunction grammar');
$expect($parse->invoke(null, $ledger, $unknownProse) === $oldParse->invoke(null, $ledger, $unknownProse),
'outside-grammar unknown-reporting prose has identical before/current parser entries');
foreach (['脉细弱且无力(s10)。', '舌质淡红并且少津(2026-10-23,s9)。',
'脉细弱(s10),当前不浮并且无力(s10)。'] as $number => $unit) {
$entries = $parse->invoke(null, $ledger, $unit);
$hadView = false;
foreach ($entries as &$entry) {
$hadView = $hadView || isset($entry['exam_conjunction_parts']);
unset($entry['exam_conjunction_parts']);
}
unset($entry);
$expect($hadView, 'closed supported single-kind entry receives only a separate parts view ' . $number);
$expect($entries === $oldParse->invoke(null, $ledger, $unit), 'raw entry fields byte-identical after removing parts view ' . $number);
}
foreach (['脉细弱且无力(s10)口干', '脉细弱且无力(s10)及脉细弱(s10)',
'“脉细弱且无力”(s10)', '「脉细弱且无力」(s10)', '"脉细弱且无力"(s10)'] as $number => $unit) {
$context = []; $view = ['sentinel'];
$helper->invokeArgs(null, [$ledger, $unit, &$context, &$view]);
$expect($view === null, 'quoted/tail/multiple-citation helper cannot export replacement parts ' . $number);
}
$context = []; $view = ['sentinel'];
$expect(!$helper->invokeArgs(null, [$ledger, '脉细弱且无力(s3)', &$context, &$view])
&& $context === [] && $view === null, 'invalid own citation cannot export context or replacement parts');
foreach (['不浮且口干', '细并且腹部按之无力', '细而且患者无固定刺痛', '弱也当前无力',
'细且且弱', '细并且', '并且细', '舌淡红且少津'] as $number => $ordinary) {
$expect($finite->invoke(null, 'pulse', $ordinary) === null, 'whole-operand finite grammar excludes ordinary or malformed subject ' . $number);
}
foreach (['。', ';', ';', '!', '?', "\n", "\r\n"] as $number => $end) {
$text = '脉细弱且无力(s10)' . $end . '当前不浮且无力(s3)。';
// The first entry may gain only its allowed parts key; the real reset is
// the last entry, which must remain the original independent subject.
$newEntries = $parse->invoke(null, $ledger, $text);
$oldEntries = $oldParse->invoke(null, $ledger, $text);
$expect(end($newEntries) === end($oldEntries), 'real sentence boundary cannot propagate finite exam context ' . $number);
}
foreach (['口干', '腹部按之无力'] as $number => $newSubject) {
$text = '脉细弱(s10),当前无力且' . $newSubject . '(s3),目前无力(s3)。';
$expect($parse->invoke(null, $ledger, $text) === $oldParse->invoke(null, $ledger, $text), 'explicit ordinary subject retains complete old parser equality ' . $number);
}
foreach ([['“', '”'], ['「', '」'], ['"', '"']] as $number => [$open, $close]) {
$text = '切诊:2026-10-23' . $open . $ledger['sources']['s10']['quote'] . $close . '(s10),当前不浮且无力(s3)。';
$expect($parse->invoke(null, $ledger, $text) === $oldParse->invoke(null, $ledger, $text), 'complete actual source-quote reset and offsets unchanged ' . $number);
}
$parts = (new ReflectionMethod(Guard::class, 'parts'))->invoke(null, '脉' . $finite->invoke(null, 'pulse', '不浮滑并且不紧'));
$topics = array_column($parts, 'topic');
$expect(in_array('脉浮滑', $topics, true) && !in_array('脉浮', $topics, true) && !in_array('脉滑', $topics, true),
'compound negative feature remains a single scoped topic in the normalized validation view');
$expect(serialize($ledger) === $ledgerBytes, 'all source bytes retained after composed and helper paths');
$expect(!class_exists('app\\adminapi\\logic\\tcm\\DiagnosisAiLogic', false), 'no inference Logic loaded');
foreach ($pins as $class => $hash) {
$expect(hash_file('sha256', (new ReflectionClass($class))->getFileName()) === $hash, 'exact final source pin after tests ' . $class);
}
echo json_encode(['scope' => 'independent r25e closed finite exam conjunction review', 'assertions' => $checks,
'cases' => count($observations), 'failures' => $failures, 'observations' => $observations], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), "\n";
exit($failures === [] ? 0 : 1);