204 lines
12 KiB
PHP
204 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
// Pure synthetic regression: no framework, configuration, database or transport.
|
|
require dirname(__DIR__) . '/app/adminapi/service/TcmClinicalFactStateGuard.php';
|
|
|
|
use app\adminapi\service\TcmClinicalFactStateGuard as Guard;
|
|
use app\adminapi\service\LegacyClinicalFactStateLedgerReference as Reference;
|
|
|
|
$checks = 0;
|
|
function ledgerExpect(bool $condition, string $message): void
|
|
{
|
|
++$GLOBALS['checks'];
|
|
if (!$condition) throw new RuntimeException($message);
|
|
}
|
|
|
|
// Restore the original linear lookup in a test-only copy of the guard. Keeping
|
|
// the parser shared isolates this regression to dedupe rather than freezing a
|
|
// second copy of the clinical rules. Compare the entire output, including order.
|
|
$implementation = (string) file_get_contents(dirname(__DIR__) . '/app/adminapi/service/TcmClinicalFactStateGuard.php');
|
|
$reference = preg_replace_callback(
|
|
'~(\$quote = self::redact\(trim\(\$value\)\);)\R.*?(?= \$id = \'s\' \.)~s',
|
|
static fn (array $match): string => $match[1] . <<<'PHP'
|
|
|
|
foreach ($ledger['sources'] as $source) {
|
|
if ($source['quote'] === $quote && $source['field'] === $field
|
|
&& $source['scope'] === $scope && $source['date'] === $date) return;
|
|
}
|
|
|
|
PHP,
|
|
$implementation,
|
|
1,
|
|
$lookupReplacements
|
|
);
|
|
ledgerExpect($lookupReplacements === 1 && is_string($reference), 'Original lookup reference is restored exactly once');
|
|
$reference = str_replace('final class TcmClinicalFactStateGuard', 'final class LegacyClinicalFactStateLedgerReference', $reference, $classReplacements);
|
|
ledgerExpect($classReplacements === 1, 'Reference class is isolated from the real guard');
|
|
eval(substr($reference, strlen('<?php')));
|
|
|
|
$date = '2026-10-23';
|
|
$diagnosis = [
|
|
'id' => 51, 'diagnosis_date' => $date . ' 09:45:00',
|
|
'chief_complaint' => '被明确别名覆盖的原始值', 'chief_complaint_text' => '口干',
|
|
'present_illness' => '活动后气短,无胸痛;未记录固定刺痛或耳鸣。', 'present_illness_text' => '',
|
|
'allergy_history' => '否认药物过敏史', 'allergy_history_text' => '未记录',
|
|
'family_history' => '否认', 'surgery_history' => null,
|
|
'trauma_history' => '否认', 'trauma_history_text' => '', 'pregnancy_history' => 0,
|
|
'tongue' => '舌淡红,苔少;无黄腻苔、紫暗或瘀斑。',
|
|
'pulse' => '脉细弱,按之无力;不浮、不滑。',
|
|
'current_medications' => '本次用药未记录;既往服用合成药,早已停用。',
|
|
'remark' => '联系 13900000001 a@example.test https://example.test/a',
|
|
];
|
|
$sharedNote = ['diagnosis_id' => 51, 'note_date' => $date, 'content' => '否认腹痛;未记录耳鸣。'];
|
|
$submission = ['required_content' => [
|
|
['key' => 'chief_complaint', 'value' => [
|
|
['id' => 51, 'diagnosis_date' => $date, 'chief_complaint' => '口干', 'chief_complaint_text' => ' 口干 '],
|
|
['id' => 51, 'diagnosis_date' => '2026-10-22', 'chief_complaint' => '口干'],
|
|
['id' => 50, 'diagnosis_date' => $date, 'chief_complaint' => '口干'],
|
|
['diagnosis_date' => $date, 'chief_complaint' => '口干'],
|
|
['chief_complaint' => '口干'],
|
|
['id' => 51, 'diagnosis_date' => $date, 'complaint' => '口干'],
|
|
['id' => 51, 'diagnosis_date' => $date, 'chief_complaint' => '口干。', 'chief_complaint_text' => '手麻未记录'],
|
|
]],
|
|
['key' => 'present_illness', 'value' => [
|
|
['diagnosis_id' => 51, 'diagnosis_date' => $date, 'present_illness' => $diagnosis['present_illness'],
|
|
'present_illness_text' => $diagnosis['present_illness']],
|
|
['id' => 50, 'diagnosis_date' => '2020-01-01', 'present_illness' => '既往有胸痛;否认气短。'],
|
|
]],
|
|
['key' => 'other_history', 'value' => [
|
|
['id' => 51, 'diagnosis_date' => $date, 'allergy_history_text' => '未记录', 'family_history' => '否认',
|
|
'surgery_history' => '无', 'trauma_history' => null, 'pregnancy_history' => false],
|
|
]],
|
|
['key' => 'diagnosis_info', 'value' => [
|
|
['id' => 51, 'diagnosis_date' => $date, 'tongue' => $diagnosis['tongue'], 'tongue_text' => $diagnosis['tongue'],
|
|
'pulse' => $diagnosis['pulse'], 'pulse_condition' => $diagnosis['pulse']],
|
|
['id' => 50, 'diagnosis_date' => '2020-01-01', 'tongue' => '舌红绛,苔黄腻。', 'pulse' => '脉弦滑有力。'],
|
|
]],
|
|
['key' => 'doctor_image_notes', 'value' => [$sharedNote, $sharedNote,
|
|
['diagnosis_id' => 51, 'note_date' => $date, 'text' => $sharedNote['content']]]],
|
|
['key' => 'doctor_file_notes', 'value' => [$sharedNote,
|
|
['id' => 51, 'record_date' => $date, 'remark' => '联系 13900000002 b@example.test https://example.test/b']]],
|
|
['key' => 'daily_tracking', 'value' => [
|
|
['diagnosis_id' => 51, 'record_date' => $date, 'content' => $sharedNote['content']],
|
|
['record_date' => $date, 'note' => '未记录'],
|
|
]],
|
|
['key' => 'prescriptions', 'value' => [
|
|
['diagnosis_id' => 51, 'prescription_date' => $date, 'prescription_name' => '合成历史方',
|
|
'usage_instruction' => '既往服用,非本次建议。'],
|
|
]],
|
|
['key' => 'video_consultation_text', 'value' => [
|
|
['diagnosis_id' => 51, 'end_time' => $date . ' 10:00:00', 'transcript_text' => '当前口干;上次否认胸痛。',
|
|
'segments' => [['text' => '当前口干;上次否认胸痛。']]],
|
|
]],
|
|
['key' => 'chat', 'value' => [
|
|
['diagnosis_id' => 51, 'msg_time' => $date, 'text' => '口干', 'parts' => [['text' => '口干']]],
|
|
['msg_time' => $date, 'text' => ' 0 '], ['msg_time' => $date, 'text' => '00'],
|
|
['msg_time' => $date, 'text' => '01'], ['msg_time' => $date, 'text' => '1'],
|
|
['msg_time' => $date, 'text' => 'none'], ['msg_time' => $date, 'text' => 'NONE'],
|
|
['msg_time' => $date, 'text' => "值\0text|s:7:\"current\";a:1:{i:0;s:1:\"x\";}"],
|
|
['msg_time' => $date, 'text' => "值\0text|s:7:\"current\";a:1:{i:0;s:1:\"y\";}"],
|
|
['msg_time' => $date, 'text' => "\xC3\x28"], ['msg_time' => $date, 'text' => "\xC3\x29"],
|
|
['msg_time' => $date, 'text' => ''], ['msg_time' => $date, 'text' => ' '],
|
|
['msg_time' => $date, 'text' => null], ['msg_time' => $date, 'text' => 0],
|
|
['msg_time' => $date, 'text' => false],
|
|
]],
|
|
['key' => 'unsupported', 'value' => [['text' => '不支持的类别不得进入账本']]],
|
|
['key' => 'chat', 'value' => '非数组类别值'], null,
|
|
]];
|
|
|
|
function ledgerEquivalent(array $diagnosis, array $submission, string $label): array
|
|
{
|
|
$actual = Guard::build($diagnosis, $submission);
|
|
$expected = Reference::build($diagnosis, $submission);
|
|
ledgerExpect($actual === $expected, $label . ': complete ledger retains every key, type, value and insertion order');
|
|
ledgerExpect(serialize($actual) === serialize($expected), $label . ': complete serialized ledger is byte-identical');
|
|
ledgerExpect(Guard::prompt($actual) === Reference::prompt($expected), $label . ': prompt bytes are unchanged');
|
|
ledgerExpect(array_keys($actual) === ['version', 'sources', 'assertions', 'exam_features', 'field_states'],
|
|
$label . ': private membership index is absent from public ledger');
|
|
return $actual;
|
|
}
|
|
|
|
$ledger = ledgerEquivalent($diagnosis, $submission, 'Mixed duplicate and longitudinal fixture');
|
|
$sources = $ledger['sources'];
|
|
ledgerExpect($sources['s1'] === ['id' => 's1', 'path' => 'current.chief_complaint', 'field' => 'chief_complaint',
|
|
'scope' => 'current', 'date' => $date, 'quote' => '口干'], 'First source ID, original path and exact tuple are preserved');
|
|
$dryMouth = array_values(array_filter($sources, static fn (array $source): bool => $source['quote'] === '口干'));
|
|
ledgerExpect(array_map(static fn (array $source): array => [$source['field'], $source['scope'], $source['date']], $dryMouth)
|
|
=== [['chief_complaint', 'current', $date], ['chief_complaint', 'current', '2026-10-22'],
|
|
['chief_complaint', 'historical', $date], ['chief_complaint', 'unspecified', $date],
|
|
['chief_complaint', 'unspecified', ''], ['complaint', 'current', $date],
|
|
['text', 'historical', $date], ['text', 'historical', '']],
|
|
'Same quote retains distinct field/date/current/historical/unspecified tuples, including undated nested parts');
|
|
ledgerExpect(count(array_filter($sources, static fn (array $source): bool => $source['quote'] === $GLOBALS['sharedNote']['content'])) === 3,
|
|
'Duplicate across projections keeps first path but distinct field/scope remains');
|
|
$firstNote = array_values(array_filter($sources, static fn (array $source): bool => $source['quote'] === $GLOBALS['sharedNote']['content']));
|
|
ledgerExpect($firstNote[0]['path'] === 'doctor_image_notes.0.content', 'Later equal tuple never overwrites original path');
|
|
ledgerExpect(count(array_filter($sources, static fn (array $source): bool => $source['field'] === 'remark')) === 1,
|
|
'Membership is calculated after identical existing redaction');
|
|
ledgerExpect(count(array_filter($sources, static fn (array $source): bool => $source['quote'] === '')) === 1,
|
|
'Malformed UTF-8 retains original post-redaction empty-source behavior');
|
|
foreach (['unknown', 'positive', 'negative'] as $state) {
|
|
ledgerExpect(in_array($state, array_column($ledger['assertions'], 'state'), true), 'Fixture exercises ' . $state . ' assertions');
|
|
}
|
|
$reports = [
|
|
'口干;无胸痛。', '固定刺痛或耳鸣未记录。', '无固定刺痛。', '患者有固定刺痛。',
|
|
'舌淡红,苔少;无黄腻苔、紫暗或瘀斑。', '舌红绛,苔黄腻。', '脉细弱,按之无力;不浮、不滑。',
|
|
'脉弦滑有力。', '过敏史:未记录。', '过敏史:无。', '家族史:无。',
|
|
'既往有胸痛;否认气短。', '当前无气短。', '本次用药未记录。', '当前正在服用合成药。',
|
|
'若出现固定刺痛,应重新评估。', '【来源:s1】口干。', '【来源:s999999】口干。', "\xC3\x28", '',
|
|
];
|
|
$referenceLedger = Reference::build($diagnosis, $submission);
|
|
$validResults = [];
|
|
foreach ($reports as $report) {
|
|
$actual = Guard::validate($ledger, $report);
|
|
$expected = Reference::validate($referenceLedger, $report);
|
|
ledgerExpect(serialize($actual) === serialize($expected), 'Every validation issue, source ID and order remains byte-identical');
|
|
$validResults[$actual['valid'] ? 'valid' : 'invalid'] = true;
|
|
}
|
|
ledgerExpect(count($validResults) === 2, 'Validation comparison exercises both passing and rejected reports');
|
|
ledgerEquivalent([], [], 'Empty build after populated build');
|
|
ledgerExpect(Guard::build($diagnosis, $submission) === $ledger, 'Repeated build has no cross-request membership state');
|
|
|
|
function uniqueHistory(int $count): array
|
|
{
|
|
$rows = [];
|
|
for ($i = 0; $i < $count; ++$i) $rows[] = ['msg_time' => '2020-01-01', 'text' => 'synthetic-history-source-' . $i];
|
|
return ['required_content' => [['key' => 'chat', 'value' => $rows]]];
|
|
}
|
|
|
|
$medium = uniqueHistory(2000);
|
|
$mediumLedger = ledgerEquivalent([], $medium, '2,000 unique historical sources');
|
|
ledgerExpect(count($mediumLedger['sources']) === 2000 && count($mediumLedger['assertions']) === 2000,
|
|
'Large equivalence fixture preserves all source and assertion entries');
|
|
unset($mediumLedger);
|
|
|
|
// Eightfold growth should remain broadly linear. Median runs and a generous
|
|
// relative bound tolerate shared-host noise without imposing an absolute limit.
|
|
Guard::build([], uniqueHistory(100));
|
|
$timings = [];
|
|
foreach ([2000, 16000] as $count) {
|
|
$history = $count === 2000 ? $medium : uniqueHistory($count);
|
|
$elapsed = [];
|
|
for ($run = 0; $run < 3; ++$run) {
|
|
$start = hrtime(true);
|
|
$largeLedger = Guard::build([], $history);
|
|
$elapsed[] = (hrtime(true) - $start) / 1000000;
|
|
ledgerExpect(count($largeLedger['sources']) === $count && count($largeLedger['assertions']) === $count,
|
|
'Scaling fixture preserves every unique source and assertion');
|
|
$tail = $largeLedger['sources']['s' . $count];
|
|
ledgerExpect($tail['path'] === 'chat.' . ($count - 1) . '.text'
|
|
&& $tail['quote'] === 'synthetic-history-source-' . ($count - 1)
|
|
&& $tail['scope'] === 'historical' && $tail['date'] === '2020-01-01',
|
|
'Scaling fixture retains original tail path, quote, scope and date');
|
|
unset($largeLedger);
|
|
}
|
|
sort($elapsed);
|
|
$timings[$count] = $elapsed[1];
|
|
}
|
|
ledgerExpect($timings[16000] < $timings[2000] * 24 + 50,
|
|
'Eightfold history growth avoids original quadratic lookup scaling: ' . json_encode($timings));
|
|
echo 'Clinical fact ledger equivalence/scaling: ' . $checks . ' assertions passed (pure, no network/database). '
|
|
. 'Median ms: ' . json_encode($timings) . "\n";
|