194 lines
14 KiB
PHP
194 lines
14 KiB
PHP
<?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);
|