156 lines
10 KiB
PHP
156 lines
10 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
// Pure finite integration regression: no inference/bootstrap/config/network.
|
||
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;
|
||
|
||
$checks = 0; $failures = []; $cases = 0;
|
||
$expect = static function (bool $ok, string $message) use (&$checks, &$failures): void {
|
||
++$checks;
|
||
if (!$ok) $failures[] = $message;
|
||
};
|
||
$snapshotPath = dirname(__DIR__, 2) . '/app/artifacts/ai_concurrency/v43r25c_before_TcmClinicalFactStateGuard.php.snapshot';
|
||
$beforeSource = file_get_contents($snapshotPath);
|
||
$expect(hash('sha256', $beforeSource) === 'b608f236c0b8877dbffb106ca8461596cf95ce911c060171cd20da31ca4a450c', 'Exact c Guard snapshot.');
|
||
if (hash('sha256', $beforeSource) !== 'b608f236c0b8877dbffb106ca8461596cf95ce911c060171cd20da31ca4a450c') {
|
||
throw new RuntimeException('Unverified before source');
|
||
}
|
||
$beforeSource = preg_replace('/final class TcmClinicalFactStateGuard\b/', 'final class R25dBeforeGuard', $beforeSource, 1, $replaced);
|
||
$expect($replaced === 1, 'Before class renamed only for isolated offline comparison.');
|
||
eval(substr($beforeSource, strlen('<?php')));
|
||
$beforeGuard = 'app\\adminapi\\service\\R25dBeforeGuard';
|
||
$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($beforeGuard::build(CompleteTcmCase::current(), $submission)), 'Construction remains byte identical.');
|
||
$expect(hash('sha256', json_encode($ledger, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES))
|
||
=== '2ebbec0b558347bf4ee128dfb5a9e63fc4e355d58f7226486a2ae96098bd57f6', 'Immutable full ledger identity.');
|
||
$expect(hash_file('sha256', __DIR__ . '/fixtures/CompleteTcmCase.php')
|
||
=== 'b54c47ec11f6a99dc87bdda78629e18c8a6f37a64017e42a72db399c727cb1c6', 'Fixture identity.');
|
||
$makeHead = static function (array $index): string {
|
||
$head = "一、四诊\n\n";
|
||
foreach (Index::ROLES as $label => $role) {
|
||
$found = null;
|
||
foreach ($index['sources'] as $source) {
|
||
if ($source['scope'] !== 'current') continue;
|
||
foreach ($source['spans'] as $span) {
|
||
if ($span['role'] === $role && $span['recorded']) { $found = [$source, $span]; break 2; }
|
||
}
|
||
}
|
||
if ($found === null) {
|
||
$head .= $label . ($role === 'palpation' ? ":其他触诊未记录,脉象见独立脉象项。\n\n" : ":未记录\n\n");
|
||
} else {
|
||
[$source, $span] = $found;
|
||
$head .= $label . ':' . $source['date'] . '“' . trim($span['quote']) . '”(' . $source['id'] . ")。\n\n";
|
||
}
|
||
}
|
||
return $head . "二、八纲\n\n";
|
||
};
|
||
$check = static function (string $unit, bool $valid, ?array $input = null, bool $oldGap = false) use (
|
||
$ledger, $makeHead, $beforeGuard, $expect, &$cases): void {
|
||
++$cases;
|
||
$input ??= $ledger;
|
||
$index = Index::build($input);
|
||
$head = $makeHead($index);
|
||
$original = $unit;
|
||
$expect(Guard::validate($input, $head)['valid'] && Contract::validate($index, $head, true)['valid'], 'Source-quoted prefix valid.');
|
||
$guard = Guard::validate($input, $head . $unit);
|
||
$contract = Contract::validate($index, $head . $unit, true);
|
||
$expect($guard['valid'] === $valid, 'Composed Guard result ' . $unit . ': ' . json_encode($guard));
|
||
$expect(($guard['valid'] && $contract['valid']) === $valid, 'Composed acceptance matches ' . $unit);
|
||
if (!$valid) $expect(in_array('SOURCE_ANNOTATION_INVALID', array_column($guard['issues'], 'code'), true), 'Own local provenance rejection ' . $unit);
|
||
if ($oldGap) $expect($beforeGuard::validate($input, $head . $unit)['valid'], 'Exact pre-repair integration bypass reproduced.');
|
||
foreach ([1, 11, 43] as $size) {
|
||
$visible = '';
|
||
$stream = new Stream($input, static function (string $chunk) use (&$visible): bool { $visible .= $chunk; 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() === $valid && $stream->rejected() === !$valid, 'Streaming acceptance at chunk ' . $size . ': ' . $unit);
|
||
$expect($visible === ($valid ? $unit . "\n\n" : ''), 'Exact original accepted bytes / zero rejected release at chunk ' . $size);
|
||
}
|
||
$expect($unit === $original, 'Validation never rewrites original unit.');
|
||
};
|
||
|
||
// Whole helper rejection must agree with the real Guard/Contract/stream path.
|
||
foreach (['本次', '此次', '当前', '目前'] as $time) {
|
||
$check('脉细弱(s10),' . $time . '无力(s3)。', false, null, $time === '本次');
|
||
$check('舌质淡红(s9),' . $time . '少津(s3)。', false, null, $time === '当前');
|
||
$check('脉细弱(s10),' . $time . '无力(s10)。', true);
|
||
$check('舌质淡红(s9),' . $time . '少津(s9)。', true);
|
||
}
|
||
foreach (['既往', '历史', '当年', '上次'] as $time) {
|
||
$check('脉细弱(s10),' . $time . '无力(s10)。', false, null, $time === '既往');
|
||
}
|
||
foreach (['脉细弱(s10), 当前按之无力(s3)。',
|
||
'脉细弱,本次无力(s3)。', '舌质淡红,当前少津(s3)。',
|
||
'脉细弱(s10),亦无力,本次无力(s3)。',
|
||
'脉细弱(s10),本次滑(s10)。', '脉细弱(s10),当前有力(s10)。',
|
||
'脉细弱(2026-10-23,s10),本次无力(2020-05-02,s10)。',
|
||
'舌质淡红、脉细弱(s9、s10),本次无力(s3)。',
|
||
'脉细弱(s10),当前无力(s10),目前无力(s3)。',
|
||
'脉细弱(s10),当前舌少津(s10)。'] as $unit) $check($unit, false);
|
||
foreach (['脉细弱,本次无力(s10)。', '舌质淡红,当前少津(s9)。',
|
||
'脉细弱(s10),亦无力,本次无力(s10)。',
|
||
'脉细弱(s10),本次无力(s1、s2、s10)。',
|
||
'脉细弱(s10),当前无力(s10),目前无力(s10)。',
|
||
'舌质淡红(s9),本次脉细弱无力(s1、s2、s10)。'] as $unit) $check($unit, true);
|
||
|
||
$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医师切诊:脉细弱无力。']);
|
||
$check('既往脉细弱(2020-05-02,s40),当前无力(s40)。', false, $historical, true);
|
||
$check('既往脉细弱(2020-05-02,s40),上次无力(s40)。', true, $historical);
|
||
$check('既往脉细弱(2020-05-02,s40),上次无力(2026-10-23,s40)。', false, $historical);
|
||
$check('既往脉细弱(2020-05-02,s40),本次无力(s10)。', true, $historical);
|
||
$oldVisit = CompleteTcmCase::current(); $oldVisit['diagnosis_date'] = '2020-02-29';
|
||
$check('脉细弱(s10),本次无力(s10)。', true, Guard::build($oldVisit));
|
||
|
||
$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'];
|
||
$check('脉细弱(' . $ids['pulse'] . '),本次按之无力(' . $ids['pulse_condition'] . ')。', true, $splitLedger);
|
||
$check('脉细弱(' . $ids['pulse'] . '),本次按之无力(' . $ids['pulse'] . ')。', false, $splitLedger);
|
||
|
||
// Parser-only reset observations certify source-reference boundaries, not
|
||
// general acceptance of these ordinary positive clinical statements.
|
||
$clauses = new ReflectionMethod(Guard::class, 'reportClauses');
|
||
$beforeClauses = new ReflectionMethod($beforeGuard, 'reportClauses');
|
||
foreach (['。', ';', ';', '!', '?', "\n", "\r\n"] as $boundary) {
|
||
$unit = '脉细弱(s10)' . $boundary . '本次无力(s3)。';
|
||
$parsed = $clauses->invoke(null, $ledger, $unit);
|
||
$expect(!in_array(true, array_column($parsed, 'annotation_invalid'), true), 'Real terminator resets exam context.');
|
||
$expect($parsed === $beforeClauses->invoke(null, $ledger, $unit), 'Real boundary quote/role/polarity output unchanged.');
|
||
}
|
||
foreach (['脉细弱(s10),口干(s2),本次无力(s3)。',
|
||
'脉细弱(s10),口干,本次无力(s3)。',
|
||
'脉细弱(s10),本次口干(s2),当前无力(s3)。',
|
||
'脉细弱(s10),腹部按之无力(s3),本次无力(s3)。',
|
||
'舌质淡红(s9),当前口干(s2),本次少津(s3)。',
|
||
'脉象资料见上文(s3),本次无力(s3)。',
|
||
'问诊:2026-10-23“' . $ledger['sources']['s3']['quote'] . '”(s3),本次无力(s3)。',
|
||
'切诊:2026-10-23“' . $ledger['sources']['s10']['quote'] . '”(s10),本次无力(s3)。'] as $unit) {
|
||
$parsed = $clauses->invoke(null, $ledger, $unit);
|
||
$expect(!in_array(true, array_column($parsed, 'annotation_invalid'), true), 'Ordinary/new or source-quoted subject cannot seed an exam continuation: ' . $unit);
|
||
$expect($parsed === $beforeClauses->invoke(null, $ledger, $unit), 'Unrelated/source-quoted boundary results unchanged.');
|
||
}
|
||
$index = Index::build($ledger);
|
||
$expect($clauses->invoke(null, $ledger, $makeHead($index)) === $beforeClauses->invoke(null, $ledger, $makeHead($index)),
|
||
'All six actual source-quoted role clauses keep exact offsets, annotations, prose and parser flags.');
|
||
$expect(serialize($ledger) === $ledgerBytes, 'All source ledger bytes unchanged.');
|
||
$expect(!class_exists('app\\adminapi\\logic\\tcm\\DiagnosisAiLogic', false), 'Inference Logic not loaded.');
|
||
echo json_encode(['scope' => 'r25d checker-only temporal-comma integration', 'assertions' => $checks, 'cases' => $cases,
|
||
'failures' => $failures], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), "\n";
|
||
exit($failures === [] ? 0 : 1);
|