1, 'name' => '黄芪', 'aliases' => ['黄耆'], 'dictionary_version' => 'fixture-v1'], ['id' => 2, 'name' => '党参', 'dictionary_version' => 'fixture-v1'], ['id' => 3, 'name' => '白术', 'dictionary_version' => 'fixture-v1'], ['id' => 4, 'name' => '茯苓', 'dictionary_version' => 'fixture-v1'], ['id' => 5, 'name' => '炙甘草', 'processing' => '蜜炙', 'dictionary_version' => 'fixture-v1'], ]; $herb = static fn (string $name, $dosage, array $extra = []): array => array_replace([ 'name' => $name, 'dosage' => $dosage, 'unit' => 'g', 'dose_basis' => 'per_dose', 'formula_type' => '主方', ], $extra); $rx = static fn (array $herbs, array $extra = []): array => array_replace([ 'prescription_type' => '饮片', 'herbs' => $herbs, 'dose_count' => 7, 'usage_days' => 7, 'times_per_day' => 2, 'usage_way' => '温服', ], $extra); $run = static fn (array $doctor, array $candidate, ?array $dictionary = null): array => PrescriptionAiComparison::compare($doctor, $candidate, $dictionary ?? $catalog); $doctor = $rx([$herb('黄芪', 12), $herb('党参', 10)]); $same = $run($doctor, $doctor); comparisonExpect(array_keys($same) === ['status', 'score', 'herb_score', 'reason_code', 'reason', 'algorithm_version', 'doctor_count', 'candidate_count', 'matched_count', 'rows', 'usage_differences', 'normalization'], 'Public response shape is stable'); comparisonNear($same['score'], 100.0, 'Identical valid prescriptions have score 100'); comparisonNear($same['herb_score'], 100.0, 'Identical herb overlap has score 100'); comparisonExpect($same['doctor_count'] === 2 && $same['matched_count'] === 2, 'Counts use normalized medication items'); comparisonExpect($same['normalization']['denominator'] === 4 && $same['usage_differences'] === [], 'Denominator and equal usage are transparent'); comparisonExpect($same['normalization']['dictionary_versions'] === ['fixture-v1'], 'Dictionary versions are retained'); $legacy = $rx([['name' => '黄芪', 'medicine_id' => 1, 'dosage' => '12.00'], ['name' => '党参', 'dosage' => 10]]); $legacyResult = $run($legacy, $doctor); comparisonNear($legacyResult['score'], 100.0, 'Persisted doctor 饮片 contract supplies absent g/per_dose/main only'); comparisonExpect(count($legacyResult['normalization']['doctor']['defaults']) === 6, 'Every persisted-contract default is audited'); comparisonBlocked($run($doctor, $legacy), 'ambiguous_herb_role'); $disjoint = $run($doctor, $rx([$herb('白术', 12), $herb('茯苓', 10)])); comparisonNear($disjoint['score'], 0.0, 'Nonempty comparable disjoint prescriptions are genuine zero'); comparisonNear($disjoint['herb_score'], 0.0, 'Disjoint herb overlap is zero'); comparisonExpect($disjoint['matched_count'] === 0, 'Disjoint match count remains zero'); $part = $run($doctor, $rx([$herb('黄芪', 6), $herb('白术', 10)])); comparisonNear($part['score'], 25.0, 'Half dosage contribution plus one unmatched item yields 25'); comparisonNear($part['herb_score'], 50.0, 'Herb overlap is independent of dosage weighting'); comparisonExpect(count(array_filter($part['rows'], static fn (array $row): bool => $row['match_type'] === 'matched')) === 1, 'Detail distinguishes matched and one-sided herbs'); $tenCatalog = []; $tenDoctor = []; $tenCandidate = []; for ($index = 1; $index <= 12; $index++) { $tenCatalog[] = ['id' => $index, 'name' => '测试药' . $index]; if ($index <= 10) { $tenDoctor[] = $herb('测试药' . $index, 10); } if ($index <= 8 || $index >= 11) { $tenCandidate[] = $herb('测试药' . $index, 10); } } comparisonNear($run($rx($tenDoctor), $rx($tenCandidate), $tenCatalog)['score'], 80.0, 'Plan example: eight common among ten each is 80'); $tenCandidate[0]['dosage'] = 5; comparisonNear($run($rx($tenDoctor), $rx($tenCandidate), $tenCatalog)['score'], 75.0, 'Plan example: one half-dose common item yields 75'); $forged = $run($rx([$herb('黄芪', 10)]), $rx([$herb('白术', 10, ['medicine_id' => 1])])); comparisonNear($forged['score'], 0.0, 'Model cannot forge an overlapping identity using medicine_id'); $alias = $run($rx([$herb('黄芪', 10)]), $rx([$herb(' 黄耆 ', 10, ['medicine_id' => 987])])); comparisonNear($alias['score'], 100.0, 'Trusted unique alias maps by name regardless of invented model ID'); comparisonExpect($alias['rows'][0]['candidate']['medicine_id'] === 1, 'Output identity comes only from the dictionary'); comparisonBlocked($run($rx([$herb('黄芪', 10, ['medicine_id' => 3])]), $rx([$herb('黄芪', 10)])), 'doctor_identity_mismatch'); comparisonBlocked($run($doctor, $rx([$herb('不认识的药', 10, ['medicine_id' => 1])])), 'unknown_herb_name'); $unknownPartial = $run($doctor, $rx([$herb('黄芪', 12), $herb('不认识的药', 10)])); comparisonBlocked($unknownPartial, 'unknown_herb_name'); comparisonExpect($unknownPartial['herb_score'] === null, 'Unknown herbs must not be dropped to fabricate even a complete herb score'); comparisonBlocked($run($doctor, $doctor, []), 'catalog_unavailable'); $ambiguousCatalog = array_merge($catalog, [['id' => 8, 'name' => '其他药', 'aliases' => ['黄芪']]]); comparisonBlocked($run($doctor, $doctor, $ambiguousCatalog), 'ambiguous_herb_name'); $corruptCatalog = array_merge($catalog, [['id' => 1, 'name' => '伪同ID药']]); comparisonBlocked($run($doctor, $rx([$herb('伪同ID药', 12)]), $corruptCatalog), 'ambiguous_herb_name'); comparisonExpect($same['normalization']['dictionary_hash'] === $run($doctor, $doctor, array_reverse($catalog))['normalization']['dictionary_hash'], 'Dictionary hash ignores server row ordering'); $single = $rx([$herb('黄芪', 10)]); $split = $rx([$herb('黄芪', 4), $herb('黄耆', 6)]); $merged = $run($single, $split); comparisonNear($merged['score'], 100.0, 'Splitting a same-semantics dose cannot manipulate the score'); comparisonExpect($merged['candidate_count'] === 1 && count($merged['normalization']['candidate']['merges']) === 1, 'Merged item count and merge audit are retained'); comparisonExpect($merged['rows'][0]['candidate']['source_rows'] === [0, 1], 'Merge trace points to both original rows'); comparisonNear($run($single, $rx([$herb('黄芪', 10), $herb('黄芪', 10)]))['score'], 50.0, 'Repeated complete doses add, rather than silently deduplicating'); $differentUsage = $rx([$herb('黄芪', 4), $herb('黄芪', 6, ['decoction_instruction' => '先煎'])]); comparisonBlocked($run($single, $differentUsage), 'duplicate_semantics_conflict'); comparisonBlocked($run($single, $rx([$herb('黄芪', 4), $herb('黄芪', 6, ['unit' => 'mg'])])), 'duplicate_semantics_conflict'); comparisonBlocked($run($single, $rx([$herb('黄芪', 1.0e308), $herb('黄芪', 1.0e308)])), 'invalid_dosage'); comparisonNear($run($single, $rx([$herb('黄芪', 10, ['formula_type' => '辅方'])]))['score'], 0.0, 'Main and auxiliary roles are never rearranged to maximize score'); comparisonNear($run($single, $rx([$herb('黄芪', 10, ['processing' => '蜜炙'])]))['score'], 0.0, 'Distinct processing is a distinct medication item'); comparisonNear($run($single, $rx([$herb('黄芪', 10, ['administration_route' => '外用'])]))['score'], 0.0, 'Distinct route is part of medication identity'); comparisonNear($run($single, $rx([$herb('黄芪', 10, ['group' => '睡前组'])]))['score'], 0.0, 'Explicit grouping is not permuted'); comparisonBlocked($run($single, $rx([$herb('黄芪', 10, ['formula_type' => '备选'])])), 'ambiguous_herb_role'); comparisonBlocked($run($rx([$herb('炙甘草', 10)]), $rx([$herb('炙甘草', 10, ['processing' => '生品'])])), 'processing_conflict'); // Institution catalogs commonly carry the processed form inside the name. Restating it is a label, // not a second identity; a processing the name does not carry still splits the item. $namedCatalog = [ ['id' => 11, 'name' => '醋五味子', 'dictionary_version' => 'fixture-v1'], ['id' => 12, 'name' => '麸炒白术', 'dictionary_version' => 'fixture-v1'], ['id' => 13, 'name' => '黄芪', 'dictionary_version' => 'fixture-v1'], ]; $named = static fn (array $doctor, array $candidate): array => PrescriptionAiComparison::compare($doctor, $candidate, $namedCatalog); comparisonNear($named($rx([$herb('醋五味子', 6)]), $rx([$herb('醋五味子', 6, ['processing' => '醋制'])]))['score'], 100.0, 'A processing label already carried by the medicine name does not split the item'); comparisonNear($named($rx([$herb('麸炒白术', 12)]), $rx([$herb('麸炒白术', 12, ['processing' => '麸炒'])]))['score'], 100.0, 'Multi-character processing labels restating the name are also treated as one identity'); $labelled = $named($rx([$herb('醋五味子', 6)]), $rx([$herb('醋五味子', 6, ['processing' => '醋制'])])); comparisonExpect(in_array('herb_processing_label', array_column($labelled['usage_differences'], 'field'), true), 'The differing processing label is still reported as a difference to review'); comparisonNear($named($rx([$herb('黄芪', 10)]), $rx([$herb('黄芪', 10, ['processing' => '蜜炙'])]))['score'], 0.0, 'A processing the name does not carry remains a distinct medication item'); foreach ([null, '', '未知', 0, false] as $unit) { comparisonBlocked($run($single, $rx([$herb('黄芪', 10, ['unit' => $unit])])), 'missing_or_unknown_unit'); } foreach ([null, '', '每次', 'total', false] as $basis) { comparisonBlocked($run($single, $rx([$herb('黄芪', 10, ['dose_basis' => $basis])])), 'missing_or_unknown_dose_basis'); } comparisonBlocked($run($single, $rx([$herb('黄芪', 10000, ['unit' => 'mg'])])), 'unit_mismatch'); comparisonNear($run($single, $rx([$herb('黄芪', '10.0', ['unit' => '克', 'dose_basis' => '每剂'])]))['score'], 100.0, 'Only unit and basis spelling aliases normalize'); comparisonBlocked($run($single, $rx([$herb('黄芪', 10)], ['prescription_type' => '颗粒'])), 'formulation_mismatch'); comparisonBlocked($run($single, $rx([$herb('白术', 10, ['dose_basis' => 'per_day'])])), 'dose_basis_mismatch'); comparisonBlocked($run($rx([['name' => '黄芪', 'dosage' => 10]], ['prescription_type' => '浓缩水丸']), $rx([$herb('黄芪', 10)], ['prescription_type' => '浓缩水丸'])), 'missing_or_unknown_unit'); comparisonBlocked($run($rx([$herb('黄芪', 10, ['unit' => null])]), $single), 'missing_or_unknown_unit'); foreach ([null, '', ' ', 0, -1, '0', '-0.1', true, false, [], '十', '10g', 'NaN', 'INF', INF, -INF, NAN, '1e9999', '1e-9999'] as $dose) { $invalidDose = $run($single, $rx([$herb('黄芪', $dose)])); comparisonBlocked($invalidDose, 'invalid_dosage'); comparisonNear($invalidDose['herb_score'], 100.0, 'Known herb overlap may survive invalid dosage, but never replace S'); comparisonExpect($invalidDose['rows'][0]['contribution'] === null, 'Invalid full comparison cannot show usable partial contributions'); } comparisonBlocked($run($rx([]), $rx([])), 'empty_prescription'); comparisonBlocked($run($single, $rx([])), 'empty_prescription'); comparisonBlocked($run($single, $rx([null])), 'invalid_herb'); foreach (['insufficient_data', 'withheld_for_risk', 'failed', 'no_medication'] as $status) { comparisonBlocked($run($single, $rx([], ['status' => $status])), $status); } $usage = $run($single, $rx([$herb('黄芪', 10, ['decoction_instruction' => '后下'])], [ 'dose_count' => 14, 'usage_days' => 14, 'times_per_day' => 3, 'usage_way' => '冷服', 'aux_usage' => ['usage_days' => 3], ])); comparisonNear($usage['score'], 100.0, 'Usage changes remain visible even at 100 structural agreement'); comparisonExpect(count($usage['usage_differences']) === 6, 'Course, frequency, route, auxiliary plan and herb instruction differences are retained'); $sameUsage = $run($single, $rx([$herb('黄芪', 10)], ['dose_count' => '7', 'usage_days' => '7.0', 'times_per_day' => '2'])); comparisonExpect($sameUsage['usage_differences'] === [], 'Numeric database serialization does not invent usage changes'); // Real workstation rows: the per-herb unit lives once on the prescription (用量单位) and the // dose basis is declared by 剂量单位=剂. Neither may be guessed when the prescription omits them. $storedHerb = static fn (string $name, $dosage): array => ['name' => $name, 'dosage' => $dosage, 'formula_type' => '主方']; $storedRx = static fn (array $herbs, array $extra = []): array => array_replace([ 'prescription_type' => '浓缩水丸', 'dosage_unit' => 'g', 'dose_unit' => '剂', 'dose_count' => 1, 'usage_days' => 7, 'times_per_day' => 2, 'herbs' => $herbs, ], $extra); $aiRx = static fn (array $herbs): array => [ 'status' => 'available_for_review', 'prescription_type' => '浓缩水丸', 'dose_basis' => 'per_dose', 'herbs' => $herbs, 'usage_days' => 7, 'times_per_day' => 2, 'dose_count' => 1, ]; $stored = $run($storedRx([$storedHerb('黄芪', '16'), $storedHerb('党参', '15')]), $aiRx([$herb('黄芪', 16.0), $herb('党参', 15.0)])); comparisonNear($stored['score'], 100.0, 'Stored rows without a per-row unit compare through the prescription 用量单位 and 剂量单位'); comparisonExpect(count($stored['normalization']['doctor']['defaults']) === 4, 'Every applied unit and dose-basis fallback stays recorded per row'); $halfDose = $run($storedRx([$storedHerb('黄芪', '16'), $storedHerb('党参', '15')]), $aiRx([$herb('黄芪', 8.0), $herb('党参', 15.0)])); comparisonNear($halfDose['score'], 75.0, 'A doubled dose in one common herb halves that row contribution'); comparisonBlocked($run($storedRx([$storedHerb('黄芪', '16')], ['dosage_unit' => '']), $aiRx([$herb('黄芪', 16.0)])), 'missing_or_unknown_unit'); comparisonBlocked($run($storedRx([$storedHerb('黄芪', '16')], ['dose_unit' => '盒', 'prescription_type' => '浓缩水丸']), $aiRx([$herb('黄芪', 16.0)])), 'missing_or_unknown_dose_basis'); $mlRx = $run($storedRx([$storedHerb('黄芪', '16')], ['dosage_unit' => 'ml']), $aiRx([$herb('黄芪', 16.0)])); comparisonExpect($mlRx['status'] === 'not_comparable' && $mlRx['normalization']['issues'][0]['code'] === 'unit_mismatch', 'A declared millilitre prescription is never silently compared against grams'); $unitNoise = $run($storedRx([$storedHerb('黄芪', '16')]), $aiRx([$herb('黄芪', 16.0)])); comparisonExpect(!in_array('dosage_unit', array_column($unitNoise['usage_differences'], 'field'), true), 'A unanimous per-herb unit is not reported as a usage difference against the prescription 用量单位'); $mixedUnits = $run($storedRx([$storedHerb('黄芪', '16')]), $aiRx([$herb('黄芪', 16.0), $herb('党参', 10.0, ['unit' => 'ml'])])); comparisonExpect(in_array('dosage_unit', array_column($mixedUnits['usage_differences'], 'field'), true), 'Mixed candidate units are never presented as one agreed prescription unit'); // Deterministic algebra properties across nontrivial overlaps and dose ratios. for ($iteration = 1; $iteration <= 25; $iteration++) { $left = $rx([$herb('黄芪', $iteration * 0.7), $herb('党参', 9.0)]); $right = $rx([$herb('黄芪', ($iteration + 3) * 0.4), $herb('白术', 11.0)]); $forward = $run($left, $right); $reverse = $run($right, $left); comparisonNear($forward['score'], $reverse['score'], 'Soft-Dice is symmetric'); comparisonExpect($forward['score'] >= 0.0 && $forward['score'] <= $forward['herb_score'], 'Dose agreement is bounded by herb agreement'); $right['herbs'] = array_reverse($right['herbs']); comparisonNear($forward['score'], $run($left, $right)['score'], 'Row permutation cannot change the score'); } echo 'PRESCRIPTION_AI_COMPARISON_TEST_OK ' . $checks . " checks\n";