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

563 lines
45 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);
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\TcmClinicalFactStateGuard as Guard;
use app\adminapi\service\TcmClinicalReportStream;
// Pure lexical role regressions. A lexical pass never certifies a diagnosis,
// source-citation relevance, or an unquoted positive finding's attribution.
$checks = 0;
function roleGrammarExpect(bool $condition, string $message): void
{
++$GLOBALS['checks'];
if (!$condition) throw new RuntimeException($message);
}
function roleGrammarCheck(array $ledger, string $text, bool $expected, string $code = ''): void
{
$result = Guard::validate($ledger, $text);
roleGrammarExpect($result['valid'] === $expected,
json_encode([$text, $result], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
if ($code !== '') roleGrammarExpect(in_array($code, array_column($result['issues'], 'code'), true),
$text . ': expected ' . $code);
foreach ([1, 2, 7, 31] as $size) {
$visible = '';
$stream = new TcmClinicalReportStream($ledger, static function (string $delta) use (&$visible): bool {
$visible .= $delta;
return true;
});
$wire = $text . "\r\n\r\n";
foreach (str_split($wire, $size) as $chunk) if (!$stream->push($chunk)) break;
roleGrammarExpect($stream->finish() === $expected && $stream->rejected() === !$expected,
'Stream decision differs at byte chunk size ' . $size . ': ' . $text);
roleGrammarExpect($expected ? $visible === $wire : $visible === '',
'Accepted bytes changed or a rejected unit leaked at byte chunk size ' . $size . ': ' . $text);
}
}
$record = [
'id' => 1, 'diagnosis_date' => '2026-10-23',
'present_illness' => '口干;无心悸;无明显胸痛;未记录固定刺痛或腰酸耳鸣。',
'past_history' => '无已知冠心病。', 'allergy_history' => '无已知药物过敏史。',
'tongue' => '舌质淡红,舌面少津,苔少;无黄腻苔、紫暗或瘀斑。',
'pulse' => '脉细弱,按之无力;不浮、不滑、不紧。',
];
$ledger = Guard::build($record);
$ids = [];
foreach ($ledger['sources'] as $source) $ids[$source['field']] = $source['id'];
foreach (['属', '属于', '为'] as $copula) foreach (['待补', '未记录'] as $state)
foreach (['依据', '证据', '资料'] as $noun) {
roleGrammarCheck($ledger, '固定刺痛' . $copula . $state . $noun . '。', true);
roleGrammarCheck($ledger, '口干' . $copula . $state . $noun . '。', false, 'POSITIVE_TO_UNKNOWN');
roleGrammarCheck($ledger, '心悸' . $copula . $state . $noun . '。', false, 'NEGATIVE_TO_UNKNOWN');
}
roleGrammarCheck($ledger, '固定刺痛属待补依据,且舌无紫暗瘀斑。', true);
roleGrammarCheck($ledger, '固定刺痛属待补依据,舌象未记录。', false, 'RECORDED_TO_UNKNOWN');
foreach (['患者有固定刺痛,属待补依据。', '患者有固定刺痛属待补依据。',
'固定刺痛属待补依据且患者有固定刺痛。', '固定刺痛属待补依据,患者有固定刺痛。'] as $text) {
roleGrammarCheck($ledger, $text, false, 'UNKNOWN_TO_POSITIVE');
}
roleGrammarCheck($ledger, '固定刺痛属待补依据,患者无固定刺痛。', false, 'UNKNOWN_TO_NEGATIVE');
roleGrammarCheck($ledger, '固定刺痛属待补依据,舌红绛。', false, 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE');
roleGrammarCheck($ledger, '固定刺痛属待补依据,脉滑。', false, 'PULSE_FEATURE_NOT_IN_CURRENT_SOURCE');
// P2 audit follow-up: a pending record role cannot retract a self-report,
// chief complaint, or an occurrence already asserted by its own subject.
foreach (['自述固定刺痛', '主诉固定刺痛', '今晨固定刺痛发作', '固定刺痛发作',
'今晨自述固定刺痛', '昨日固定刺痛持续了3小时'] as $assertion) {
roleGrammarCheck($ledger, $assertion . '。', false, 'UNKNOWN_TO_POSITIVE');
foreach (['属待补依据', '为待补资料', '属于未记录证据'] as $recordTail) {
roleGrammarCheck($ledger, $assertion . $recordTail . '。', false, 'UNKNOWN_TO_POSITIVE');
}
roleGrammarCheck($ledger, '固定刺痛属待补依据,' . $assertion . '为待补资料。', false, 'UNKNOWN_TO_POSITIVE');
roleGrammarCheck($ledger, $assertion . '属待补依据,舌淡红苔少。', false, 'UNKNOWN_TO_POSITIVE');
}
foreach (['自述固定刺痛属未记录固定刺痛。', '主诉固定刺痛为未记录固定刺痛。',
'今晨固定刺痛发作属未记录固定刺痛。', '自述固定刺痛属未记录而非否认。',
'主诉:固定刺痛为待补资料。', '肾系定位因自述固定刺痛未记录而待定。'] as $assertedRecord) {
roleGrammarCheck($ledger, $assertedRecord, false, 'UNKNOWN_TO_POSITIVE');
}
foreach (['固定刺痛发作时段属待补依据。', '今晨固定刺痛发作频次为待补资料。',
'固定刺痛持续时间属于未记录信息。', '自述内容属待补资料。', '主诉内容为待补资料。',
'固定刺痛、腰酸耳鸣属待补依据。', '本次固定刺痛属待补依据。'] as $neutralRecord) {
roleGrammarCheck($ledger, $neutralRecord, true);
}
roleGrammarCheck($ledger, '口干属待补依据,自述固定刺痛为待补资料。', false, 'POSITIVE_TO_UNKNOWN');
roleGrammarCheck($ledger, '心悸为待补资料,主诉固定刺痛属待补依据。', false, 'NEGATIVE_TO_UNKNOWN');
roleGrammarCheck($ledger, '固定刺痛属待补依据,舌象未记录。', false, 'RECORDED_TO_UNKNOWN');
$assertedSource = Guard::build(['present_illness' => '自述固定刺痛属待补依据。']);
roleGrammarCheck($assertedSource, '固定刺痛未记录。', false, 'POSITIVE_TO_UNKNOWN');
foreach (['肾系定位', '证名判断', '脏腑归属'] as $conclusion) foreach (['未记录', '尚未记录', '未采集'] as $state) {
roleGrammarCheck($ledger, $conclusion . '因腰酸耳鸣' . $state . '而待定。', true);
roleGrammarCheck($ledger, $conclusion . '因口干' . $state . '而待定。', false, 'POSITIVE_TO_UNKNOWN');
roleGrammarCheck($ledger, $conclusion . '因心悸' . $state . '而待定。', false, 'NEGATIVE_TO_UNKNOWN');
}
roleGrammarCheck($ledger, '腰酸耳鸣未记录而待定。', true);
roleGrammarCheck($ledger, '肾系定位因舌象未记录而待定。', false, 'RECORDED_TO_UNKNOWN');
foreach (['患者有腰酸耳鸣,肾系定位因未记录而待定。', '患者有腰酸耳鸣未记录而待定。',
'肾系定位因腰酸耳鸣未记录而待定且患者有固定刺痛。'] as $text) {
roleGrammarCheck($ledger, $text, false, 'UNKNOWN_TO_POSITIVE');
}
roleGrammarCheck($ledger, '肾系定位因腰酸耳鸣未记录而待定,口干未记录。', false, 'POSITIVE_TO_UNKNOWN');
roleGrammarCheck($ledger, '肾系定位因腰酸耳鸣未记录而待定,舌红绛。', false, 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE');
foreach (['每次', '每日', '每餐'] as $iteration) foreach (['饮水', '喝水', '进食'] as $measure) {
roleGrammarCheck($ledger, $iteration . $measure . '不多,舌淡红苔少。', true);
}
foreach (['、', ',', '且', '但'] as $boundary) {
roleGrammarCheck($ledger, '每次饮水不多' . $boundary . '患者无固定刺痛。', false, 'UNKNOWN_TO_NEGATIVE');
roleGrammarCheck($ledger, '每次饮水不多' . $boundary . '患者有心悸。', false, 'NEGATIVE_TO_POSITIVE');
roleGrammarCheck($ledger, '每次饮水不多' . $boundary . '口干未记录。', false, 'POSITIVE_TO_UNKNOWN');
roleGrammarCheck($ledger, '每次饮水不多' . $boundary . '舌红绛。', false, 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE');
roleGrammarCheck($ledger, '每次饮水不多' . $boundary . '脉滑。', false, 'PULSE_FEATURE_NOT_IN_CURRENT_SOURCE');
}
roleGrammarCheck(Guard::build(['water_intake' => '饮水未记录。']), '每次饮水不多。', false, 'UNKNOWN_TO_POSITIVE');
roleGrammarCheck($ledger, '每次腰酸耳鸣不多。', false, 'UNKNOWN_TO_POSITIVE');
roleGrammarCheck($ledger, '每次饮水不多舌红绛。', false, 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE');
foreach (['', '或', '及', '和', '、'] as $join) foreach (['舌', '舌质', '舌象', '舌体', '舌色'] as $subject) {
roleGrammarCheck($ledger, $subject . '无紫暗' . $join . '瘀斑。', true);
}
$qualified = Guard::build(array_replace($record, ['tongue' => '舌质淡红;无明显紫暗或瘀斑。']));
$partial = Guard::build(array_replace($record, ['tongue' => '舌质淡红;无紫暗。']));
$otherField = Guard::build(array_replace($record, ['tongue' => '舌质淡红;无紫暗。', 'tongue_coating' => '无瘀斑。']));
foreach (['', '或', '及', '和', '、'] as $join) {
roleGrammarCheck($qualified, '舌无紫暗' . $join . '瘀斑。', false, 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE');
roleGrammarCheck($qualified, '舌无明显紫暗' . $join . '瘀斑。', true);
roleGrammarCheck($partial, '舌无紫暗' . $join . '瘀斑。', false, 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE');
roleGrammarCheck($otherField, '舌无紫暗' . $join . '瘀斑。', false, 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE');
}
roleGrammarCheck($ledger, '舌无紫。', false, 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE');
roleGrammarCheck($ledger, '舌无紫暗或瘀斑,舌裂纹。', false, 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE');
roleGrammarCheck($ledger, '舌无紫暗或瘀斑,脉涩。', false, 'PULSE_FEATURE_NOT_IN_CURRENT_SOURCE');
roleGrammarCheck($ledger, '2026-10-22舌无紫暗或瘀斑。', false, 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE');
roleGrammarCheck($ledger, '舌无紫暗或瘀斑(2026-10-22,' . $ids['tongue'] . ')。', false, 'SOURCE_ANNOTATION_INVALID');
roleGrammarCheck($ledger, '舌无紫暗或瘀斑(s999)。', false, 'SOURCE_ANNOTATION_INVALID');
foreach (['红绛', '绛红', '紫暗', '淡红'] as $phenotype) {
$matching = Guard::build(['tongue' => '舌' . $phenotype . '。']);
roleGrammarCheck($matching, '舌' . $phenotype . '。', true);
roleGrammarCheck($matching, '舌无' . $phenotype . '。', false, 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE');
}
foreach ([',舌', ',且舌', ',舌'] as $boundary) {
roleGrammarCheck($ledger, '无心悸' . $boundary . '淡红苔少,脉细弱。', true);
roleGrammarCheck($ledger, '无心悸' . $boundary . '红绛。', false, 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE');
roleGrammarCheck($ledger, '无心悸' . $boundary . '无淡红。', false, 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE');
roleGrammarCheck($ledger, '无固定刺痛' . $boundary . '淡红苔少。', false, 'UNKNOWN_TO_NEGATIVE');
}
roleGrammarCheck($ledger, '无明显胸痛,舌淡红苔少。', true);
roleGrammarCheck($ledger, '无胸痛,舌淡红苔少。', false, 'NEGATION_SCOPE_WIDENED');
roleGrammarCheck($ledger, '无药物过敏史,舌淡红苔少。', false, 'NEGATION_SCOPE_WIDENED');
foreach (['舌脉未呈黄腻、滑、紧等所见。', '舌未呈紫暗或瘀斑。', '脉未呈滑、紧等所见。',
'舌象未呈黄腻苔。', '本次脉象未呈浮。',
'舌脉未呈黄腻、滑、紧等所见,但不能仅凭这些否定完全排除夹实。'] as $text) {
roleGrammarCheck($ledger, $text, true);
}
roleGrammarCheck($qualified, '舌未呈紫暗或瘀斑。', false, 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE');
roleGrammarCheck($qualified, '舌未呈明显紫暗或瘀斑。', true);
roleGrammarCheck($partial, '舌脉未呈黄腻、滑、紧等所见。', false, 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE');
roleGrammarCheck($ledger, '舌脉未呈黄腻、滑、涩等所见。', false, 'PULSE_FEATURE_NOT_IN_CURRENT_SOURCE');
roleGrammarCheck($ledger, '舌脉未呈红绛、滑、紧等所见。', false, 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE');
roleGrammarCheck($ledger, '舌未呈滑。', false, 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE');
roleGrammarCheck($ledger, '脉未呈黄腻。', false, 'PULSE_FEATURE_NOT_IN_CURRENT_SOURCE');
roleGrammarCheck($ledger, '舌脉未呈黄腻、滑、紧等所见,患者有固定刺痛。', false, 'UNKNOWN_TO_POSITIVE');
roleGrammarCheck($ledger, '舌脉未呈黄腻、滑、紧等所见且患者有固定刺痛。', false, 'UNKNOWN_TO_POSITIVE');
roleGrammarCheck($ledger, '舌脉未呈黄腻、滑、紧等所见,舌红绛。', false, 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE');
roleGrammarCheck($ledger, '舌脉未呈黄腻、滑、紧等所见,脉涩。', false, 'PULSE_FEATURE_NOT_IN_CURRENT_SOURCE');
roleGrammarCheck($ledger, '舌象未记录。', false, 'RECORDED_TO_UNKNOWN');
roleGrammarCheck($ledger, '未记录紫暗或瘀斑。', false, 'NEGATIVE_TO_UNKNOWN');
roleGrammarCheck($ledger, '舌未呈紫暗或瘀斑(s999)。', false, 'SOURCE_ANNOTATION_INVALID');
$parsed = (new ReflectionMethod(Guard::class, 'parts'))->invoke(null, '每次饮水不多,舌淡红苔少');
roleGrammarExpect(!in_array('negative', array_column($parsed, 'state'), true), 'Quantity 多 is not an absent clinical topic');
$features = (new ReflectionMethod(Guard::class, 'features'))->invoke(null, 'tongue', '舌紫暗或瘀斑');
roleGrammarExpect($features === ['紫暗', '瘀斑'], 'The optional tongue subject cannot consume the start of 紫暗');
$mixed = (new ReflectionMethod(Guard::class, 'parts'))->invoke(null, '舌脉未呈黄腻、滑、紧等所见');
roleGrammarExpect(array_column($mixed, 'topic') === ['舌黄腻', '脉滑、紧']
&& array_column($mixed, 'state') === ['negative', 'negative'], 'Mixed examinations retain separate typed negative parts');
// v37: discomfort is a complete symptom lexeme; an outer negative survives.
$gutPositive = Guard::build(array_replace($record, ['present_illness' => '胃肠不适;口干;无心悸;未记录固定刺痛。']));
$gutUnknown = Guard::build(array_replace($record, ['present_illness' => '口干;无心悸;未记录胃肠不适或固定刺痛。']));
$gutNegative = Guard::build(array_replace($record, ['present_illness' => '口干;无胃肠不适;未记录固定刺痛。']));
$gutQualified = Guard::build(array_replace($record, ['present_illness' => '口干;无明显胃肠不适。']));
roleGrammarCheck($gutPositive, '胃肠不适,口干。', true);
roleGrammarCheck($gutPositive, '用后监测胃肠不适、汗出、口干及气短。', true);
foreach (['无胃肠不适。', '患者无胃肠不适。', '未见胃肠不适。'] as $negative) {
roleGrammarCheck($gutPositive, $negative, false, 'POSITIVE_TO_NEGATIVE');
roleGrammarCheck($gutUnknown, $negative, false, 'UNKNOWN_TO_NEGATIVE');
roleGrammarCheck($gutNegative, $negative, true);
}
roleGrammarCheck($gutUnknown, '患者有胃肠不适。', false, 'UNKNOWN_TO_POSITIVE');
roleGrammarCheck($gutUnknown, '胃肠不适每日1次。', false, 'UNKNOWN_TO_POSITIVE');
roleGrammarCheck($gutUnknown, '胃肠不适未记录。', true);
roleGrammarCheck($gutPositive, '胃肠不适未记录。', false, 'POSITIVE_TO_UNKNOWN');
roleGrammarCheck($gutNegative, '胃肠不适未记录。', false, 'NEGATIVE_TO_UNKNOWN');
roleGrammarCheck($gutNegative, '患者有胃肠不适。', false, 'NEGATIVE_TO_POSITIVE');
roleGrammarCheck($gutQualified, '无胃肠不适。', false, 'NEGATION_SCOPE_WIDENED');
roleGrammarCheck($gutQualified, '无明显胃肠不适。', true);
roleGrammarCheck($gutPositive, '胃肠不适,舌红绛。', false, 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE');
roleGrammarCheck($gutPositive, '胃肠不适,脉滑。', false, 'PULSE_FEATURE_NOT_IN_CURRENT_SOURCE');
$gutParts = (new ReflectionMethod(Guard::class, 'parts'))->invoke(null, '胃肠不适、汗出、口干');
roleGrammarExpect(array_column($gutParts, 'state') === ['positive'], 'Discomfort cannot negate following positive symptoms');
$negativeGutParts = (new ReflectionMethod(Guard::class, 'parts'))->invoke(null, '无胃肠不适');
roleGrammarExpect(array_column($negativeGutParts, 'state') === ['negative']
&& array_column($negativeGutParts, 'topic') === ['胃肠不适'], 'An outer absence retains the complete discomfort object');
$pendingReview = '新方各成分既往耐受、保健品及其他未记录药物仍待核';
foreach ([$pendingReview, '新药既往耐受、合并用药尚待复核', '药物相互作用、肝肾功能仍需核查',
'三味药耐受或保健品待核实'] as $review) {
roleGrammarCheck($ledger, $review . '。', true);
roleGrammarCheck($ledger, '本次无已知药物过敏史,但' . $review . '。', true);
}
foreach ([',', '且', '但', '而', ''] as $join) {
roleGrammarCheck($ledger, $pendingReview . $join . '患者无固定刺痛。', false, 'UNKNOWN_TO_NEGATIVE');
roleGrammarCheck($ledger, $pendingReview . $join . '患者有心悸。', false, 'NEGATIVE_TO_POSITIVE');
roleGrammarCheck($ledger, $pendingReview . $join . '自述固定刺痛属待补依据。', false, 'UNKNOWN_TO_POSITIVE');
}
foreach ([',', '且', '但', '而'] as $join) {
roleGrammarCheck($ledger, $pendingReview . $join . '口干未记录。', false, 'POSITIVE_TO_UNKNOWN');
roleGrammarCheck($ledger, $pendingReview . $join . '心悸未记录。', false, 'NEGATIVE_TO_UNKNOWN');
roleGrammarCheck($ledger, $pendingReview . $join . '舌红绛。', false, 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE');
roleGrammarCheck($ledger, $pendingReview . $join . '脉滑。', false, 'PULSE_FEATURE_NOT_IN_CURRENT_SOURCE');
}
roleGrammarCheck($ledger, '患者无固定刺痛,' . $pendingReview . '。', false, 'UNKNOWN_TO_NEGATIVE');
roleGrammarCheck($ledger, '本次无药物过敏史,但' . $pendingReview . '。', false, 'NEGATION_SCOPE_WIDENED');
roleGrammarCheck($ledger, $pendingReview . '(s999)。', false, 'SOURCE_ANNOTATION_INVALID');
roleGrammarCheck($ledger, $pendingReview . '今晨固定刺痛发作。', false, 'UNKNOWN_TO_POSITIVE');
roleGrammarCheck($ledger, $pendingReview . '2026-10-23患者无固定刺痛。', false, 'UNKNOWN_TO_NEGATIVE');
$safetyQuantity = Guard::build(array_replace($record, ['water_intake' => '饮水量未记录。']));
roleGrammarCheck($safetyQuantity, $pendingReview . '每次饮水量不多。', false, 'UNKNOWN_TO_POSITIVE');
roleGrammarCheck($safetyQuantity, $pendingReview . '且每次饮水量不多。', false, 'UNKNOWN_TO_POSITIVE');
roleGrammarCheck($ledger, '固定刺痛仍待核。', false, 'UNKNOWN_TO_POSITIVE');
roleGrammarCheck($ledger, '固定刺痛耐受、患者无固定刺痛仍待核。', false, 'UNKNOWN_TO_NEGATIVE');
// Existing applicability and no-self-changing-medication instructions did not
// cause the original rejection. They receive no new blanket exemption.
foreach (['孕育相关不适用于男性', '不擅停换'] as $instruction) {
roleGrammarCheck($ledger, $instruction . '。', true);
roleGrammarCheck($ledger, $instruction . ',患者无固定刺痛。', false, 'UNKNOWN_TO_NEGATIVE');
roleGrammarCheck($ledger, $instruction . '且患者有心悸。', false, 'NEGATIVE_TO_POSITIVE');
roleGrammarCheck($ledger, $instruction . ',口干未记录。', false, 'POSITIVE_TO_UNKNOWN');
roleGrammarCheck($ledger, $instruction . ',舌红绛。', false, 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE');
}
$applicabilityParts = (new ReflectionMethod(Guard::class, 'parts'))->invoke(null, '孕育相关不适用于男性');
roleGrammarExpect(in_array('negative', array_column($applicabilityParts, 'state'), true),
'The discomfort lexeme rule does not reinterpret the applicability predicate');
foreach (['不适用', '不适于', '不适合', '不适应', '不适宜', '不适当'] as $predicate) {
$predicateParts = (new ReflectionMethod(Guard::class, 'parts'))->invoke(null, '该药物' . $predicate . '此情形');
roleGrammarExpect(in_array('negative', array_column($predicateParts, 'state'), true),
'A complete applicability/suitability predicate is not the discomfort lexeme: ' . $predicate);
}
// Portable complete safety units, rebuilt against the actual production
// source projection. A lexical pass does not certify the safety advice.
$safetySnapshot = (new ReflectionMethod(PatientAiReportLogic::class, 'buildSourceSnapshotFromRows'))->invoke(null, CompleteTcmCase::rows());
$safetySubmission = (new ReflectionMethod(PatientAiReportLogic::class, 'sanitizeSnapshotForUpstream'))->invoke(null, AiRequiredSource::submission($safetySnapshot));
$safetyLedger = Guard::build(CompleteTcmCase::current(), $safetySubmission);
$safetyUnits = [
'禁忌与用药复核:本次询问为“无已知药物过敏史”(s5),但新方各成分既往耐受、保健品及其他未记录药物仍待核。'
. '现有ALT 22U/L、肌酐78μmol/L、eGFR 96mL/min/1.73m²、尿ACR 12mg/g,可作启用前基线,不能替代后续监测。'
. '孕育相关不适用于男性,但胃肠耐受、肝肾变化及过敏反应仍需核查。',
'禁忌待核:虽本次询问“无已知药物过敏史”(s5),仍须核实三味药既往耐受、其他药物及保健品;用后监测皮疹、胃肠不适、汗出、口干、气短及肝肾功能。'
. '现用二甲双胍0.5g每日2次,维持既有医嘱,不擅停换;联用期间继续监测空腹、餐后血糖及低血糖相关症状。'
. '2020-05-02藿香正气散系胃肠不适旧方,早已停用,不续用。',
];
foreach ($safetyUnits as $safetyUnit) roleGrammarCheck($safetyLedger, $safetyUnit, true);
// v38: absence of a symptom record, classified unknown, remains a data gap.
foreach (['无', '没有', '未见'] as $missing) foreach (['记录', '资料'] as $recordNoun) foreach (['属', '为'] as $copula) {
$tail = $recordNoun . $copula . '未知。';
roleGrammarCheck($ledger, $missing . '固定刺痛' . $tail, true);
roleGrammarCheck($ledger, $missing . '口干' . $tail, false, 'POSITIVE_TO_UNKNOWN');
roleGrammarCheck($ledger, $missing . '心悸' . $tail, false, 'NEGATIVE_TO_UNKNOWN');
roleGrammarCheck($ledger, $missing . '舌象' . $tail, false, 'RECORDED_TO_UNKNOWN');
}
roleGrammarCheck($ledger, '无固定刺痛的记录属于不详。', true);
roleGrammarCheck($ledger, '无口干信息属未知。', false, 'POSITIVE_TO_UNKNOWN');
roleGrammarCheck($ledger, '无心悸描述属未知。', false, 'NEGATIVE_TO_UNKNOWN');
foreach ([',', '且'] as $join) {
roleGrammarCheck($ledger, '无固定刺痛记录属未知' . $join . '患者有固定刺痛。', false, 'UNKNOWN_TO_POSITIVE');
roleGrammarCheck($ledger, '无固定刺痛记录属未知' . $join . '患者无固定刺痛。', false, 'UNKNOWN_TO_NEGATIVE');
roleGrammarCheck($ledger, '无固定刺痛记录属未知' . $join . '口干未记录。', false, 'POSITIVE_TO_UNKNOWN');
roleGrammarCheck($ledger, '无固定刺痛记录属未知' . $join . '舌红绛。', false, 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE');
}
roleGrammarCheck($ledger, '患者有固定刺痛,无固定刺痛记录属未知。', false, 'UNKNOWN_TO_POSITIVE');
roleGrammarCheck($ledger, '自述固定刺痛属待补依据,无固定刺痛记录属未知。', false, 'UNKNOWN_TO_POSITIVE');
roleGrammarCheck($ledger, '无固定刺痛记录属未知,患者无药物过敏史。', false, 'NEGATION_SCOPE_WIDENED');
roleGrammarCheck($ledger, '无固定刺痛记录属未知(s999)。', false, 'SOURCE_ANNOTATION_INVALID');
roleGrammarCheck($ledger, '无固定刺痛记录属未知(2026-10-22,' . $ids['present_illness'] . ')。', false, 'SOURCE_ANNOTATION_INVALID');
$missingRecordSource = Guard::build(['present_illness' => '无固定刺痛记录属未知。']);
roleGrammarCheck($missingRecordSource, '无固定刺痛。', false, 'UNKNOWN_TO_NEGATIVE');
roleGrammarCheck($missingRecordSource, '患者有固定刺痛。', false, 'UNKNOWN_TO_POSITIVE');
$missingRecordParts = (new ReflectionMethod(Guard::class, 'parts'))->invoke(null, '无固定刺痛记录属未知');
roleGrammarExpect(array_column($missingRecordParts, 'state') === ['unknown']
&& array_column($missingRecordParts, 'topic') === ['固定刺痛'], 'Copular missing-record state retains its exact unknown subject');
// Explicit formula method/aim fragments are conceptual. The heading alone
// grants no exemption to observed findings or subsequent unrelated prose.
$functionAim = '方义上以养津改善口干舌燥';
foreach ([$functionAim . '。', '方义上以生津缓解口干舌燥。', '方义层面以养津改善口干舌燥。',
'方义:以补气恢复推动固摄,以养津改善口干舌燥,并以收敛配合控制自汗。'] as $functionText) {
roleGrammarCheck($ledger, $functionText, true);
}
foreach (['患者舌燥。', '以养津改善口干舌燥。', '方义上患者舌燥。', '方义上以养津改善患者舌燥。',
'方义上以舌燥改善口干。', '方义上舌燥,以养津改善口干。',
'患者舌燥,' . $functionAim . '。', $functionAim . ',舌燥。',
$functionAim . ',舌红绛。', '舌红绛,' . $functionAim . '。',
$functionAim . ',患者口干,以养津改善口干舌燥。'] as $actualExam) {
roleGrammarCheck($ledger, $actualExam, false, 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE');
}
foreach ([',', '且', '但', '而', ''] as $join) {
roleGrammarCheck($ledger, $functionAim . $join . '患者有固定刺痛。', false, 'UNKNOWN_TO_POSITIVE');
roleGrammarCheck($ledger, $functionAim . $join . '患者无固定刺痛。', false, 'UNKNOWN_TO_NEGATIVE');
roleGrammarCheck($ledger, $functionAim . $join . '自述固定刺痛属待补依据。', false, 'UNKNOWN_TO_POSITIVE');
}
foreach ([',', '且', '但', '而'] as $join) {
roleGrammarCheck($ledger, $functionAim . $join . '口干未记录。', false, 'POSITIVE_TO_UNKNOWN');
roleGrammarCheck($ledger, $functionAim . $join . '心悸未记录。', false, 'NEGATIVE_TO_UNKNOWN');
roleGrammarCheck($ledger, $functionAim . $join . '脉滑。', false, 'PULSE_FEATURE_NOT_IN_CURRENT_SOURCE');
}
roleGrammarCheck($ledger, $functionAim . ',患者无药物过敏史。', false, 'NEGATION_SCOPE_WIDENED');
roleGrammarCheck($ledger, $functionAim . ',舌无紫暗或瘀斑。', true);
roleGrammarCheck($ledger, $functionAim . ',舌无紫暗或瘀斑(s999)。', false, 'SOURCE_ANNOTATION_INVALID');
roleGrammarCheck($ledger, $functionAim . '(s999)。', false, 'SOURCE_ANNOTATION_INVALID');
roleGrammarCheck($ledger, $functionAim . '今晨固定刺痛发作。', false, 'UNKNOWN_TO_POSITIVE');
roleGrammarCheck($safetyQuantity, $functionAim . '且每次饮水量不多。', false, 'UNKNOWN_TO_POSITIVE');
roleGrammarCheck($ledger, '方义上以养津改善自述固定刺痛。', false, 'UNKNOWN_TO_POSITIVE');
roleGrammarCheck($ledger, '方义上以养津改善患者有固定刺痛。', false, 'UNKNOWN_TO_POSITIVE');
$recordAndFunctionUnits = [
'血:面色少华及脉细可作血不足鉴别,但缺少明确头晕、唇甲色淡等资料,尚不能独立定为血虚;无固定刺痛记录属未知(s2),不能据此排除血瘀。',
'本例支持证据:神疲、语声低微、动则气短、自汗且活动后加重,脉细弱、按之无力,支持益气;口干欲饮而少量频饮、大便稍干、舌面少津及苔少,支持生津。'
. '方义上以补气恢复推动固摄,以养津改善口干舌燥,并以收敛配合控制自汗;是否采用仍须结合面诊确认汗出性质及津亏程度。',
];
foreach ($recordAndFunctionUnits as $recordAndFunctionUnit) roleGrammarCheck($safetyLedger, $recordAndFunctionUnit, true);
// v39: an observation joined without a comma is never a formula aim object.
$shortFunctionAim = '方义上以养津改善口干';
$joinedObservations = [
['并诉称固定刺痛', 'UNKNOWN_TO_POSITIVE'],
['并诊见舌红绛', 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE'],
['同时脉滑', 'PULSE_FEATURE_NOT_IN_CURRENT_SOURCE'],
['还诉称固定刺痛', 'UNKNOWN_TO_POSITIVE'],
['又查见舌红绛', 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE'],
['并且报告固定刺痛', 'UNKNOWN_TO_POSITIVE'],
['另查见脉滑', 'PULSE_FEATURE_NOT_IN_CURRENT_SOURCE'],
['诉称固定刺痛', 'UNKNOWN_TO_POSITIVE'],
['诊见舌红绛', 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE'],
['舌质呈红绛', 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE'],
['脉呈滑', 'PULSE_FEATURE_NOT_IN_CURRENT_SOURCE'],
];
foreach ($joinedObservations as [$observation, $code]) {
roleGrammarCheck($safetyLedger, $shortFunctionAim . $observation . '。', false, $code);
roleGrammarCheck($safetyLedger, $shortFunctionAim . ',' . $observation . '。', false, $code);
roleGrammarCheck($safetyLedger, $observation . ',' . $shortFunctionAim . '。', false, $code);
}
roleGrammarCheck($ledger, $functionAim . '并口干未记录。', false, 'POSITIVE_TO_UNKNOWN');
roleGrammarCheck($ledger, $functionAim . '同时心悸未记录。', false, 'NEGATIVE_TO_UNKNOWN');
roleGrammarCheck($ledger, $functionAim . '还舌象未记录。', false, 'RECORDED_TO_UNKNOWN');
roleGrammarCheck($ledger, $functionAim . '又患者无固定刺痛。', false, 'UNKNOWN_TO_NEGATIVE');
roleGrammarCheck($ledger, $functionAim . '另无胸痛。', false, 'NEGATION_SCOPE_WIDENED');
roleGrammarCheck($ledger, $functionAim . '并患者无药物过敏史。', false, 'NEGATION_SCOPE_WIDENED');
roleGrammarCheck($ledger, $functionAim . '并自述固定刺痛属待补依据。', false, 'UNKNOWN_TO_POSITIVE');
roleGrammarCheck($ledger, $functionAim . '还诊见舌红绛属待补依据。', false, 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE');
roleGrammarCheck($safetyQuantity, $functionAim . '同时每次饮水量不多。', false, 'UNKNOWN_TO_POSITIVE');
roleGrammarCheck($ledger, $functionAim . '并舌无紫暗或瘀斑。', true);
roleGrammarCheck($ledger, $functionAim . '并舌无紫暗或瘀斑(s999)。', false, 'SOURCE_ANNOTATION_INVALID');
roleGrammarCheck($ledger, $functionAim . '。', true);
roleGrammarCheck($ledger, '方义上以养津改善口干及舌燥。', true);
roleGrammarCheck($ledger, '方义上以补气恢复推动固摄,并以养津改善口干舌燥,亦以收敛配合控制自汗。', true);
// A diagnostic evidence threshold is UNKNOWN, never a blanket heat/cold denial.
foreach (['本次没有', '当前无'] as $absence) foreach (['确定', '确认'] as $relation)
foreach (['热象', '寒象'] as $evidenceHead) {
$thresholdPrefix = $absence . '足以独立' . $relation;
$thresholdSuffix = '的阳性' . $evidenceHead . '。';
roleGrammarCheck($ledger, $thresholdPrefix . '热证' . $thresholdSuffix, true);
roleGrammarCheck($ledger, $thresholdPrefix . '口干' . $thresholdSuffix, false, 'POSITIVE_TO_UNKNOWN');
roleGrammarCheck($ledger, $thresholdPrefix . '心悸' . $thresholdSuffix, false, 'NEGATIVE_TO_UNKNOWN');
roleGrammarCheck($ledger, $thresholdPrefix . '舌象' . $thresholdSuffix, false, 'RECORDED_TO_UNKNOWN');
}
$thresholdPhrase = '本次没有足以独立确定热证的阳性热象';
$thresholdParts = (new ReflectionMethod(Guard::class, 'parts'))->invoke(null, $thresholdPhrase);
roleGrammarExpect(array_column($thresholdParts, 'state') === ['unknown']
&& array_column($thresholdParts, 'topic') === ['热证'], 'An explicit independent threshold retains its unknown diagnostic subject');
foreach (['本次没有足以确定热证的阳性热象。', '本次没有阳性热象。', '本次无独立寒象。',
'本次亦无独立阳性寒象。', '本次无寒象。'] as $bareSignDenial) {
roleGrammarCheck($ledger, $bareSignDenial, false, 'NEGATIVE_NOT_IN_SOURCE');
}
roleGrammarCheck($ledger, $thresholdPhrase . ',患者有固定刺痛。', false, 'UNKNOWN_TO_POSITIVE');
roleGrammarCheck($ledger, $thresholdPhrase . ',患者无固定刺痛。', false, 'UNKNOWN_TO_NEGATIVE');
roleGrammarCheck($ledger, $thresholdPhrase . ',口干未记录。', false, 'POSITIVE_TO_UNKNOWN');
roleGrammarCheck($ledger, $thresholdPhrase . ',舌红绛。', false, 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE');
roleGrammarCheck($ledger, $thresholdPhrase . ',脉滑。', false, 'PULSE_FEATURE_NOT_IN_CURRENT_SOURCE');
roleGrammarCheck($ledger, $thresholdPhrase . ',患者无药物过敏史。', false, 'NEGATION_SCOPE_WIDENED');
roleGrammarCheck($ledger, $thresholdPhrase . '(s999)。', false, 'SOURCE_ANNOTATION_INVALID');
roleGrammarCheck($ledger, $thresholdPhrase . '(2026-10-22,' . $ids['present_illness'] . ')。', false, 'SOURCE_ANNOTATION_INVALID');
$knownHeat = Guard::build(array_replace($record, ['clinical_diagnosis' => '本次已确认热证。']));
roleGrammarCheck($knownHeat, $thresholdPhrase . '。', false, 'POSITIVE_TO_UNKNOWN');
roleGrammarCheck($ledger, '本次没有足以独立确定患者有固定刺痛的阳性热象。', false, 'UNKNOWN_TO_POSITIVE');
roleGrammarCheck($ledger, '本次没有足以独立确定热证并诉称固定刺痛的阳性热象。', false);
// The optional Qwen grammar repair ends a negative at an explicit gradual
// postfix occurrence, while the preceding qualifier and new symptom still count.
$gradualOccurrenceUnit = '表里:病位在里。依据:无恶寒发热、无明显急性感染诱因,症状逐渐出现,非外感表证。限制依据:s2。';
roleGrammarCheck($safetyLedger, $gradualOccurrenceUnit, true);
roleGrammarCheck($safetyLedger, str_replace('无明显急性感染诱因', '无急性感染诱因', $gradualOccurrenceUnit), false, 'NEGATION_SCOPE_WIDENED');
foreach (['逐渐', '渐次', '陆续'] as $gradual) {
roleGrammarCheck($ledger, '无心悸,固定刺痛' . $gradual . '出现。', false, 'UNKNOWN_TO_POSITIVE');
roleGrammarCheck($ledger, '无心悸,口干' . $gradual . '出现。', true);
roleGrammarCheck($ledger, '无心悸,口干' . $gradual . '出现,舌红绛。', false, 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE');
roleGrammarCheck($ledger, '无胸痛,口干' . $gradual . '出现。', false, 'NEGATION_SCOPE_WIDENED');
}
roleGrammarCheck($safetyLedger, '患者无急性感染诱因。', false, 'NEGATION_SCOPE_WIDENED');
roleGrammarCheck($ledger, '无心悸,胸痛,口干逐渐出现。', false, 'NEGATION_SCOPE_WIDENED');
roleGrammarCheck($ledger, '无心悸,固定刺痛,口干逐渐出现。', false, 'UNKNOWN_TO_NEGATIVE');
roleGrammarCheck($safetyLedger, '日常记录显示活动后乏力、汗出较明显,无胸痛或晕厥。', false, 'NEGATION_SCOPE_WIDENED');
roleGrammarCheck($safetyLedger, '2026-10-22活动记录见“活动后乏力、汗出较明显,无胸痛或晕厥”。', true);
// v40: a complete evidence-limited exclusion and a locally linked review
// question must not borrow polarity from an earlier observed statement.
$syndromeExclusion = '仍不能仅凭证候排除心肺、感染或代谢因素';
$modalReviewHead = '适用前须确认仍以气虚、自汗、津少为主';
$linkedReviewQuestion = '并复核气短、自汗有无心肺、感染或代谢原因';
$linkedReview = $modalReviewHead . ',' . $linkedReviewQuestion;
roleGrammarCheck($safetyLedger, '自汗及气短虽有虚象,' . $syndromeExclusion . '。', true);
roleGrammarCheck($safetyLedger, $linkedReview . '。', true);
roleGrammarCheck($safetyLedger, str_replace('有无', '是否', $linkedReview) . '。', true);
roleGrammarCheck($safetyLedger, '适用前需核实气短、自汗为主,复核气短有无感染原因。', true);
roleGrammarCheck($safetyLedger, '适用前应再次确认气虚、自汗为主,再核查气短是否感染原因。', true);
foreach (['仍不能仅凭证候排除感染。', '仍不得只据证候完全排除感染。',
'不能仅凭本次资料排除感染。', '不能仅凭当前阴性记录排除感染。'] as $limit) {
roleGrammarCheck($safetyLedger, $limit, true);
}
$reviewAdjacentAssertions = [
['患者有固定刺痛', 'UNKNOWN_TO_POSITIVE'],
['患者无固定刺痛', 'UNKNOWN_TO_NEGATIVE'],
['口干未记录', 'POSITIVE_TO_UNKNOWN'],
['舌象未记录', 'RECORDED_TO_UNKNOWN'],
['舌红绛', 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE'],
['脉滑', 'PULSE_FEATURE_NOT_IN_CURRENT_SOURCE'],
];
foreach ([$syndromeExclusion, $linkedReview] as $reviewRole) {
foreach ($reviewAdjacentAssertions as [$assertion, $code]) {
roleGrammarCheck($safetyLedger, $reviewRole . ',' . $assertion . '。', false, $code);
roleGrammarCheck($safetyLedger, $assertion . ',' . $reviewRole . '。', false, $code);
}
foreach (['并诉称固定刺痛', '还诉称固定刺痛'] as $tail) {
roleGrammarCheck($safetyLedger, $reviewRole . $tail . '。', false, 'UNKNOWN_TO_POSITIVE');
}
foreach (['并诊见舌红绛', '又查见舌红绛'] as $tail) {
roleGrammarCheck($safetyLedger, $reviewRole . $tail . '。', false, 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE');
}
roleGrammarCheck($safetyLedger, $reviewRole . '同时脉滑。', false, 'PULSE_FEATURE_NOT_IN_CURRENT_SOURCE');
foreach (['且', '但', '而', '并', '同时', '还', '又', '另', ''] as $join) {
roleGrammarCheck($safetyLedger, $reviewRole . $join . '患者有固定刺痛。', false, 'UNKNOWN_TO_POSITIVE');
}
foreach (['且', '并', '同时', '还', '又', '另'] as $join) {
roleGrammarCheck($safetyLedger, $reviewRole . $join . '口干未记录。', false, 'POSITIVE_TO_UNKNOWN');
roleGrammarCheck($safetyLedger, $reviewRole . $join . '心悸未记录。', false, 'NEGATIVE_TO_UNKNOWN');
}
roleGrammarCheck($safetyLedger, $reviewRole . '今晨固定刺痛发作。', false, 'UNKNOWN_TO_POSITIVE');
roleGrammarCheck($safetyQuantity, $reviewRole . '每次饮水量不多。', false, 'UNKNOWN_TO_POSITIVE');
roleGrammarCheck($safetyLedger, $reviewRole . '2026-10-23患者无固定刺痛。', false, 'UNKNOWN_TO_NEGATIVE');
roleGrammarCheck($safetyLedger, $reviewRole . ',患者有感染。', false, 'NEGATIVE_TO_POSITIVE');
roleGrammarCheck($safetyLedger, $reviewRole . ',患者无急性感染诱因。', false, 'NEGATION_SCOPE_WIDENED');
roleGrammarCheck($safetyLedger, $reviewRole . ',患者无药物过敏史。', false, 'NEGATION_SCOPE_WIDENED');
roleGrammarCheck($safetyLedger, $reviewRole . '(s999)。', false, 'SOURCE_ANNOTATION_INVALID');
roleGrammarCheck($safetyLedger, $reviewRole . '(2026-10-22,s2)。', false, 'SOURCE_ANNOTATION_INVALID');
}
roleGrammarCheck($safetyLedger, $linkedReviewQuestion . '。', false, 'NEGATION_SCOPE_WIDENED');
roleGrammarCheck($safetyLedger, '有无感染。', false, 'NEGATION_SCOPE_WIDENED');
roleGrammarCheck($safetyLedger, $modalReviewHead . ',复核无固定刺痛。', false, 'UNKNOWN_TO_NEGATIVE');
roleGrammarCheck($safetyLedger, '适用前须确认患者有固定刺痛,' . $linkedReviewQuestion . '。', false, 'UNKNOWN_TO_POSITIVE');
roleGrammarCheck($safetyLedger, $modalReviewHead . ',并复核患者有固定刺痛。', false, 'UNKNOWN_TO_POSITIVE');
roleGrammarCheck($safetyLedger, $modalReviewHead . ',并复核气短有无患者有固定刺痛。', false, 'UNKNOWN_TO_POSITIVE');
roleGrammarCheck($safetyLedger, $modalReviewHead . ',并复核气短有无感染患者有固定刺痛。', false, 'UNKNOWN_TO_POSITIVE');
roleGrammarCheck($safetyLedger, $modalReviewHead . ',并复核气短有无感染诊见舌红绛。', false, 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE');
$multipleQuestionProse = (new ReflectionMethod(Guard::class, 'linkedReviewRequestProse'))->invoke(null,
$modalReviewHead . ',并复核气短有无感染有无心肺原因');
roleGrammarExpect(preg_match_all('/有无/u', $multipleQuestionProse) === 2,
'A multiple-interrogative fragment receives no linked-question normalization');
roleGrammarCheck($safetyLedger, $modalReviewHead . ',并复核气短有无无固定刺痛。', false, 'UNKNOWN_TO_NEGATIVE');
roleGrammarCheck($safetyLedger, '仍不能仅凭患者无固定刺痛排除感染。', false, 'UNKNOWN_TO_NEGATIVE');
$reviewQuestionProse = (new ReflectionMethod(Guard::class, 'reportProse'))->invoke(null, $linkedReview);
roleGrammarExpect(!str_contains($reviewQuestionProse, '有无') && !str_contains($reviewQuestionProse, '感染'),
'A linked question is recognized while its typed modal context still exists');
$exclusionProse = (new ReflectionMethod(Guard::class, 'reportProse'))->invoke(null, '自汗及气短虽有虚象,' . $syndromeExclusion);
roleGrammarExpect(str_contains($exclusionProse, '有虚象') && !str_contains($exclusionProse, '感染'),
'The observed prefix remains visible while only the bounded exclusion fragment is normalized');
$reviewAndExclusionUnits = [
'反证与局限:寒热均未定,且限制依据:(s2);因此不能把本方向解释为治疗阴虚火旺,也不能加入温阳或清热立法。'
. '便稍干、小便略多还可能受饮水、血糖等影响;自汗及气短虽有虚象,仍不能仅凭证候排除心肺、感染或代谢因素。'
. '若出现明显胸痛、心悸、喘促、浮肿、意识改变或急性高热,应先行现代医学评估,而非继续按原方向处理。',
'反证与局限:寒热未定,限制依据:(s2);本方向不能解释为治疗阴虚火旺,亦不据此采用温阳或清热法。'
. '适用前须确认仍以气虚、自汗、津少为主,并复核气短、自汗有无心肺、感染或代谢原因;症状性质改变时应重新辨证。',
];
foreach ($reviewAndExclusionUnits as $reviewAndExclusionUnit) roleGrammarCheck($safetyLedger, $reviewAndExclusionUnit, true);
// Actual terminal v41 C2 rejections: an explicit unknown-state noun and the
// colon immediately before a valid source citation must not become a positive
// symptom. Neither closed role may erase a known finding or borrow an adjacent
// assertion, and the stream must preserve the original unmodified units.
$unknownStateUnits = [
'血:面色少华、脉细可作血少鉴别,但无血常规、头晕、唇甲色泽等证据,现有资料不足以确定血虚。'
. '固定刺痛为未记录状态(s2),不能据此排除血瘀。',
'血:面色少华、脉细仅提示血少鉴别;血常规、头晕、唇甲色泽未记录,现有资料不足以确定血虚。'
. '固定刺痛属待补依据:(s2),不能判断血瘀与否。',
];
foreach ($unknownStateUnits as $unit) roleGrammarCheck($safetyLedger, $unit, true);
foreach (['为未记录状态', '属待补依据:', '属于未记录状态:'] as $role) {
roleGrammarCheck($ledger, '固定刺痛' . $role . '。', true);
roleGrammarCheck($ledger, '固定刺痛' . $role . '(' . $ids['present_illness'] . ')。', true);
roleGrammarCheck($ledger, '口干' . $role . '。', false, 'POSITIVE_TO_UNKNOWN');
roleGrammarCheck($ledger, '心悸' . $role . '。', false, 'NEGATIVE_TO_UNKNOWN');
roleGrammarCheck($ledger, '舌象' . $role . '。', false, 'RECORDED_TO_UNKNOWN');
roleGrammarCheck($ledger, '脉象' . $role . '。', false, 'RECORDED_TO_UNKNOWN');
foreach (['自述固定刺痛', '主诉固定刺痛', '今晨固定刺痛发作', '患者有固定刺痛'] as $assertion) {
roleGrammarCheck($ledger, $assertion . $role . '。', false, 'UNKNOWN_TO_POSITIVE');
}
foreach ([
['患者有固定刺痛', 'UNKNOWN_TO_POSITIVE'],
['患者无固定刺痛', 'UNKNOWN_TO_NEGATIVE'],
['口干未记录', 'POSITIVE_TO_UNKNOWN'],
['舌象未记录', 'RECORDED_TO_UNKNOWN'],
['舌红绛', 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE'],
['脉滑', 'PULSE_FEATURE_NOT_IN_CURRENT_SOURCE'],
] as [$assertion, $code]) {
roleGrammarCheck($ledger, '固定刺痛' . $role . ',' . $assertion . '。', false, $code);
roleGrammarCheck($ledger, $assertion . ',固定刺痛' . $role . '。', false, $code);
}
foreach (['且', '并', '同时', '还', '又', '另', ''] as $join) {
roleGrammarCheck($ledger, '固定刺痛' . $role . $join . '患者有固定刺痛。', false, 'UNKNOWN_TO_POSITIVE');
}
roleGrammarCheck($ledger, '固定刺痛' . $role . '(s999)。', false, 'SOURCE_ANNOTATION_INVALID');
roleGrammarCheck($ledger, '固定刺痛' . $role . '(2026-10-22,' . $ids['present_illness'] . ')。', false, 'SOURCE_ANNOTATION_INVALID');
}
// Each complete unit preserves byte identity, including split UTF-8 and CRLF.
$units = [
'固定刺痛属待补依据(' . $ids['present_illness'] . '),且舌无紫暗或瘀斑(' . $ids['tongue'] . ')。',
'肾系定位因腰酸耳鸣未记录而待定。每次饮水不多,舌淡红苔少。',
'舌脉未呈黄腻、滑、紧等所见(' . $ids['tongue'] . '、' . $ids['pulse'] . ')。',
];
$wire = implode("\r\n\r\n", $units) . "\r\n\r\n";
foreach ([1, 2, 7, 31] as $size) {
$visible = '';
$stream = new TcmClinicalReportStream($ledger, static function (string $delta) use (&$visible): bool { $visible .= $delta; return true; });
$pushed = true;
foreach (str_split($wire, $size) as $chunk) if (!$stream->push($chunk)) { $pushed = false; break; }
roleGrammarExpect($pushed && $stream->finish() && !$stream->rejected(), 'Complete role units survive byte chunk size ' . $size);
roleGrammarExpect($visible === $wire, 'Complete accepted role units preserve every byte at chunk size ' . $size);
}
echo 'Clinical fact state bounded role grammar: ' . $checks . " assertions passed (offline, original streaming bytes).\n";