1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
//! Cross-component aggregation contracts (AGENTS.md §20) — properties that
//! only exist once more than one difficulty-contributing component is
//! wired into `analyze`, so they can't live in a single component's test
//! file.
use yomitoki::{AnalysisConfig, FindingCode};
#[test]
fn ring_topology_is_a_full_pass_through_floor_for_difficulty() {
// AGGREGATE_WEIGHT_RING_TOPOLOGY = 1.0 is the *only* aggregation weight
// that's a full pass-through (see rules.rs) — every other component
// contributes at a fractional weight (0.4/0.5/0.4), so
// `difficulty >= component.normalized` is only guaranteed to hold for
// ring_topology specifically, not for size/stereo/functional-group
// liability in general.
//
// An earlier version of this test asserted that inequality for all
// four components — false in general: a plain 40-carbon alkane has
// size_topology.normalized ~0.52 but difficulty ~0.21, since
// AGGREGATE_WEIGHT_SIZE_TOPOLOGY (0.4) dilutes it. It only passed
// because its one fixture happened to have dominant ring burden
// masking that. Caught by an advisor review, not by this test.
let config = AnalysisConfig::default();
let report =
yomitoki::analyze_smiles("C1CC2CCC1C2C(N)C(O)C(Cl)C", &config).expect("valid SMILES");
let ring_alone = report
.components
.ring_topology
.as_ref()
.expect("ring_topology always runs")
.normalized
.value();
assert!(
report.overall.difficulty.value() >= ring_alone,
"overall difficulty {} fell below ring_topology alone {ring_alone}",
report.overall.difficulty.value(),
);
}
#[test]
fn adding_a_functional_group_liability_never_lowers_difficulty() {
// Metamorphic property (AGENTS.md §14.3 style): a molecule that gains a
// Brenk-alert-triggering feature, with the rest of its structure held
// equal, must not read as *easier*. Ethane (no alerts) vs. acetonitrile
// (CC#N, triggers the `nitrile` alert) isolates functional_group_
// liability's contribution — both have zero ring and stereo burden,
// and size_topology's own contribution barely moves between them
// (confirmed via probe: 0.0090 vs 0.0122), so functional_group_
// liability (0.0 vs 0.0769) is what actually drives the difference.
let config = AnalysisConfig::default();
let plain = yomitoki::analyze_smiles("CC", &config).expect("ethane");
let with_fg_alert = yomitoki::analyze_smiles("CC#N", &config).expect("acetonitrile");
assert!(
with_fg_alert.overall.difficulty.value() >= plain.overall.difficulty.value(),
"plain={} with_alert={}",
plain.overall.difficulty.value(),
with_fg_alert.overall.difficulty.value()
);
}
#[test]
fn dominant_penalties_rank_across_components_by_contribution_not_by_component_identity() {
// Pins down a real cross-component ranking outcome so a change to any
// component's weight constants forces a conscious look at this test
// rather than silently reordering which component "wins" — see
// tasks/lessons.md for why this needed its own test instead of relying
// on ring_topology's own (single-component) ranking test.
let config = AnalysisConfig::default();
// 2 stereocenters (weight 0.24) vs. one bridged ring (weight 0.6):
// ring wins under the current STEREO_WEIGHT_PER_CENTER.
let ring_wins = yomitoki::analyze_smiles("C1CC2CCC1C2C(N)C(O)C(Cl)C", &config)
.expect("valid SMILES")
.dominant_penalties()[0]
.code;
assert_eq!(ring_wins, FindingCode::RingBridgedComplexity);
// 6 stereocenters (weight 0.72) vs. the same bridged ring (weight
// 0.6): stereo burden outranks ring burden once enough centers pile
// up. Documents that this is expected under the current weights, not
// a bug.
let stereo_wins = yomitoki::analyze_smiles("OC1CC2(C(N)C(O)C(Cl)C(N)C)CCC1C2", &config)
.expect("valid SMILES")
.dominant_penalties()[0]
.code;
assert_eq!(stereo_wins, FindingCode::StereoCenterCount);
}
#[test]
fn heavier_molecules_are_not_prematurely_saturated_to_the_same_difficulty() {
// Guards against the weighted sum silently clamping two distinguishable
// molecules to the same ceiling value before `ProbabilityLikeScore`'s
// 0.0..=1.0 clamp does its job — that would make them indistinguishable
// even though they clearly differ in size/flexibility.
let config = AnalysisConfig::default();
let lighter = yomitoki::analyze_smiles("CCCCCCCCCCCCCC", &config).expect("14-carbon chain");
let heavier = yomitoki::analyze_smiles("CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", &config)
.expect("40-carbon chain");
let lighter_difficulty = lighter.overall.difficulty.value();
let heavier_difficulty = heavier.overall.difficulty.value();
assert!(
heavier_difficulty > lighter_difficulty,
"lighter={lighter_difficulty} heavier={heavier_difficulty}"
);
assert!(
lighter_difficulty < 1.0,
"lighter molecule already saturated at {lighter_difficulty}"
);
}