invoke(null, CompleteTcmCase::rows()); $submission = (new ReflectionMethod(PatientAiReportLogic::class, 'sanitizeSnapshotForUpstream')) ->invoke(null, AiRequiredSource::submission($snapshot)); $ledger = Guard::build(CompleteTcmCase::current(), $submission); $index = Index::build($ledger); $ledgerBytes = serialize($ledger); $expect(hash('sha256', json_encode($ledger, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) === '2ebbec0b558347bf4ee128dfb5a9e63fc4e355d58f7226486a2ae96098bd57f6', 'Original ledger identity remains unchanged.'); $expect($ledger['sources']['s10']['quote'] === '本次医师切诊:脉细弱,按之无力;不浮、不滑、不紧。', 'Exact current pulse source.'); $expect(str_contains($ledger['sources']['s2']['quote'], '未记录腰酸耳鸣或固定刺痛。'), 'Lumbar/tinnitus reference remains UNKNOWN.'); $first = "一、四诊\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, trim($span['quote'])]; break 2; } } } if ($role === 'palpation' && $found === null) { $first .= "切诊:其他触诊未记录,脉象见独立脉象项。\n\n"; continue; } $expect($found !== null, 'Each recorded role has a source span.'); [$source, $quote] = $found; $first .= $label . ':' . $source['date'] . '“' . $quote . '”(' . $source['id'] . ")。\n\n"; } $head = $first . "二、八纲\n\n"; $expect(Contract::validate($index, $head, true)['valid'], 'Actual six-role prefix is valid.'); $check = static function (string $label, string $text, bool $guardValid, bool $contractValid, string $code = '', ?array $otherLedger = null, bool $stream = true) use ($ledger, $index, $head, $expect): void { $inputLedger = $otherLedger ?? $ledger; $original = $text; $result = Guard::validate($inputLedger, $text); $contract = Contract::validate($index, $head . $text, true); $expect($result['valid'] === $guardValid && $contract['valid'] === $contractValid, $label . ': ' . json_encode([$result, $contract], JSON_UNESCAPED_UNICODE)); if ($code !== '') $expect(in_array($code, array_column($result['issues'], 'code'), true) || in_array($code, array_column($contract['issues'], 'code'), true), $label . ': retained ' . $code); $expect($text === $original, $label . ': original report bytes preserved.'); if (!$stream || $otherLedger !== null) return; foreach ([1, 7, 31] as $size) { $visible = ''; $reader = new TcmClinicalReportStream($ledger, static function (string $delta) use (&$visible): bool { $visible .= $delta; return true; }, null, $head, null, $index); foreach (mb_str_split($text . "\n\n", $size, 'UTF-8') as $chunk) if (!$reader->push($chunk)) break; $valid = $guardValid && $contractValid; $expect($reader->finish() === $valid && $reader->rejected() === !$valid, $label . ': composed stream at chunk ' . $size); if ($valid) $expect($visible === $text . "\n\n", $label . ': source/output bytes forwarded unchanged.'); } }; $abstract = [ '不能仅据相关阴性反推为绝对无实', '该限制不等同于证明纯虚无夹杂', '病位不在表,属里证', ]; foreach ($abstract as $text) { $check('closed diagnostic proposition', $text . '。', true, true); foreach ([',患者无固定刺痛。', '且患者无固定刺痛。', '并患者无固定刺痛。', '而患者无固定刺痛。', '患者无固定刺痛。', ',患者有固定刺痛。', ',口干未记录。', ',脉滑。', '且脉滑。', ',舌质红绛。', ',本次无明显急性感染诱因。'] as $tail) { $check('diagnostic proposition cannot hide adjacent assertion', $text . $tail, str_contains($tail, '无明显急性感染诱因'), !preg_match('/无|没有|未见/u', $tail) || str_contains($tail, '无力')); } } foreach (['不能仅据相关阴性反推为绝对无实性皮疹。', '并非不能仅据相关阴性反推为绝对无实。', '该限制不等同于证明纯虚无夹杂为事实。', '并非该限制不等同于证明纯虚无夹杂。', '该限制不等同于证明纯虚无夹杂(s999)。'] as $text) { $check('clinical nouns or outer negation have no abstract exemption', $text, str_contains($text, 's999') ? false : true, false, 'UNQUOTED_PATIENT_DENIAL'); } $pulse = '主证可暂定为气虚、卫表不固证,依据为动则气短、自汗活动后加重及脉细弱无力(s1、s2、s10)。'; $check('positive evidence starts after syndrome proposition', $pulse, true, true); foreach (['脉细弱有力', '脉细弱滑无力', '脉细弱无力且脉结'] as $unsupported) { $check('unsupported positive pulse remains rejected', str_replace('脉细弱无力', $unsupported, $pulse), false, true, 'PULSE_FEATURE_NOT_IN_CURRENT_SOURCE'); } $missingPulse = CompleteTcmCase::current(); $missingPulse['pulse'] = null; $check('missing current pulse cannot use interview/historical pulse', $pulse, false, true, 'PULSE_FEATURE_NOT_IN_CURRENT_SOURCE', Guard::build($missingPulse, $submission)); $weakOnly = CompleteTcmCase::current(); $weakOnly['pulse'] = '本次医师切诊:脉细弱。'; $check('weak pulse does not imply no force', $pulse, false, true, 'PULSE_FEATURE_NOT_IN_CURRENT_SOURCE', Guard::build($weakOnly)); $check('pulse date mismatch', str_replace('s1、s2、s10', '2020-05-02,s10', $pulse), false, true, 'SOURCE_ANNOTATION_INVALID'); // Each explicit exam reference belongs to its own adjacent evidence, even // when another part of the same sentence cites the right exam source. foreach (['脉细弱无力(s3)。', '脉细弱无力(s14)。', '卫表不固证,依据为脉细弱无力(s3)。', '卫表不固证,依据为脉细弱无力(s14)。', '脉细弱无力(s3),气短与自汗的资料见s10。', '气短(s10),脉细弱无力(s3)。', '脉细弱无力(s3),脉细弱无力(s10)。', '脉细弱无力(s10),按之无力(s3)。', '脉细弱(s3),按之无力(s10)。', '脉细弱无力(s3、s14),口干(s10)。', '舌质淡红(s3)。', '舌面少津、苔少(s10)。', '舌质淡红(s9),舌面少津(s3)。', '舌质淡红、脉细弱无力(s9)。', '舌质淡红、脉细弱无力(s10)。', ] as $badReference) { $check('local citation cannot borrow actual exam support elsewhere', $badReference, false, true, 'SOURCE_ANNOTATION_INVALID'); } foreach (['脉细弱无力(s10)。', '脉细弱无力(s1、s2、s10)。', '脉细弱无力(s10),气短(s2)。', '气短(s2),脉细弱无力(s10)。', '脉细弱(s10),按之无力(s10)。', '舌质淡红(s9),脉细弱无力(s10)。', '舌质淡红、脉细弱无力(s9、s10)。', ] as $validReference) $check('own current exam or correct composite citation', $validReference, true, true); $historicalPulse = $ledger; $historicalPulse['sources']['s40'] = $ledger['sources']['s10']; $historicalPulse['sources']['s40']['id'] = 's40'; $historicalPulse['sources']['s40']['scope'] = 'historical'; $historicalPulse['sources']['s40']['path'] = 'history.0.pulse'; $check('same-date historical pulse role is still not current', '脉细弱无力(s40)。', false, true, 'SOURCE_ANNOTATION_INVALID', $historicalPulse); $splitPulse = CompleteTcmCase::current(); $splitPulse['pulse'] = '本次医师切诊:脉细弱。'; $splitPulse['pulse_condition'] = '本次医师切诊:按之无力。'; $splitLedger = Guard::build($splitPulse); $pulseId = $conditionId = ''; foreach ($splitLedger['sources'] as $source) { if ($source['field'] === 'pulse') $pulseId = $source['id']; if ($source['field'] === 'pulse_condition') $conditionId = $source['id']; } $check('pulse-field alias must actually contain every cited feature', '脉细弱无力(' . $conditionId . ')。', false, true, 'SOURCE_ANNOTATION_INVALID', $splitLedger); $check('actual split current pulse sources compose without a synonym', '脉细弱无力(' . $pulseId . '、' . $conditionId . ')。', true, true, '', $splitLedger); foreach (['脉细弱,无力(s14)。', '脉细弱,无力(s3)。', '舌质淡红,少津(s3)。', '舌质淡红(s9),少津(s3)。', '脉不浮,不滑、不紧(s14)。', '脉细弱,无力(s14),口干(s10)。', '脉细弱(s10),无力(s3)。', '既往脉细弱无力(s10)。', '既往脉细弱无力(s10),本次脉细弱无力(s10)。'] as $badAntecedent) { $check('established exam list and explicit historical scope require own supporting source', $badAntecedent, false, true, 'SOURCE_ANNOTATION_INVALID'); } foreach (['脉细弱,无力(s10)。', '舌质淡红,少津(s9)。', '舌质淡红(s9),少津(s9)。', '脉细弱(s10),无力(s10)。'] as $goodAntecedent) { $check('bare features retain the established local or immediately cited exam kind', $goodAntecedent, true, true); } $oldCurrent = CompleteTcmCase::current(); $oldCurrent['diagnosis_date'] = '2020-05-02'; $oldLedger = Guard::build($oldCurrent); $check('an internally consistent old visit is still current to its own construction', '本次脉细弱无力(s10)。', true, true, '', $oldLedger); $oldHistorical = $ledger; $oldHistorical['sources']['s40'] = array_replace($ledger['sources']['s10'], ['id' => 's40', 'scope' => 'historical', 'path' => 'history.0.pulse', 'date' => '2020-05-02', 'quote' => '2020-05-02医师切诊:脉细弱无力。']); $check('actual historical own source and date remain valid', '既往脉细弱(2020-05-02,s40),无力(s40)。', true, true, '', $oldHistorical); $oldRelativeRecord = $oldHistorical; $oldRelativeRecord['sources']['s40']['quote'] = '2020-05-02本次医师切诊:脉细弱无力。'; $check('historical own-date record keeps relative original 本次 in its source context', '既往脉细弱无力(2020-05-02,s40)。', true, true, '', $oldRelativeRecord); $oldInnerDate = $ledger; $oldInnerDate['sources']['s10']['quote'] = '2020-05-02医师切诊:脉细弱无力。'; $check('explicit inner old exam date cannot become current through outer metadata', '本次脉细弱无力(s10)。', false, true, 'SOURCE_ANNOTATION_INVALID', $oldInnerDate); $check('historical bare continuation cannot switch to the current source', '既往脉细弱(2020-05-02,s40),无力(s10)。', false, true, 'SOURCE_ANNOTATION_INVALID', $oldHistorical); $syntheticMismatch = $ledger; $syntheticMismatch['sources']['s40'] = array_replace($ledger['sources']['s10'], ['id' => 's40', 'date' => '2020-05-02']); $check('diagnostic inconsistent current date violates existing per-kind construction metadata', '本次脉细弱无力(s40)。', false, true, 'SOURCE_ANNOTATION_INVALID', $syntheticMismatch); // These are parser-only diagnostics, not new clinical acceptances. A new // ordinary subject must reset pulse/tongue inheritance; without a valid // construction date the existing trusted-ledger assumption is explicit. $referenceGrammar = new ReflectionMethod(Guard::class, 'explicitExamReferencesSupported'); foreach (['脉细弱(s10),口干(s2),无力(s3)。', '脉细弱,腹部按之无力(s3)。', '脉细弱(s10),腹部按之无力(s3)。', '舌质淡红(s9),口干(s2),少津(s3)。'] as $resetOnly) { $expect($referenceGrammar->invoke(null, $ledger, $resetOnly), 'Parser-only: explicit ordinary subject ends the examination antecedent.'); } foreach ([null, '', 'not-a-date'] as $missingAuthority) { $unboundDate = $syntheticMismatch; $unboundDate['field_states']['pulse']['date'] = $missingAuthority; $expect($referenceGrammar->invoke(null, $unboundDate, '本次脉细弱无力(s40)。'), 'Parser-only diagnostic: no guessed visit-date rule when construction metadata is unavailable.'); } $expect($referenceGrammar->invoke(null, $oldHistorical, '脉细弱(s10),口干(2020-05-02,s40),脉细弱无力(s10)。'), 'Parser-only: another subject citation date cannot govern the next explicit examination.'); $locator = '腰肾相关资料见s2,暂不定肾虚'; $check('verified anaphor names actual missing record', $locator . '。', true, true); $check('locator whitespace retains source verification and output bytes', '腰肾相关资料见s2 ,暂不定肾虚。', true, true); foreach (['s3', 's10', 's14', 's999', 's0', 's2x', 's-2', 's02', 'S2'] as $badId) { $check('anaphor needs same current source and actual lumbar item', str_replace('s2', $badId, $locator) . '。', false, true, 'SOURCE_ANNOTATION_INVALID'); } $check('wrong locator source cannot hide behind whitespace', '腰肾相关资料见s3 ,暂不定肾虚。', false, true, 'SOURCE_ANNOTATION_INVALID'); $check('malformed locator token cannot hide a suffix after whitespace', '腰肾相关资料见s2 x,暂不定肾虚。', false, true, 'SOURCE_ANNOTATION_INVALID'); $check('anaphor date mismatch', $locator . '(2020-05-02,s2)。', false, true, 'SOURCE_ANNOTATION_INVALID'); $noLumbar = CompleteTcmCase::current(); $noLumbar['present_illness'] = '神疲乏力,未记录固定刺痛。'; $check('locator cannot invent a missing lumbar subject', $locator . '。', false, true, 'SOURCE_ANNOTATION_INVALID', Guard::build($noLumbar)); foreach ([',患者无腰酸耳鸣。', '且患者无固定刺痛。', ',患者有固定刺痛。', ',脉滑。'] as $tail) { $check('locator cannot hide actual findings', $locator . $tail, false, !preg_match('/无/u', $tail)); } $future = '适用条件:复核症状仍以活动后乏力、气短、自汗为主,口干少量频饮及少津舌持续存在,且无需要优先处理的急性病因'; $check('future acute-cause applicability criterion', $future . '。', true, true); $check('future recheck without optional persistence criterion', '适用条件:复核症状仍以活动后乏力、气短、自汗为主,且无需要优先处理的急性病因。', true, true); foreach ([',患者无固定刺痛。', '且患者无固定刺痛。', '并患者无固定刺痛。', '患者无固定刺痛。', ',患者有固定刺痛。', ',口干未记录。', ',脉滑。', '且舌质红绛。'] as $tail) { $check('future frame owns no adjoining actual finding', $future . $tail, false, !preg_match('/无/u', $tail)); } foreach ([str_replace('复核症状', '本次复核症状', $future), str_replace('且无需要', '且本次无需要', $future), str_replace('且无需要', '且已确认无需要', $future), str_replace('复核症状仍以', '复核患者自述仍以', $future)] as $actual) { $check('current/completed exclusion cannot borrow condition heading', $actual . '。', true, false, 'UNQUOTED_PATIENT_DENIAL'); } $check('unsupported pulse inside future target', str_replace('口干少量频饮及少津舌', '脉滑', $future) . '。', false, true, 'PULSE_FEATURE_NOT_IN_CURRENT_SOURCE'); $q = '表里:本次症状逐渐出现,无明显急性感染诱因(s2),病位不在表,属里证。'; $check('actual qualified Q inference still needs quoted denial protocol', $q, true, false, 'UNQUOTED_PATIENT_DENIAL'); $check('quoted full fragment permits diagnostic inference', '表里:本次“此次症状逐渐出现,无明显急性感染诱因”(s2),病位不在表,属里证。', true, true); $check('actual Q degree deletion remains rejected', str_replace('无明显急性感染', '无急性感染', $q), false, false, 'NEGATION_SCOPE_WIDENED'); $check('unknown is not absent', '患者无腰酸耳鸣或固定刺痛。', false, false, 'UNKNOWN_TO_NEGATIVE'); // Exact retained r24 units are read in full, never substituted or rewritten. $artifactRoot = dirname(__DIR__, 2) . '/app/artifacts/ai_concurrency/'; $actualFiles = [ 'tcm_complete_v43r24_single_openai_20261024_0_openai.rejected.json' => [[true, true], [true, true]], 'tcm_complete_v43r24_double_openai_20261024_0_openai.rejected.json' => [[true, true]], 'tcm_complete_v43r24_double_openai_20261024_1_openai.rejected.json' => [[true, true]], 'tcm_complete_v43r24_openai_deadline_20261024_openai_next_full90_openai.rejected.json' => [[true, true]], 'tcm_complete_v43r24_single_qwen_20261024_0_qwen.rejected.json' => [[false, false], [false, false]], 'tcm_complete_v43r24_double_qwen_20261024_0_qwen.rejected.json' => [[false, false], [false, false]], 'tcm_complete_v43r24_double_qwen_20261024_1_qwen.rejected.json' => [[false, false], [true, false]], ]; $actualHashes = [ 'tcm_complete_v43r24_single_openai_20261024_0_openai.rejected.json' => '39056e3750c9b385f543db7da351b47657d997c5ac705e48cbd93f088b4c178e', 'tcm_complete_v43r24_double_openai_20261024_0_openai.rejected.json' => '0c03a7a8a764179e4535ea40c0c608e811598a2259ae9aefec3c3bdc3959f543', 'tcm_complete_v43r24_double_openai_20261024_1_openai.rejected.json' => 'e1e5a7a26aae0f7443b00c6dc1f4db2ad0f97006fc84fd91bb0171423c21c252', 'tcm_complete_v43r24_openai_deadline_20261024_openai_next_full90_openai.rejected.json' => '084a59af11c14b06d6a9d5664db53a87617c5e0083bcd6640b52c2c8d1d7a21c', 'tcm_complete_v43r24_single_qwen_20261024_0_qwen.rejected.json' => '55ee0125d1cec5ccc344d9fef747e118d9e55b8147fd901e819d8a4a38408b8a', 'tcm_complete_v43r24_double_qwen_20261024_0_qwen.rejected.json' => '2e7cce04ca83526a17c3cda27764b2386e192df35b36ef37f3dd47f622f561ba', 'tcm_complete_v43r24_double_qwen_20261024_1_qwen.rejected.json' => '0d803a89c349f10f1317080c7232379c59b7e1f291e514b373e63960819d4b85', ]; foreach ($actualFiles as $name => $outcomes) { if (!is_file($artifactRoot . $name)) continue; $before = hash_file('sha256', $artifactRoot . $name); $expect($before === $actualHashes[$name], 'Exact original r24 rejection file identity: ' . $name); $saved = json_decode((string) file_get_contents($artifactRoot . $name), true, 512, JSON_THROW_ON_ERROR); $expect(count($saved['rejections']) === count($outcomes), 'Expected complete retained units: ' . $name); foreach ($saved['rejections'] as $i => $unit) { [$gv, $cv] = $outcomes[$i]; $check('exact retained ' . $name . ' / ' . $i, $unit['unit'], $gv, $cv); } $expect(hash_file('sha256', $artifactRoot . $name) === $before, 'Original result artifact unchanged: ' . $name); } $expect(serialize($ledger) === $ledgerBytes, 'Every source ledger byte is preserved across all validations.'); $expect(!class_exists('app\\adminapi\\logic\\tcm\\DiagnosisAiLogic', false), 'No inference/runtime logic loaded.'); if ($failures !== []) { fwrite(STDERR, 'TCM r25 role/pulse: ' . count($failures) . ' failures / ' . $checks . " assertions\n- " . implode("\n- ", $failures) . "\n"); exit(1); } echo "TCM r25 role/pulse: {$checks} assertions passed (pure Guard/Contract + composed bounded streaming; no model/network).\n";