diff --git a/.gitignore b/.gitignore index 058fa071..36f929e4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ dist/ *.log .DS_Store +__pycache__/ # Rust target/ diff --git a/crates/labcolors-core/contracts/clean-set-srgb8-v1/receipt-v1.json b/crates/labcolors-core/contracts/clean-set-srgb8-v1/receipt-v1.json index 9876ba9a..14865105 100644 --- a/crates/labcolors-core/contracts/clean-set-srgb8-v1/receipt-v1.json +++ b/crates/labcolors-core/contracts/clean-set-srgb8-v1/receipt-v1.json @@ -48,11 +48,11 @@ "sha256": "8bf5d5a1c0f00ce245a1ecb18b923aa1631483345962d72b633e466c242a8a1d" }, { - "bytes": 13860, + "bytes": 14149, "license": "MIT", "path": "crates/labcolors-core/src/lib.rs", "role": "module_registration_source", - "sha256": "55441efda3b9d8f8534cc015a15d4bf1390d74435738a1196c3bd44b6fb55120" + "sha256": "919b46d9a838b1f5f333af938c2db7bfa20ba444e22c841def0349d93d213c38" }, { "bytes": 39439, diff --git a/crates/labcolors-core/contracts/clean-set-srgb8-v1/receipt-v1.sha256 b/crates/labcolors-core/contracts/clean-set-srgb8-v1/receipt-v1.sha256 index e52fa872..2e924cae 100644 --- a/crates/labcolors-core/contracts/clean-set-srgb8-v1/receipt-v1.sha256 +++ b/crates/labcolors-core/contracts/clean-set-srgb8-v1/receipt-v1.sha256 @@ -1 +1 @@ -7faaf2132ca3eb0f3686c81953ecc3e3327e80d1d036f92c5f3227fc99cbb613 receipt-v1.json +a1cedccfe9acf5feeb9244e4c9ed3dc7ee1c1703fc6910c665dbd88443899b14 receipt-v1.json diff --git a/crates/labcolors-core/contracts/contextual-region-formula-v1.lcir b/crates/labcolors-core/contracts/contextual-region-formula-v1.lcir new file mode 100644 index 00000000..bc286907 --- /dev/null +++ b/crates/labcolors-core/contracts/contextual-region-formula-v1.lcir @@ -0,0 +1,701 @@ +labcolors_exact_real_ssa 1 +arithmetic exact_real_v1 +types 4 +type u8 unsigned_integer_0_255 +type real mathematical_real +type bool exact_boolean +type surround_profile closed_enum +operators 20 +operator lookup 2 real table_u8_exact_dyadic_at_ordinal +operator eq 2 bool exact_same_type_equality +operator select 3 same bool_true_second_else_third +operator add 2 real exact_x_plus_y +operator sub 2 real exact_x_minus_y +operator mul 2 real exact_x_times_y +operator div 2 real domain_y_ne_zero_x_div_y_else_domain_unproven +operator min 2 real exact_lesser_real +operator max 2 real exact_greater_real +operator root3 1 real domain_x_ge_zero_unique_y_ge_zero_y_cubed_eq_x_else_domain_unproven +operator sqrt 1 real domain_x_ge_zero_unique_y_ge_zero_y_squared_eq_x_else_domain_unproven +operator exp 1 real analytic_natural_exponential +operator log 1 real domain_x_gt_zero_analytic_natural_logarithm_else_domain_unproven +operator sin 1 real analytic_sine_radians +operator cos 1 real analytic_cosine_radians +operator abs 1 real exact_absolute_value +operator sign 1 real negative_minus_one_zero_zero_positive_one +operator pow_pos 2 real domain_x_gt_zero_exp_y_mul_log_x_else_domain_unproven +operator pow_nn 2 real if_x_eq_zero_and_y_gt_zero_zero_else_pow_pos +operator ratio0 2 real if_x_eq_zero_and_y_eq_zero_zero_else_domain_y_gt_zero_x_div_y +decode_table decode_srgb8 256 +decode 00 0000000000000000 +decode 01 3f33e45677c176f7 +decode 02 3f43e45677c176f7 +decode 03 3f4dd681b3a23272 +decode 04 3f53e45677c176f7 +decode 05 3f58dd6c15b1d4b4 +decode 06 3f5dd681b3a23272 +decode 07 3f6167cba8c94818 +decode 08 3f63e45677c176f7 +decode 09 3f6660e146b9a5d5 +decode 0a 3f68dd6c15b1d4b4 +decode 0b 3f6b6a31b5259c99 +decode 0c 3f6e1e31d70c99dd +decode 0d 3f707c38bf8583a9 +decode 0e 3f71fcc2beed6421 +decode 0f 3f7390ffaf95e279 +decode 10 3f753936cc7bc928 +decode 11 3f76f5addb50c915 +decode 12 3f78c6a94031b561 +decode 13 3f7aac6c0fb97351 +decode 14 3f7ca7381f9f602b +decode 15 3f7eb74e160978d0 +decode 16 3f806e76bbda92b8 +decode 17 3f818c2a5a8a8044 +decode 18 3f82b4e09b3f0ae3 +decode 19 3f83e8b7b3bde965 +decode 1a 3f8527cd60af8b85 +decode 1b 3f86723eea8d3709 +decode 1c 3f87c8292a3db6b3 +decode 1d 3f8929a88d67b521 +decode 1e 3f8a96d91a8016bd +decode 1f 3f8c0fd67499fab6 +decode 20 3f8d94bbdefd740e +decode 21 3f8f25a44089883f +decode 22 3f9061551372c694 +decode 23 3f9135f3e4c2cce2 +decode 24 3f9210bb8642b172 +decode 25 3f92f1b8c1ae46bd +decode 26 3f93d8f839b79c0b +decode 27 3f94c6866b3e9fa4 +decode 28 3f95ba6fae794313 +decode 29 3f96b4c0380d2dee +decode 2a 3f97b5841a1bf3ac +decode 2b 3f98bcc74542addb +decode 2c 3f99ca95898dc8b5 +decode 2d 3f9adefa9761c020 +decode 2e 3f9bfa0200597bd9 +decode 2f 3f9d1bb7381aec1f +decode 30 3f9e442595227bca +decode 31 3f9f73585185e1b5 +decode 32 3fa054ad45d76878 +decode 33 3fa0f31ba386ff26 +decode 34 3fa194fcb663747b +decode 35 3fa23a55e62a662a +decode 36 3fa2e32c8e148d11 +decode 37 3fa38f85fd21eacf +decode 38 3fa43f67766310ff +decode 39 3fa4f2d6313fa8d0 +decode 3a 3fa5a9d759ba5ed0 +decode 3b 3fa6647010b254ee +decode 3c 3fa722a56c2239ee +decode 3d 3fa7e47c775d2427 +decode 3e 3fa8a9fa33494b07 +decode 3f 3fa973239698b9cc +decode 40 3faa3ffd8e001389 +decode 41 3fab108cfc6b7fbc +decode 42 3fabe4d6bb31d522 +decode 43 3facbcdf9a4616f2 +decode 44 3fad98ac60675833 +decode 45 3fae7841cb4f16df +decode 46 3faf5ba48fde2048 +decode 47 3fb0216cad240765 +decode 48 3fb096f2671eb815 +decode 49 3fb10e65c38a5192 +decode 4a 3fb187c90bf8bce2 +decode 4b 3fb2031e85f5d6da +decode 4c 3fb28068731a1952 +decode 4d 3fb2ffa9111cb94b +decode 4e 3fb380e299e53f92 +decode 4f 3fb40417439ca10f +decode 50 3fb4894940bddbfb +decode 51 3fb5107ac0261e59 +decode 52 3fb599aded247aac +decode 53 3fb624e4ef892ed4 +decode 54 3fb6b221ebb4817e +decode 55 3fb7416702a539d1 +decode 56 3fb7d2b65206b527 +decode 57 3fb86611f43e9e6a +decode 58 3fb8fb7c007a4a70 +decode 59 3fb992f68abbbc89 +decode 5a 3fba2c83a3e6566d +decode 5b 3fbac82559cb3644 +decode 5c 3fbb65ddb7354604 +decode 5d 3fbc05aec3f4fe5e +decode 5e 3fbca79a84ebe030 +decode 5f 3fbd4ba2fc17a6a5 +decode 60 3fbdf1ca289d34b8 +decode 61 3fbe9a1206d34003 +decode 62 3fbf447c904cbb4e +decode 63 3fbff10bbbe302c2 +decode 64 3fc04fe0bedfe5f1 +decode 65 3fc0a84fe3b36d8f +decode 66 3fc101d443dfc06f +decode 67 3fc15c6ed58eefdf +decode 68 3fc1b8208da5fef0 +decode 69 3fc214ea5fc9514a +decode 6a 3fc272cd3e610123 +decode 6b 3fc2d1ca1a9d1cfb +decode 6c 3fc331e1e479cdf5 +decode 6d 3fc393158ac3674e +decode 6e 3fc3f565fb1a5fd5 +decode 6f 3fc458d421f735df +decode 70 3fc4bd60eaae3e73 +decode 71 3fc5230d3f736034 +decode 72 3fc589da095dbaa1 +decode 73 3fc5f1c8306b3a3c +decode 74 3fc65ad89b841a2b +decode 75 3fc6c50c307e53bf +decode 76 3fc73063d420fc80 +decode 77 3fc79ce06a279303 +decode 78 3fc80a82d5453b5d +decode 79 3fc8794bf727eb3f +decode 7a 3fc8e93cb07b8679 +decode 7b 3fc95a55e0ecec0b +decode 7c 3fc9cc98672cf47e +decode 7d 3fca400520f3619c +decode 7e 3fcab49ceb01c003 +decode 7f 3fcb2a60a1263b0a +decode 80 3fcba1511e3e632d +decode 81 3fcc196f3c39e76f +decode 82 3fcc92bbd41d41fe +decode 83 3fcd0d37be045851 +decode 84 3fcd88e3d1250f68 +decode 85 3fce05c0e3d1d3e0 +decode 86 3fce83cfcb7c16f0 +decode 87 3fcf03115cb6bfd3 +decode 88 3fcf83866b38924d +decode 89 3fd00297e4ef4553 +decode 8a 3fd044072557177a +decode 8b 3fd086115f6beb3a +decode 8c 3fd0c8b6fb5c735e +decode 8d 3fd10bf860ef039a +decode 8e 3fd14fd5f782a5a6 +decode 8f 3fd1945026102997 +decode 90 3fd1d967532b31b1 +decode 91 3fd21f1be50339e7 +decode 92 3fd2656e41649ae3 +decode 93 3fd2ac5ecdb988f8 +decode 94 3fd2f3edef0b0ed8 +decode 95 3fd33c1c0a020438 +decode 96 3fd384e982e800b1 +decode 97 3fd3ce56bda84a81 +decode 98 3fd418641dd0c1bc +decode 99 3fd463120692c7af +decode 9a 3fd4ae60dac4229d +decode 9b 3fd4fa50fcdfde15 +decode 9c 3fd546e2cf0727a9 +decode 9d 3fd59416b3022858 +decode 9e 3fd5e1ed0a40daab +decode 9f 3fd6306635dbdd7b +decode a0 3fd67f82969543a2 +decode a1 3fd6cf428cd96079 +decode a2 3fd71fa678bf915d +decode a3 3fd770aeba0b042a +decode a4 3fd7c25bb02b7ac5 +decode a5 3fd814adba3e0bd9 +decode a6 3fd867a5370de0b1 +decode a7 3fd8bb428514f067 +decode a8 3fd90f86027cb84e +decode a9 3fd964700d1ef1b1 +decode aa 3fd9ba0102864521 +decode ab 3fda10393feefafd +decode ac 3fda67192247a9be +decode ad 3fdabea10631e195 +decode ae 3fdb16d14802d5ca +decode af 3fdb6faa43c403bb +decode b0 3fdbc92c5533d785 +decode b1 3fdc2357d7c64e5d +decode b2 3fdc7e2d26a596de +decode b3 3fdcd9ac9cb2aef2 +decode b4 3fdd35d69485ffc5 +decode b5 3fdd92ab686ff782 +decode b6 3fddf02b7279a10d +decode b7 3fde4e570c6539c5 +decode b8 3fdead2e8faec526 +decode b9 3fdf0cb2558c9ea4 +decode ba 3fdf6ce2b6f00983 +decode bb 3fdfcdc00c85bec2 +decode bc 3fe017a5575b3cb2 +decode bd 3fe048c17ad3c04b +decode be 3fe07a349c9d9837 +decode bf 3fe0abfee888c050 +decode c0 3fe0de208a4444c8 +decode c1 3fe11099ad5e83eb +decode c2 3fe1436a7d456eef +decode c3 3fe176932546ca12 +decode c4 3fe1aa13d0906bda +decode c5 3fe1ddecaa307b85 +decode c6 3fe2121ddd15aece +decode c7 3fe246a7940f86d1 +decode c8 3fe27b89f9ce8c4b +decode c9 3fe2b0c538e48b07 +decode ca 3fe2e6597bc4cca0 +decode cb 3fe31c46ecc4528d +decode cc 3fe3528db61a0f73 +decode cd 3fe3892e01df1fcc +decode ce 3fe3c027fa0f01eb +decode cf 3fe3f77bc887cd3b +decode d0 3fe42f29970a68f8 +decode d1 3fe467318f3ac22d +decode d2 3fe49f93daa00113 +decode d3 3fe4d850a2a4bde1 +decode d4 3fe51168109734e5 +decode d5 3fe54ada4da97a1b +decode d6 3fe584a782f1ac23 +decode d7 3fe5becfd96a2698 +decode d8 3fe5f95379f1b3ed +decode d9 3fe634328d4bbe97 +decode da 3fe66f6d3c2081cf +decode db 3fe6ab03aefd39aa +decode dc 3fe6e6f60e5452b1 +decode dd 3fe72344827d98f6 +decode de 3fe75fef33b6669b +decode df 3fe79cf64a21d1e2 +decode e0 3fe7da59edc8dab0 +decode e1 3fe8181a469a9787 +decode e2 3fe856377c6c6224 +decode e3 3fe894b1b6fa0377 +decode e4 3fe8d3891de5df49 +decode e5 3fe912bdd8b91f45 +decode e6 3fe952500ee3dda5 +decode e7 3fe9923fe7bd4f67 +decode e8 3fe9d28d8a83edfc +decode e9 3fea13391e5da09f +decode ea 3fea5442ca57e52e +decode eb 3fea95aab567f88f +decode ec 3fead771066afec2 +decode ed 3feb1995e4262a69 +decode ee 3feb5c197546e3f8 +decode ef 3feb9efbe062f086 +decode f0 3febe23d4bf8981b +decode f1 3fec25ddde6ecbbb +decode f2 3fec69ddbe154af1 +decode f3 3fecae3d1124c90b +decode f4 3fecf2fbfdbf11f1 +decode f5 3fed381aa9ef2e82 +decode f6 3fed7d993ba988d4 +decode f7 3fedc377d8cc0fd5 +decode f8 3fee09b6a71e5aa6 +decode f9 3fee5055cc51cbb4 +decode fa 3fee97556e01b351 +decode fb 3feedeb5b1b37216 +decode fc 3fef2676bcd69ade +decode fd 3fef6e98b4c51466 +decode fe 3fefb71bbec33ab2 +decode ff 3ff0000000000000 +literals 56 +literal zero 0000000000000000 +literal p0_007 3f7cac083126e979 +literal p0_0228 3f9758e219652bd4 +literal p0_1 3fb999999999999a +literal srgb_m20 3f93cb7548c0e47c +literal cat_m21 3fa9103c8e25c811 +literal cat_m12 3fa77a2cecc814d7 +literal p0_2 3fc999999999999a +literal p0_25 3fd0000000000000 +literal p0_29 3fd28f5c28f5c28f +literal p0_305 3fd3851eb851eb85 +literal p0_42 3fdae147ae147ae1 +literal p0_525 3fe0cccccccccccd +literal p0_59 3fe2e147ae147ae1 +literal p0_69 3fe6147ae147ae14 +literal p0_725 3fe7333333333333 +literal p0_73 3fe75c28f5c28f5c +literal p0_8 3fe999999999999a +literal p0_9 3feccccccccccccd +literal d65_x 3fee6a228c5f3dc7 +literal d65_z 3ff16cc7d1ef8103 +literal srgb_m00 3fda649c610130ae +literal srgb_m01 3fd6e2a96ccdcb18 +literal srgb_m02 3fc719fe95deff92 +literal srgb_m10 3fcb37c144093a33 +literal srgb_m11 3fe6e2a96ccdcb18 +literal srgb_m12 3fb27b32117f32db +literal srgb_m21 3fbe838c9112641e +literal srgb_m22 3fee6ac26776ae5f +literal cat_m00 3fd9aeb3dd11be6e +literal cat_m01 3fe4ce379b77c02b +literal cat_m02 bfaa5918a009f623 +literal cat_m10 bfd0046412cf0f9d +literal cat_m11 3ff345479d4d8341 +literal cat_m20 bf6107faa044ae86 +literal cat_m22 3fee800431bde82d +literal one 3ff0000000000000 +literal p1_05 3ff0cccccccccccd +literal p1_48 3ff7ae147ae147ae +literal p1_64 3ffa3d70a3d70a3d +literal p1_7 3ffb333333333333 +literal two 4000000000000000 +literal p3_6 400ccccccccccccd +literal p3_8 400e666666666666 +literal five 4014000000000000 +literal nine 4022000000000000 +literal eleven 4026000000000000 +literal twelve 4028000000000000 +literal thirteen 402a000000000000 +literal twenty 4034000000000000 +literal p27_13 403b2147ae147ae1 +literal forty_two 4045000000000000 +literal ninety_two 4057000000000000 +literal hundred 4059000000000000 +literal four_hundred 4079000000000000 +literal fifty_thousand 40e86a0000000000 +enum_type surround_profile 3 +enum surround_profile surround_average 01 +enum surround_profile surround_dim 02 +enum surround_profile surround_dark 03 +point_inputs 6 +input r8 u8 +input g8 u8 +input b8 u8 +input adapting_luminance real +input background_ratio real +input surround surround_profile +point_nodes 226 +node linear_r real lookup decode_srgb8 r8 +node linear_g real lookup decode_srgb8 g8 +node linear_b real lookup decode_srgb8 b8 +node xyz_x_r real mul srgb_m00 linear_r +node xyz_x_g real mul srgb_m01 linear_g +node xyz_x_b real mul srgb_m02 linear_b +node xyz_x_rg real add xyz_x_r xyz_x_g +node xyz_x real add xyz_x_rg xyz_x_b +node xyz_y_r real mul srgb_m10 linear_r +node xyz_y_g real mul srgb_m11 linear_g +node xyz_y_b real mul srgb_m12 linear_b +node xyz_y_rg real add xyz_y_r xyz_y_g +node xyz_y real add xyz_y_rg xyz_y_b +node xyz_z_r real mul srgb_m20 linear_r +node xyz_z_g real mul srgb_m21 linear_g +node xyz_z_b real mul srgb_m22 linear_b +node xyz_z_rg real add xyz_z_r xyz_z_g +node xyz_z real add xyz_z_rg xyz_z_b +node is_average bool eq surround surround_average +node is_dim bool eq surround surround_dim +node surround_f_tail real select is_dim p0_9 p0_8 +node surround_f real select is_average one surround_f_tail +node surround_c_tail real select is_dim p0_59 p0_525 +node surround_c real select is_average p0_69 surround_c_tail +node surround_nc_tail real select is_dim p0_9 p0_8 +node surround_nc real select is_average one surround_nc_tail +node n real add background_ratio zero +node five_la real mul five adapting_luminance +node k_denominator real add five_la one +node k real div one k_denominator +node k_squared real mul k k +node k_fourth real mul k_squared k_squared +node fl_direct real mul k_fourth adapting_luminance +node one_minus_k_fourth real sub one k_fourth +node one_minus_k_fourth_squared real mul one_minus_k_fourth one_minus_k_fourth +node five_la_root real root3 five_la +node fl_indirect_a real mul p0_1 one_minus_k_fourth_squared +node fl_indirect real mul fl_indirect_a five_la_root +node fl real add fl_direct fl_indirect +node negative_p0_2 real sub zero p0_2 +node n_power real pow_pos n negative_p0_2 +node nbb real mul p0_725 n_power +node n_root real sqrt n +node z real add p1_48 n_root +node white_x real mul d65_x hundred +node white_y real mul one hundred +node white_z real mul d65_z hundred +node white_lms_l_x real mul cat_m00 white_x +node white_lms_l_y real mul cat_m01 white_y +node white_lms_l_z real mul cat_m02 white_z +node white_lms_l_xy real add white_lms_l_x white_lms_l_y +node white_lms_l real add white_lms_l_xy white_lms_l_z +node white_lms_m_x real mul cat_m10 white_x +node white_lms_m_y real mul cat_m11 white_y +node white_lms_m_z real mul cat_m12 white_z +node white_lms_m_xy real add white_lms_m_x white_lms_m_y +node white_lms_m real add white_lms_m_xy white_lms_m_z +node white_lms_s_x real mul cat_m20 white_x +node white_lms_s_y real mul cat_m21 white_y +node white_lms_s_z real mul cat_m22 white_z +node white_lms_s_xy real add white_lms_s_x white_lms_s_y +node white_lms_s real add white_lms_s_xy white_lms_s_z +node negative_la real sub zero adapting_luminance +node negative_la_minus_42 real sub negative_la forty_two +node d_exp_arg real div negative_la_minus_42 ninety_two +node d_exp real exp d_exp_arg +node inverse_3_6 real div one p3_6 +node d_exp_scaled real mul inverse_3_6 d_exp +node d_inner real sub one d_exp_scaled +node d_raw real mul surround_f d_inner +node d_nonnegative real max zero d_raw +node d real min one d_nonnegative +node one_minus_d real sub one d +node white_ratio_l real div hundred white_lms_l +node white_ratio_m real div hundred white_lms_m +node white_ratio_s real div hundred white_lms_s +node rgb_d_l_a real mul d white_ratio_l +node rgb_d_l real add rgb_d_l_a one_minus_d +node rgb_d_m_a real mul d white_ratio_m +node rgb_d_m real add rgb_d_m_a one_minus_d +node rgb_d_s_a real mul d white_ratio_s +node rgb_d_s real add rgb_d_s_a one_minus_d +node white_adapted_l real mul white_lms_l rgb_d_l +node white_adapted_m real mul white_lms_m rgb_d_m +node white_adapted_s real mul white_lms_s rgb_d_s +node white_abs_l real abs white_adapted_l +node white_x_l_a real mul fl white_abs_l +node white_x_l real div white_x_l_a hundred +node white_y_l real pow_nn white_x_l p0_42 +node white_mag_l_num real mul four_hundred white_y_l +node white_mag_l_den real add white_y_l p27_13 +node white_mag_l real div white_mag_l_num white_mag_l_den +node white_sign_l real sign white_adapted_l +node white_response_l real mul white_sign_l white_mag_l +node white_abs_m real abs white_adapted_m +node white_x_m_a real mul fl white_abs_m +node white_x_m real div white_x_m_a hundred +node white_y_m real pow_nn white_x_m p0_42 +node white_mag_m_num real mul four_hundred white_y_m +node white_mag_m_den real add white_y_m p27_13 +node white_mag_m real div white_mag_m_num white_mag_m_den +node white_sign_m real sign white_adapted_m +node white_response_m real mul white_sign_m white_mag_m +node white_abs_s real abs white_adapted_s +node white_x_s_a real mul fl white_abs_s +node white_x_s real div white_x_s_a hundred +node white_y_s real pow_nn white_x_s p0_42 +node white_mag_s_num real mul four_hundred white_y_s +node white_mag_s_den real add white_y_s p27_13 +node white_mag_s real div white_mag_s_num white_mag_s_den +node white_sign_s real sign white_adapted_s +node white_response_s real mul white_sign_s white_mag_s +node aw_l real mul two white_response_l +node aw_s real div white_response_s twenty +node aw_lm real add aw_l white_response_m +node aw_lms real add aw_lm aw_s +node aw real mul aw_lms nbb +node p0_29_power_n real pow_pos p0_29 n +node t_inner_base real sub p1_64 p0_29_power_n +node t_inner real pow_pos t_inner_base p0_73 +node fl_pow_025 real pow_pos fl p0_25 +node stimulus_x real mul xyz_x hundred +node stimulus_y real mul xyz_y hundred +node stimulus_z real mul xyz_z hundred +node lms_l_x real mul cat_m00 stimulus_x +node lms_l_y real mul cat_m01 stimulus_y +node lms_l_z real mul cat_m02 stimulus_z +node lms_l_xy real add lms_l_x lms_l_y +node lms_l real add lms_l_xy lms_l_z +node lms_m_x real mul cat_m10 stimulus_x +node lms_m_y real mul cat_m11 stimulus_y +node lms_m_z real mul cat_m12 stimulus_z +node lms_m_xy real add lms_m_x lms_m_y +node lms_m real add lms_m_xy lms_m_z +node lms_s_x real mul cat_m20 stimulus_x +node lms_s_y real mul cat_m21 stimulus_y +node lms_s_z real mul cat_m22 stimulus_z +node lms_s_xy real add lms_s_x lms_s_y +node lms_s real add lms_s_xy lms_s_z +node adapted_l real mul lms_l rgb_d_l +node adapted_m real mul lms_m rgb_d_m +node adapted_s real mul lms_s rgb_d_s +node adapted_abs_l real abs adapted_l +node adapted_x_l_a real mul fl adapted_abs_l +node adapted_x_l real div adapted_x_l_a hundred +node adapted_y_l real pow_nn adapted_x_l p0_42 +node adapted_mag_l_num real mul four_hundred adapted_y_l +node adapted_mag_l_den real add adapted_y_l p27_13 +node adapted_mag_l real div adapted_mag_l_num adapted_mag_l_den +node adapted_sign_l real sign adapted_l +node response_l real mul adapted_sign_l adapted_mag_l +node adapted_abs_m real abs adapted_m +node adapted_x_m_a real mul fl adapted_abs_m +node adapted_x_m real div adapted_x_m_a hundred +node adapted_y_m real pow_nn adapted_x_m p0_42 +node adapted_mag_m_num real mul four_hundred adapted_y_m +node adapted_mag_m_den real add adapted_y_m p27_13 +node adapted_mag_m real div adapted_mag_m_num adapted_mag_m_den +node adapted_sign_m real sign adapted_m +node response_m real mul adapted_sign_m adapted_mag_m +node adapted_abs_s real abs adapted_s +node adapted_x_s_a real mul fl adapted_abs_s +node adapted_x_s real div adapted_x_s_a hundred +node adapted_y_s real pow_nn adapted_x_s p0_42 +node adapted_mag_s_num real mul four_hundred adapted_y_s +node adapted_mag_s_den real add adapted_y_s p27_13 +node adapted_mag_s real div adapted_mag_s_num adapted_mag_s_den +node adapted_sign_s real sign adapted_s +node response_s real mul adapted_sign_s adapted_mag_s +node opponent_a_m_scaled real mul twelve response_m +node opponent_a_m real div opponent_a_m_scaled eleven +node opponent_a_s real div response_s eleven +node opponent_a_lm real sub response_l opponent_a_m +node opponent_a real add opponent_a_lm opponent_a_s +node opponent_b_lm real add response_l response_m +node opponent_b_s real mul two response_s +node opponent_b_num real sub opponent_b_lm opponent_b_s +node opponent_b real div opponent_b_num nine +node opponent_a_squared real mul opponent_a opponent_a +node opponent_b_squared real mul opponent_b opponent_b +node opponent_norm_squared real add opponent_a_squared opponent_b_squared +node opponent_norm real sqrt opponent_norm_squared +node achrom_l real mul two response_l +node achrom_s real div response_s twenty +node achrom_lm real add achrom_l response_m +node achrom_lms real add achrom_lm achrom_s +node achrom real mul achrom_lms nbb +node j_base real div achrom aw +node j_exponent real mul surround_c z +node j_power real pow_nn j_base j_exponent +node j real mul hundred j_power +node cos_two real cos two +node sin_two real sin two +node a_cos_two real mul opponent_a cos_two +node b_sin_two real mul opponent_b sin_two +node hue_projection real sub a_cos_two b_sin_two +node norm_hue_offset real mul p3_8 opponent_norm +node hue_sum real add hue_projection norm_hue_offset +node e_hue_times_norm real mul p0_25 hue_sum +node t_scale real div fifty_thousand thirteen +node t_num_0 real mul t_scale e_hue_times_norm +node t_num_1 real mul t_num_0 surround_nc +node t_num real mul t_num_1 nbb +node t_den_s real mul p1_05 response_s +node t_den_lm real add response_l response_m +node t_den_lms real add t_den_lm t_den_s +node t_den real add t_den_lms p0_305 +node t real div t_num t_den +node t_power real pow_nn t p0_9 +node j_fraction real div j hundred +node j_root real sqrt j_fraction +node m_0 real mul t_power j_root +node m_1 real mul m_0 t_inner +node m real mul m_1 fl_pow_025 +node jp_num real mul p1_7 j +node jp_den_scaled real mul p0_007 j +node jp_den real add one jp_den_scaled +node jp real div jp_num jp_den +node mp_scaled real mul p0_0228 m +node mp_log_arg real add one mp_scaled +node mp_log real log mp_log_arg +node mp real div mp_log p0_0228 +node opponent_a_direction real ratio0 opponent_a opponent_norm +node opponent_b_direction real ratio0 opponent_b opponent_norm +node ap real mul mp opponent_a_direction +node bp real mul mp opponent_b_direction +point_checkpoints 39 +checkpoint decoded_r linear_r +checkpoint decoded_g linear_g +checkpoint decoded_b linear_b +checkpoint xyz_x xyz_x +checkpoint xyz_y xyz_y +checkpoint xyz_z xyz_z +checkpoint surround_f surround_f +checkpoint surround_c surround_c +checkpoint surround_nc surround_nc +checkpoint n n +checkpoint fl fl +checkpoint nbb nbb +checkpoint ncb nbb +checkpoint z z +checkpoint d d +checkpoint rgb_d_l rgb_d_l +checkpoint rgb_d_m rgb_d_m +checkpoint rgb_d_s rgb_d_s +checkpoint aw aw +checkpoint t_inner t_inner +checkpoint fl_pow_025 fl_pow_025 +checkpoint lms_l lms_l +checkpoint lms_m lms_m +checkpoint lms_s lms_s +checkpoint response_l response_l +checkpoint response_m response_m +checkpoint response_s response_s +checkpoint opponent_a opponent_a +checkpoint opponent_b opponent_b +checkpoint opponent_norm opponent_norm +checkpoint achrom achrom +checkpoint j j +checkpoint e_hue_times_norm e_hue_times_norm +checkpoint t t +checkpoint m m +checkpoint jp jp +checkpoint mp mp +checkpoint ap ap +checkpoint bp bp +point_outputs 3 +output jp real +output ap real +output bp real +segment_inputs 14 +input segment_t real +input segment_a real +input segment_b real +input segment_t0 real +input segment_t1 real +input segment_c0a real +input segment_c0b real +input segment_c1a real +input segment_c1b real +input segment_rho0 real +input segment_rho1 real +input segment_g00 real +input segment_g01 real +input segment_g11 real +segment_nodes 27 +node segment_d real sub segment_t1 segment_t0 +node segment_alpha real sub segment_t1 segment_t +node segment_beta real sub segment_t segment_t0 +node segment_ca0 real mul segment_alpha segment_c0a +node segment_ca1 real mul segment_beta segment_c1a +node segment_ca real add segment_ca0 segment_ca1 +node segment_cb0 real mul segment_alpha segment_c0b +node segment_cb1 real mul segment_beta segment_c1b +node segment_cb real add segment_cb0 segment_cb1 +node segment_r0 real mul segment_alpha segment_rho0 +node segment_r1 real mul segment_beta segment_rho1 +node segment_r real add segment_r0 segment_r1 +node segment_da real mul segment_d segment_a +node segment_db real mul segment_d segment_b +node segment_ua real sub segment_da segment_ca +node segment_ub real sub segment_db segment_cb +node segment_ua_squared real mul segment_ua segment_ua +node segment_term_a real mul segment_g00 segment_ua_squared +node segment_ua_ub real mul segment_ua segment_ub +node segment_cross_a real mul segment_g01 segment_ua_ub +node segment_cross real mul two segment_cross_a +node segment_ub_squared real mul segment_ub segment_ub +node segment_term_b real mul segment_g11 segment_ub_squared +node segment_quadratic_a real add segment_term_a segment_cross +node segment_quadratic real add segment_quadratic_a segment_term_b +node segment_radius real mul segment_d segment_r +node segment_f real sub segment_quadratic segment_radius +segment_outputs 1 +output segment_f real +singleton_inputs 8 +input singleton_a real +input singleton_b real +input singleton_ca real +input singleton_cb real +input singleton_rho real +input singleton_g00 real +input singleton_g01 real +input singleton_g11 real +singleton_nodes 12 +node singleton_ua real sub singleton_a singleton_ca +node singleton_ub real sub singleton_b singleton_cb +node singleton_ua_squared real mul singleton_ua singleton_ua +node singleton_term_a real mul singleton_g00 singleton_ua_squared +node singleton_ua_ub real mul singleton_ua singleton_ub +node singleton_cross_a real mul singleton_g01 singleton_ua_ub +node singleton_cross real mul two singleton_cross_a +node singleton_ub_squared real mul singleton_ub singleton_ub +node singleton_term_b real mul singleton_g11 singleton_ub_squared +node singleton_quadratic_a real add singleton_term_a singleton_cross +node singleton_quadratic real add singleton_quadratic_a singleton_term_b +node singleton_f real sub singleton_quadratic singleton_rho +singleton_outputs 1 +output singleton_f real +driver 6 +rule tone_domain closed_first_last +rule out_of_tone_domain outside +rule one_knot_tone exact_equality_required +rule one_knot_predicate singleton_f_le_zero +rule multi_knot_predicate piecewise_linear_segment_f_le_zero +rule boundary inclusive +end diff --git a/crates/labcolors-core/contracts/point-support-reference-surplus-q55-bps-proof-v1.json b/crates/labcolors-core/contracts/point-support-reference-surplus-q55-bps-proof-v1.json index 3a643248..6dcafa59 100644 --- a/crates/labcolors-core/contracts/point-support-reference-surplus-q55-bps-proof-v1.json +++ b/crates/labcolors-core/contracts/point-support-reference-surplus-q55-bps-proof-v1.json @@ -1 +1 @@ -{"artifact_id":"wcag22-srgb8-luminance-q55-v1","basis_point_proof":{"checks":30,"drop_all_semantics":"zero required surplus; current must still meet the anchor","drop_domain_inclusive":[0,10000],"nonpositive_baseline_semantics":"zero required surplus; current must meet the anchor"},"bound_id":"point-support-reference-surplus-q55-bps-v1","certified_claim":"for every successfully evaluated enabled stability cell, decision is Retained iff current_lower_surplus >= (10000-drop_bps)/10000 * max(baseline_lower_surplus,0); the declared anchor remains a separate hard floor","comparator_proof":{"algorithm":"euclidean-continued-fraction-ordering-v1","dense_denominator_inclusive":[1,31],"dense_numerator_inclusive":[0,31],"dense_small_cases":984064,"invariant":"equal integer parts; reciprocal proper fractions reverse order","largest_fibonacci_index":186,"oracle":"unbounded-integer-cross-product","random_cases":250000,"random_corpus_sha256":"97c4af7b452b31a4ab92645f70c17acb38bf57ca55484e32ad9d7d79d97a333d","random_seed":210583930,"termination":"each nonterminal denominator becomes a strictly smaller remainder","u128_adversarial_cases":190},"declared_operation_law":"q55-lower-reference-distance-explicit-anchor-bps-retention-v1","excluded_claim":"does not certify retention against the unknown exact baseline surplus, renderer equivalence outside encoded-sRGB8 source-over, or a successful result when evaluation fails","integer_replay_envelope":{"assumption":"every Q55 luminance upper <= scale + 3","i128_max":170141183460469231731687303715884105727,"offset_cleared_denominator_max":756604737398243388,"positive_baseline_numerator_max":1188950301625811064,"rational_denominator_max":1513209474796486776,"required_denominator_max":15132094747964867760000,"required_numerator_max":11889503016258110640000,"signed_anchor_abs_coarse_max":5296233161787703716,"u128_max":340282366920938463463374607431768211455,"u64_max":18446744073709551615},"profile_id":"srgb8-q55-retained-reference-surplus-bps-v1","proof_id":"point-support-reference-surplus-integer-v1","proof_payload_sha256":"bff7d4c424a9acb7be8e91c32768f585ae2a43e43a87734f6913aaf835468b71","q55_dependency":{"artifact_id":"wcag22-srgb8-luminance-q55-v1","artifact_sha256":"7ff239d9052b346f3c50da01ca65ca2330892ed1a3ff30e190797fcef6f03604","maximum_luminance_upper":36028797018963971,"outward_interval_width_bound":3,"proof_id":"wcag22-srgb8-full-domain-q55-v1","proof_payload_sha256":"3c639a7c875046c46b56b51ecdd67d5ecaf14a1134490c88a222e7037b63c0f2","proof_sha256":"ac59cf89503170c789223b91d775213a19d4e571ef930f2ea609fcd51b14defd","q55_scale":36028797018963968},"reference_and_anchor_proof":{"anchor_identity_checks":75,"orientation_law":"distance-magnitude-symmetric-orientation-reported-separately","overlap_lower_distance":"0/1","separated_endpoint_checks":504},"schema_version":2,"site_id":"point-support-retained-reference-surplus-v1","source_binding_exclusions":["whole-crate compilation or compiler/toolchain attestation","binary, package, FFI, renderer, or browser transport attestation","unrelated Lab Colors modules outside the declared point-support semantic cone"],"source_binding_law":"point-support-rust-whole-file-semantic-cone-v2","source_binding_schema_version":2,"source_binding_scope":"exact bytes of the private point-support Rust semantic cone and its two WCAG include_str inputs; comments and cfg(test) text are intentionally significant","source_closure_sha256":"faef3711ffbfeb823e7187996d2f2a06171dea273fab2cd0aa89a56d6fefc95d","source_files":[{"kind":"compile-time-input","path":"crates/labcolors-core/contracts/wcag22-srgb8-q55-proof-v1.json","sha256":"ac59cf89503170c789223b91d775213a19d4e571ef930f2ea609fcd51b14defd"},{"kind":"compile-time-input","path":"crates/labcolors-core/contracts/wcag22-srgb8-v1.json","sha256":"b4bb7e5f17a99f2c911fdbe3da23a48b049277b796291094950f14680cc3cc7b"},{"kind":"rust-source","path":"crates/labcolors-core/src/appearance.rs","sha256":"66266904fc84afb03a3a774a8aaa0da23fe1e05a8b04abb14d5f18820e17a85d"},{"kind":"rust-source","path":"crates/labcolors-core/src/composition.rs","sha256":"195a67327a3bd86d7816b634481389930bf68577bb1202fad14c2ea152df8625"},{"kind":"rust-source","path":"crates/labcolors-core/src/constraints/exact.rs","sha256":"892576a8621185352583e63dc0a1aacac32e32a8063b6fe24ae16d4ff9dce7cb"},{"kind":"rust-source","path":"crates/labcolors-core/src/constraints/mod.rs","sha256":"f18bcafd7f911e84049cf9dae9673d6d6e0ed1fd50a9a7635a0ff597953b7727"},{"kind":"rust-source","path":"crates/labcolors-core/src/constraints/wcag22.rs","sha256":"856093c91159d8b3faab001f2d6524d33d7b16458a5a4e98ea65f8c62ab2694c"},{"kind":"rust-source","path":"crates/labcolors-core/src/hash.rs","sha256":"f97a0fd7d6ad3162f0f1dfb326fccfb7ed40da9a8fa67a5b8a239a1ae2ae49c3"},{"kind":"rust-source","path":"crates/labcolors-core/src/lcs_occurrence.rs","sha256":"78d37406e9bdc37f126b72987c9c92b452c13b3233c0aeb0a75ed25dadb83a68"},{"kind":"rust-source","path":"crates/labcolors-core/src/lib.rs","sha256":"55441efda3b9d8f8534cc015a15d4bf1390d74435738a1196c3bd44b6fb55120"},{"kind":"rust-source","path":"crates/labcolors-core/src/numerics.rs","sha256":"e73a12136494f2ef9aca4e943ab38302c1439f054cecab36a552d35252c164f9"},{"kind":"rust-source","path":"crates/labcolors-core/src/observation.rs","sha256":"b0cfec5c9fe798abd5492260aac3caf87685f7e27f4f9628b01f386ef3f6ac7d"},{"kind":"rust-source","path":"crates/labcolors-core/src/point_support.rs","sha256":"6f6a376ff036d3d65960c004e6566e1bca580f19f5bd3cd333a80b0da5b5c242"},{"kind":"rust-source","path":"crates/labcolors-core/src/session.rs","sha256":"38c65b46a3e392b399092e8c4834d7f31acb2b91eff626d6faa1b1aaa90b52f3"},{"kind":"rust-source","path":"crates/labcolors-core/src/srgb8.rs","sha256":"6c95324eb05476f35f75375a9af0b2b4a41b8b2978c46e67d2ce1aea5adde342"},{"kind":"rust-source","path":"crates/labcolors-core/src/wcag22.rs","sha256":"7ba7864eb7e73789bad6c63c64a4dc2dcc08c2da6921375fb9564fca230c2780"},{"kind":"rust-source","path":"crates/labcolors-core/src/wcag22/kernel.rs","sha256":"c97980c1ca2c7ea9cabff9c8d2fb7282773cca180ae15948391c29c9d6196040"},{"kind":"rust-source","path":"crates/labcolors-core/src/wcag22/q55_data.rs","sha256":"af4d23d6b70c45ce6efa839e7dda4bb0a61f6aae43cb805af6fa9b29e6c3bae2"},{"kind":"rust-source","path":"crates/labcolors-core/src/wcag22_evidence.rs","sha256":"3c5a75b07254c6071a64700af208a64987d0f0ea9698eadc54a9e74585ce1f72"}],"source_negative_controls":43,"universal_algebraic_certificate":{"basis_point_scale_instantiation":10000,"domain":"integers; Q55 scale Q>0; anchor L>=D>=0; lighter monotonicity L2>=L1>D>=0; darker monotonicity L>D2>=D1>=0; current/baseline denominators b,q>0; basis-point scale B>0 instantiated as 10000; p>0; a>=0; 0<=drop_bps<=B","identities":["three explicit anchor-surplus formulas after denominator clearing","reference distance is monotone increasing in lighter L","reference distance is monotone decreasing in darker D","positive-baseline retained threshold is p*(B-drop)/(q*B)","a/b >= p*(B-drop)/(q*B) iff a*q*B >= p*(B-drop)*b"],"method":"exact-sparse-integer-polynomial-identities-plus-positive-denominator-order-lemma-v1","nonpositive_baseline_case":"max(baseline,0)=0; retained threshold is exactly zero","symbolic_mutation_controls":{"anchor_coefficients_and_denominator":6,"retained_cross_product":5},"wolfram_language_cross_check":{"query":"FullSimplify[{20 g/d - 0 == 20 g/d, 20 g/d - 2 == (20 g - 2 d)/d, 20 g/d - 7/2 == (40 g - 7 d)/(2 d), Equivalent[a/b >= p (s-x)/(q s), a q s >= p (s-x) b], Max[p/q, 0] (s-x)/s == Piecewise[{{0, p <= 0}}, p (s-x)/(q s)]}, Assumptions -> Element[{a,b,p,q,s,x,g,d}, Integers] && a >= 0 && b > 0 && q > 0 && s > 0 && 0 <= x <= s && d > 0 && g >= 0]","query_sha256":"8cdbb9964583030c8b92498961896cb2a98613f1cb31eb7c54acdf8e16beff10","result":"{True, True, True, True, True}","result_sha256":"13a8f2ee8d0fde335a638e46d7cc8a8427b9a1437c77d22cfcf925bb87fa6303"}},"verifier_sha256":"ac10b71590385532f4989112fe47887b6275d780db79dfa75a435e7a2148fccb"} +{"artifact_id":"wcag22-srgb8-luminance-q55-v1","basis_point_proof":{"checks":30,"drop_all_semantics":"zero required surplus; current must still meet the anchor","drop_domain_inclusive":[0,10000],"nonpositive_baseline_semantics":"zero required surplus; current must meet the anchor"},"bound_id":"point-support-reference-surplus-q55-bps-v1","certified_claim":"for every successfully evaluated enabled stability cell, decision is Retained iff current_lower_surplus >= (10000-drop_bps)/10000 * max(baseline_lower_surplus,0); the declared anchor remains a separate hard floor","comparator_proof":{"algorithm":"euclidean-continued-fraction-ordering-v1","dense_denominator_inclusive":[1,31],"dense_numerator_inclusive":[0,31],"dense_small_cases":984064,"invariant":"equal integer parts; reciprocal proper fractions reverse order","largest_fibonacci_index":186,"oracle":"unbounded-integer-cross-product","random_cases":250000,"random_corpus_sha256":"97c4af7b452b31a4ab92645f70c17acb38bf57ca55484e32ad9d7d79d97a333d","random_seed":210583930,"termination":"each nonterminal denominator becomes a strictly smaller remainder","u128_adversarial_cases":190},"declared_operation_law":"q55-lower-reference-distance-explicit-anchor-bps-retention-v1","excluded_claim":"does not certify retention against the unknown exact baseline surplus, renderer equivalence outside encoded-sRGB8 source-over, or a successful result when evaluation fails","integer_replay_envelope":{"assumption":"every Q55 luminance upper <= scale + 3","i128_max":170141183460469231731687303715884105727,"offset_cleared_denominator_max":756604737398243388,"positive_baseline_numerator_max":1188950301625811064,"rational_denominator_max":1513209474796486776,"required_denominator_max":15132094747964867760000,"required_numerator_max":11889503016258110640000,"signed_anchor_abs_coarse_max":5296233161787703716,"u128_max":340282366920938463463374607431768211455,"u64_max":18446744073709551615},"profile_id":"srgb8-q55-retained-reference-surplus-bps-v1","proof_id":"point-support-reference-surplus-integer-v1","proof_payload_sha256":"3cd2e3ee7c6e2a0eac228c6dc0d68c7c9b53161830df15737b254ee3169fbbfb","q55_dependency":{"artifact_id":"wcag22-srgb8-luminance-q55-v1","artifact_sha256":"7ff239d9052b346f3c50da01ca65ca2330892ed1a3ff30e190797fcef6f03604","maximum_luminance_upper":36028797018963971,"outward_interval_width_bound":3,"proof_id":"wcag22-srgb8-full-domain-q55-v1","proof_payload_sha256":"3c639a7c875046c46b56b51ecdd67d5ecaf14a1134490c88a222e7037b63c0f2","proof_sha256":"ac59cf89503170c789223b91d775213a19d4e571ef930f2ea609fcd51b14defd","q55_scale":36028797018963968},"reference_and_anchor_proof":{"anchor_identity_checks":75,"orientation_law":"distance-magnitude-symmetric-orientation-reported-separately","overlap_lower_distance":"0/1","separated_endpoint_checks":504},"schema_version":2,"site_id":"point-support-retained-reference-surplus-v1","source_binding_exclusions":["whole-crate compilation or compiler/toolchain attestation","binary, package, FFI, renderer, or browser transport attestation","unrelated Lab Colors modules outside the declared point-support semantic cone"],"source_binding_law":"point-support-rust-whole-file-semantic-cone-v2","source_binding_schema_version":2,"source_binding_scope":"exact bytes of the private point-support Rust semantic cone and its two WCAG include_str inputs; comments and cfg(test) text are intentionally significant","source_closure_sha256":"8c6688559f108204423e9efe364dc8a108d203be07260077912b37a0b8811803","source_files":[{"kind":"compile-time-input","path":"crates/labcolors-core/contracts/wcag22-srgb8-q55-proof-v1.json","sha256":"ac59cf89503170c789223b91d775213a19d4e571ef930f2ea609fcd51b14defd"},{"kind":"compile-time-input","path":"crates/labcolors-core/contracts/wcag22-srgb8-v1.json","sha256":"b4bb7e5f17a99f2c911fdbe3da23a48b049277b796291094950f14680cc3cc7b"},{"kind":"rust-source","path":"crates/labcolors-core/src/appearance.rs","sha256":"66266904fc84afb03a3a774a8aaa0da23fe1e05a8b04abb14d5f18820e17a85d"},{"kind":"rust-source","path":"crates/labcolors-core/src/composition.rs","sha256":"195a67327a3bd86d7816b634481389930bf68577bb1202fad14c2ea152df8625"},{"kind":"rust-source","path":"crates/labcolors-core/src/constraints/exact.rs","sha256":"892576a8621185352583e63dc0a1aacac32e32a8063b6fe24ae16d4ff9dce7cb"},{"kind":"rust-source","path":"crates/labcolors-core/src/constraints/mod.rs","sha256":"f18bcafd7f911e84049cf9dae9673d6d6e0ed1fd50a9a7635a0ff597953b7727"},{"kind":"rust-source","path":"crates/labcolors-core/src/constraints/wcag22.rs","sha256":"856093c91159d8b3faab001f2d6524d33d7b16458a5a4e98ea65f8c62ab2694c"},{"kind":"rust-source","path":"crates/labcolors-core/src/hash.rs","sha256":"f97a0fd7d6ad3162f0f1dfb326fccfb7ed40da9a8fa67a5b8a239a1ae2ae49c3"},{"kind":"rust-source","path":"crates/labcolors-core/src/lcs_occurrence.rs","sha256":"78d37406e9bdc37f126b72987c9c92b452c13b3233c0aeb0a75ed25dadb83a68"},{"kind":"rust-source","path":"crates/labcolors-core/src/lib.rs","sha256":"919b46d9a838b1f5f333af938c2db7bfa20ba444e22c841def0349d93d213c38"},{"kind":"rust-source","path":"crates/labcolors-core/src/numerics.rs","sha256":"e73a12136494f2ef9aca4e943ab38302c1439f054cecab36a552d35252c164f9"},{"kind":"rust-source","path":"crates/labcolors-core/src/observation.rs","sha256":"b0cfec5c9fe798abd5492260aac3caf87685f7e27f4f9628b01f386ef3f6ac7d"},{"kind":"rust-source","path":"crates/labcolors-core/src/point_support.rs","sha256":"6f6a376ff036d3d65960c004e6566e1bca580f19f5bd3cd333a80b0da5b5c242"},{"kind":"rust-source","path":"crates/labcolors-core/src/session.rs","sha256":"38c65b46a3e392b399092e8c4834d7f31acb2b91eff626d6faa1b1aaa90b52f3"},{"kind":"rust-source","path":"crates/labcolors-core/src/srgb8.rs","sha256":"6c95324eb05476f35f75375a9af0b2b4a41b8b2978c46e67d2ce1aea5adde342"},{"kind":"rust-source","path":"crates/labcolors-core/src/wcag22.rs","sha256":"7ba7864eb7e73789bad6c63c64a4dc2dcc08c2da6921375fb9564fca230c2780"},{"kind":"rust-source","path":"crates/labcolors-core/src/wcag22/kernel.rs","sha256":"c97980c1ca2c7ea9cabff9c8d2fb7282773cca180ae15948391c29c9d6196040"},{"kind":"rust-source","path":"crates/labcolors-core/src/wcag22/q55_data.rs","sha256":"af4d23d6b70c45ce6efa839e7dda4bb0a61f6aae43cb805af6fa9b29e6c3bae2"},{"kind":"rust-source","path":"crates/labcolors-core/src/wcag22_evidence.rs","sha256":"3c5a75b07254c6071a64700af208a64987d0f0ea9698eadc54a9e74585ce1f72"}],"source_negative_controls":43,"universal_algebraic_certificate":{"basis_point_scale_instantiation":10000,"domain":"integers; Q55 scale Q>0; anchor L>=D>=0; lighter monotonicity L2>=L1>D>=0; darker monotonicity L>D2>=D1>=0; current/baseline denominators b,q>0; basis-point scale B>0 instantiated as 10000; p>0; a>=0; 0<=drop_bps<=B","identities":["three explicit anchor-surplus formulas after denominator clearing","reference distance is monotone increasing in lighter L","reference distance is monotone decreasing in darker D","positive-baseline retained threshold is p*(B-drop)/(q*B)","a/b >= p*(B-drop)/(q*B) iff a*q*B >= p*(B-drop)*b"],"method":"exact-sparse-integer-polynomial-identities-plus-positive-denominator-order-lemma-v1","nonpositive_baseline_case":"max(baseline,0)=0; retained threshold is exactly zero","symbolic_mutation_controls":{"anchor_coefficients_and_denominator":6,"retained_cross_product":5},"wolfram_language_cross_check":{"query":"FullSimplify[{20 g/d - 0 == 20 g/d, 20 g/d - 2 == (20 g - 2 d)/d, 20 g/d - 7/2 == (40 g - 7 d)/(2 d), Equivalent[a/b >= p (s-x)/(q s), a q s >= p (s-x) b], Max[p/q, 0] (s-x)/s == Piecewise[{{0, p <= 0}}, p (s-x)/(q s)]}, Assumptions -> Element[{a,b,p,q,s,x,g,d}, Integers] && a >= 0 && b > 0 && q > 0 && s > 0 && 0 <= x <= s && d > 0 && g >= 0]","query_sha256":"8cdbb9964583030c8b92498961896cb2a98613f1cb31eb7c54acdf8e16beff10","result":"{True, True, True, True, True}","result_sha256":"13a8f2ee8d0fde335a638e46d7cc8a8427b9a1437c77d22cfcf925bb87fa6303"}},"verifier_sha256":"64a6c702a447c77fb82af5f886fbcd266124c7e2074b92274f84bb68f71faef9"} diff --git a/crates/labcolors-core/src/contextual_region.rs b/crates/labcolors-core/src/contextual_region.rs new file mode 100644 index 00000000..c59a314e --- /dev/null +++ b/crates/labcolors-core/src/contextual_region.rs @@ -0,0 +1,804 @@ +//! Точное определение контекстной декартовой области family. +//! +//! Авторские binary64 bit-patterns здесь являются конечными dyadic-числами, а +//! не результатами вычислений платформы. Модуль только парсит геометрию и +//! связывает полный versioned pipeline в [`FamilyDefinitionDigestV2`]. Точный +//! образ конечного output domain строится отдельным offline proof-срезом. + +use core::cmp::Ordering; + +use crate::family::FamilyDefinitionDigestV2; +use crate::lcs_occurrence::{ + AdmittedSrgb8TristimulusBindingV1, AppearanceContextId, AppearanceContextSchemaReleaseId, + Cam16UcsViewReleaseId, Cam16ViewReleaseId, ColorimetricFrameId, ColorimetricFrameReleaseId, + ColorimetricTransformReleaseId, ModeledLcsOccurrenceReleaseId, ObserverProfileId, + OutputProfileId, ReferenceWhiteId, SurroundProfileId, TristimulusScale, +}; +use crate::sha256::Hasher; + +const SIGN_MASK: u64 = 1_u64 << 63; +const EXPONENT_MASK: u64 = 0x7ff0_0000_0000_0000; +const FRACTION_MASK: u64 = 0x000f_ffff_ffff_ffff; +const NORMAL_HIDDEN_BIT: u64 = 1_u64 << 52; +const POSITIVE_ZERO_BITS: u64 = 0; + +// Канонический codec пишет длины как u64. На поддерживаемых Rust targets это +// доказывает точность usize -> u64 вместо недостижимой runtime-ошибки. +const _: () = assert!(usize::BITS <= u64::BITS); + +/// Ошибка допуска одного raw IEEE 754 binary64 bit-pattern. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ExactDyadic64ErrorV1 { + NonFinite, + NegativeZero, +} + +/// Конечное exact-dyadic число, канонически заданное raw binary64 bits. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) struct ExactDyadic64V1(u64); + +impl ExactDyadic64V1 { + pub(crate) const fn try_from_bits(bits: u64) -> Result { + if bits & EXPONENT_MASK == EXPONENT_MASK { + return Err(ExactDyadic64ErrorV1::NonFinite); + } + if bits == SIGN_MASK { + return Err(ExactDyadic64ErrorV1::NegativeZero); + } + Ok(Self(bits)) + } + + pub(crate) const fn bits(self) -> u64 { + self.0 + } + + const fn is_negative(self) -> bool { + self.0 & SIGN_MASK != 0 + } + + const fn magnitude_parts(self) -> (u64, i32) { + let exponent = ((self.0 & EXPONENT_MASK) >> 52) as i32; + let fraction = self.0 & FRACTION_MASK; + // Fraction занимает 52 младших бита, normal significand дополнен + // скрытым `2^52`, а binary64 bias равен 1023. Поэтому normal value + // имеет масштаб `exponent - 1023 - 52 = exponent - 1075`, тогда как + // subnormal value по определению имеет фиксированный масштаб `-1074`. + if exponent == 0 { + (fraction, -1074) + } else { + // Поля непересекаются по построению, поэтому сложение точно и + // выражает значение significand, а не политику установки битов. + (NORMAL_HIDDEN_BIT + fraction, exponent - 1075) + } + } +} + +impl Ord for ExactDyadic64V1 { + fn cmp(&self, other: &Self) -> Ordering { + match (self.is_negative(), other.is_negative()) { + (true, false) => Ordering::Less, + (false, true) => Ordering::Greater, + (false, false) => self.0.cmp(&other.0), + (true, true) => other.0.cmp(&self.0), + } + } +} + +impl PartialOrd for ExactDyadic64V1 { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +#[derive(Debug, Clone, Copy)] +struct ExactDyadicProductV1 { + negative: bool, + significand: u128, + exponent: i32, +} + +impl ExactDyadicProductV1 { + fn of(left: ExactDyadic64V1, right: ExactDyadic64V1) -> Self { + let (left_significand, left_exponent) = left.magnitude_parts(); + let (right_significand, right_exponent) = right.magnitude_parts(); + let significand = u128::from(left_significand) * u128::from(right_significand); + Self { + negative: significand != 0 && left.is_negative() != right.is_negative(), + significand, + exponent: left_exponent + right_exponent, + } + } + + fn magnitude_cmp(self, other: Self) -> Ordering { + match (self.significand, other.significand) { + (0, 0) => return Ordering::Equal, + (0, _) => return Ordering::Less, + (_, 0) => return Ordering::Greater, + _ => {} + } + + // Индекс бита u128 лежит в 0..=127, поэтому оба преобразования точны. + let self_top = self.exponent + (u128::BITS - 1 - self.significand.leading_zeros()) as i32; + let other_top = + other.exponent + (u128::BITS - 1 - other.significand.leading_zeros()) as i32; + match self_top.cmp(&other_top) { + Ordering::Equal => { + let common_exponent = self.exponent.min(other.exponent); + let self_shift = (self.exponent - common_exponent) as u32; + let other_shift = (other.exponent - common_exponent) as u32; + // Произведение двух binary64 significands занимает не более + // 106 бит. При одинаковом top-bit выравнивание к меньшему + // exponent сохраняет ту же итоговую bit length, поэтому оба + // сдвига помещаются в u128 без потери старших битов. + (self.significand << self_shift).cmp(&(other.significand << other_shift)) + } + order => order, + } + } +} + +impl PartialEq for ExactDyadicProductV1 { + fn eq(&self, other: &Self) -> bool { + self.cmp(other) == Ordering::Equal + } +} + +impl Eq for ExactDyadicProductV1 {} + +impl Ord for ExactDyadicProductV1 { + fn cmp(&self, other: &Self) -> Ordering { + match (self.negative, other.negative) { + (true, false) => Ordering::Less, + (false, true) => Ordering::Greater, + (false, false) => self.magnitude_cmp(*other), + (true, true) => other.magnitude_cmp(*self), + } + } +} + +impl PartialOrd for ExactDyadicProductV1 { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +#[cfg(test)] +mod exact_dyadic_product_laws { + use super::{ExactDyadic64V1, ExactDyadicProductV1}; + + #[test] + fn equality_and_ordering_use_the_same_exact_value() { + let exact = |bits| ExactDyadic64V1::try_from_bits(bits).unwrap(); + // `2.25 × 1` и `1.5²` — одно exact-dyadic значение, но raw products + // отличаются удвоенной significand и уменьшенным на единицу exponent. + let factored = + ExactDyadicProductV1::of(exact(0x4002_0000_0000_0000), exact(0x3ff0_0000_0000_0000)); + let squared = + ExactDyadicProductV1::of(exact(0x3ff8_0000_0000_0000), exact(0x3ff8_0000_0000_0000)); + + assert_eq!(factored.cmp(&squared), core::cmp::Ordering::Equal); + assert_eq!(factored, squared); + + let one = + ExactDyadicProductV1::of(exact(0x3ff0_0000_0000_0000), exact(0x3ff0_0000_0000_0000)); + assert_ne!(factored.cmp(&one), core::cmp::Ordering::Equal); + assert_ne!(factored, one); + } +} + +/// Сырое binary64-представление одной симметричной `2 x 2` shape matrix. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Shape2BitsV1 { + g00: u64, + g01: u64, + g11: u64, +} + +impl Shape2BitsV1 { + pub(crate) const fn new(g00: u64, g01: u64, g11: u64) -> Self { + Self { g00, g01, g11 } + } +} + +/// Точная ошибка positive-definite допуска shape matrix. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Shape2ErrorV1 { + NonPositiveLeadingMinor, + NonPositiveDeterminant, +} + +/// Симметричная exact-dyadic positive-definite `2 x 2` shape matrix. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Shape2V1 { + g00: ExactDyadic64V1, + g01: ExactDyadic64V1, + g11: ExactDyadic64V1, +} + +impl Shape2V1 { + fn try_new( + g00: ExactDyadic64V1, + g01: ExactDyadic64V1, + g11: ExactDyadic64V1, + ) -> Result { + let zero = ExactDyadic64V1(POSITIVE_ZERO_BITS); + if g00 <= zero { + return Err(Shape2ErrorV1::NonPositiveLeadingMinor); + } + + // Sylvester для symmetric 2 x 2: g00 > 0 и + // g00*g11 - g01*g01 > 0. Сравнение products сохраняет точную dyadic- + // семантику даже когда platform binary64 получил бы 0 или infinity. + let diagonal = ExactDyadicProductV1::of(g00, g11); + let off_diagonal = ExactDyadicProductV1::of(g01, g01); + if diagonal <= off_diagonal { + return Err(Shape2ErrorV1::NonPositiveDeterminant); + } + Ok(Self { g00, g01, g11 }) + } + + pub(crate) const fn g00(self) -> ExactDyadic64V1 { + self.g00 + } + + pub(crate) const fn g01(self) -> ExactDyadic64V1 { + self.g01 + } + + pub(crate) const fn g11(self) -> ExactDyadic64V1 { + self.g11 + } +} + +/// Сырое binary64-представление одного piecewise-linear tone knot. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct TubeKnotBitsV1 { + tone: u64, + center_a: u64, + center_b: u64, + radius_squared: u64, +} + +impl TubeKnotBitsV1 { + pub(crate) const fn new(tone: u64, center_a: u64, center_b: u64, radius_squared: u64) -> Self { + Self { + tone, + center_a, + center_b, + radius_squared, + } + } +} + +/// Поле definition, в котором raw binary64 не был допущен. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TubeCoordinateV1 { + ShapeG00, + ShapeG01, + ShapeG11, + Tone, + CenterA, + CenterB, + RadiusSquared, +} + +/// Один допущенный knot непрерывной декартовой области. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct TubeKnotV1 { + tone: ExactDyadic64V1, + center: [ExactDyadic64V1; 2], + radius_squared: ExactDyadic64V1, +} + +impl TubeKnotV1 { + pub(crate) const fn tone(self) -> ExactDyadic64V1 { + self.tone + } + + pub(crate) const fn center(self) -> [ExactDyadic64V1; 2] { + self.center + } + + pub(crate) const fn radius_squared(self) -> ExactDyadic64V1 { + self.radius_squared + } +} + +/// Ошибка parse целого `PiecewiseLinearCartesianTubeV1`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PiecewiseLinearCartesianTubeErrorV1 { + Coordinate { + index: Option, + coordinate: TubeCoordinateV1, + reason: ExactDyadic64ErrorV1, + }, + Shape(Shape2ErrorV1), + EmptyToneDomain, + ToneNotStrictlyIncreasing { + index: usize, + }, + NegativeRadiusSquared { + index: usize, + }, + ResourceExhausted, +} + +/// Замкнутая область: между строго упорядоченными tone knots линейно +/// интерполируются центр и `radius_squared`; точка `z` принадлежит области, +/// когда `(z - center(t))ᵀ G (z - center(t)) <= radius_squared(t)`. +/// Здесь `t = J′`, `z = [a′, b′]` в identity-bound rectangular CAM16-UCS view; +/// tone-домен замкнут от первого до последнего knot, а один knot задаёт singleton. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PiecewiseLinearCartesianTubeV1 { + shape: Shape2V1, + knots: Vec, +} + +impl PiecewiseLinearCartesianTubeV1 { + pub(crate) fn try_from_bits( + shape: Shape2BitsV1, + knots: &[TubeKnotBitsV1], + ) -> Result { + fn coordinate( + bits: u64, + index: Option, + coordinate: TubeCoordinateV1, + ) -> Result { + ExactDyadic64V1::try_from_bits(bits).map_err(|reason| { + PiecewiseLinearCartesianTubeErrorV1::Coordinate { + index, + coordinate, + reason, + } + }) + } + + let shape = Shape2V1::try_new( + coordinate(shape.g00, None, TubeCoordinateV1::ShapeG00)?, + coordinate(shape.g01, None, TubeCoordinateV1::ShapeG01)?, + coordinate(shape.g11, None, TubeCoordinateV1::ShapeG11)?, + ) + .map_err(PiecewiseLinearCartesianTubeErrorV1::Shape)?; + if knots.is_empty() { + return Err(PiecewiseLinearCartesianTubeErrorV1::EmptyToneDomain); + } + + let mut parsed = Vec::new(); + parsed + .try_reserve_exact(knots.len()) + .map_err(|_| PiecewiseLinearCartesianTubeErrorV1::ResourceExhausted)?; + let zero = ExactDyadic64V1(POSITIVE_ZERO_BITS); + for (index, knot) in knots.iter().copied().enumerate() { + let tone = coordinate(knot.tone, Some(index), TubeCoordinateV1::Tone)?; + if parsed + .last() + .is_some_and(|previous: &TubeKnotV1| previous.tone >= tone) + { + return Err( + PiecewiseLinearCartesianTubeErrorV1::ToneNotStrictlyIncreasing { index }, + ); + } + let center_a = coordinate(knot.center_a, Some(index), TubeCoordinateV1::CenterA)?; + let center_b = coordinate(knot.center_b, Some(index), TubeCoordinateV1::CenterB)?; + let radius_squared = coordinate( + knot.radius_squared, + Some(index), + TubeCoordinateV1::RadiusSquared, + )?; + if radius_squared < zero { + return Err(PiecewiseLinearCartesianTubeErrorV1::NegativeRadiusSquared { index }); + } + parsed.push(TubeKnotV1 { + tone, + center: [center_a, center_b], + radius_squared, + }); + } + + Ok(Self { + shape, + knots: parsed, + }) + } + + pub(crate) const fn shape(&self) -> Shape2V1 { + self.shape + } + + pub(crate) fn knots(&self) -> &[TubeKnotV1] { + &self.knots + } +} + +/// Ошибка связывания закрытого contextual pipeline. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ContextualRegionPipelineErrorV1 { + OutputProfileMismatch { + domain: OutputProfileId, + lowering: OutputProfileId, + }, + FrameMismatch { + lowering: ColorimetricFrameId, + context: ColorimetricFrameId, + }, +} + +/// Математическая семантика offline-вычисления контекстной family-области. +/// +/// `NominalExactRealLiftV1` трактует зарегистрированные binary64 bit-patterns +/// decode-table, коэффициентов, context и region как точные dyadic constants, +/// а арифметические операции и трансцендентные функции — как операции над +/// действительными числами без промежуточного binary64 rounding и platform +/// `libm`. Это nominal mathematical lift связанных formula releases, а не claim +/// об идеальной source-algebraic семантике IEC/CIE; такая семантика потребует +/// нового release ID. Сам release является content address канонического +/// `contracts/contextual-region-formula-v1.lcir`, а не именем этой Rust-ветви +/// или хешем реализации evaluator-а. +/// +/// `exact_real_v1` задаёт обычные операции над ℝ; `root3` — единственный +/// неотрицательный кубический корень, `pow_pos(x, p) = exp(p·log(x))` при +/// `x > 0`, `pow_nn(0, p) = 0` при `p > 0` и иначе совпадает с `pow_pos`, а +/// `ratio0(0, 0) = 0` и иначе является делением при строго положительном знаменателе. +/// Эти domain-условия обязаны быть доказаны offline evaluator-ом: нарушение не +/// превращается в NaN, fallback или член family. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ContextualRegionFormulaReleaseIdV1 { + NominalExactRealLiftV1, + #[cfg(test)] + MutationSentinelV1, +} + +pub(crate) const CONTEXTUAL_REGION_FORMULA_RELEASE_V1: ContextualRegionFormulaReleaseIdV1 = + ContextualRegionFormulaReleaseIdV1::NominalExactRealLiftV1; + +const CONTEXTUAL_REGION_FORMULA_RELEASE_DIGEST_V1: [u8; 32] = [ + 0x2c, 0x62, 0x6d, 0x8e, 0xe6, 0x0e, 0xeb, 0x62, 0xae, 0x4d, 0xb5, 0x36, 0x60, 0xd6, 0x1b, 0xbc, + 0x25, 0xe0, 0xef, 0xd4, 0xe5, 0x57, 0xf0, 0xdc, 0x1e, 0x77, 0x56, 0x5c, 0x13, 0x0b, 0x6e, 0x52, +]; + +/// Полный versioned pipeline, относительно которого definition имеет смысл. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ContextualRegionPipelineV1 { + output_profile: OutputProfileId, + lowering: AdmittedSrgb8TristimulusBindingV1, + modeled_occurrence: ModeledLcsOccurrenceReleaseId, + context: AppearanceContextId, + cam16_view: Cam16ViewReleaseId, + rectangular_view: Cam16UcsViewReleaseId, + formula: ContextualRegionFormulaReleaseIdV1, +} + +impl ContextualRegionPipelineV1 { + pub(crate) fn try_new( + output_profile: OutputProfileId, + lowering: AdmittedSrgb8TristimulusBindingV1, + modeled_occurrence: ModeledLcsOccurrenceReleaseId, + context: AppearanceContextId, + cam16_view: Cam16ViewReleaseId, + rectangular_view: Cam16UcsViewReleaseId, + formula: ContextualRegionFormulaReleaseIdV1, + ) -> Result { + if output_profile != lowering.signal_output_profile() { + return Err(ContextualRegionPipelineErrorV1::OutputProfileMismatch { + domain: output_profile, + lowering: lowering.signal_output_profile(), + }); + } + if lowering.result_frame() != context.frame() { + return Err(ContextualRegionPipelineErrorV1::FrameMismatch { + lowering: lowering.result_frame(), + context: context.frame(), + }); + } + Ok(Self { + output_profile, + lowering, + modeled_occurrence, + context, + cam16_view, + rectangular_view, + formula, + }) + } +} + +trait CanonicalSinkV1 { + fn write(&mut self, bytes: &[u8]); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ContextualRegionIdentityEncodingReleaseIdV1 { + LengthPrefixedBigEndianV1, + #[cfg(test)] + MutationSentinelV1, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ContextualRegionFamilyProviderReleaseIdV1 { + V1, + #[cfg(test)] + MutationSentinelV1, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PiecewiseLinearCartesianTubeReleaseIdV1 { + V1, + #[cfg(test)] + MutationSentinelV1, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ContextualRegionDefinitionReleasesV1 { + identity_encoding: ContextualRegionIdentityEncodingReleaseIdV1, + provider: ContextualRegionFamilyProviderReleaseIdV1, + region: PiecewiseLinearCartesianTubeReleaseIdV1, +} + +impl ContextualRegionDefinitionReleasesV1 { + #[cfg(test)] + pub(crate) const fn new( + identity_encoding: ContextualRegionIdentityEncodingReleaseIdV1, + provider: ContextualRegionFamilyProviderReleaseIdV1, + region: PiecewiseLinearCartesianTubeReleaseIdV1, + ) -> Self { + Self { + identity_encoding, + provider, + region, + } + } +} + +const CONTEXTUAL_REGION_DEFINITION_RELEASES_V1: ContextualRegionDefinitionReleasesV1 = + ContextualRegionDefinitionReleasesV1 { + identity_encoding: ContextualRegionIdentityEncodingReleaseIdV1::LengthPrefixedBigEndianV1, + provider: ContextualRegionFamilyProviderReleaseIdV1::V1, + region: PiecewiseLinearCartesianTubeReleaseIdV1::V1, + }; + +impl CanonicalSinkV1 for Hasher { + fn write(&mut self, bytes: &[u8]) { + self.update(bytes); + } +} + +#[cfg(test)] +impl CanonicalSinkV1 for Vec { + fn write(&mut self, bytes: &[u8]) { + self.extend_from_slice(bytes); + } +} + +fn write_field(sink: &mut impl CanonicalSinkV1, bytes: &[u8]) { + sink.write(&(bytes.len() as u64).to_be_bytes()); + sink.write(bytes); +} + +fn output_profile_tag(value: OutputProfileId) -> u8 { + match value { + OutputProfileId::Iec61966Srgb8D65V1 => 1, + } +} + +fn lowering_tag(value: AdmittedSrgb8TristimulusBindingV1) -> u8 { + match value { + AdmittedSrgb8TristimulusBindingV1::Iec61966Srgb8ToCie1931TwoDegreeXyzD65RelativeY1V1 => 1, + } +} + +fn transform_tag(value: ColorimetricTransformReleaseId) -> u8 { + match value { + ColorimetricTransformReleaseId::Iec61966Srgb8ToCie1931TwoDegreeXyzD65RelativeY1V1 => 1, + } +} + +fn observer_tag(value: ObserverProfileId) -> u8 { + match value { + ObserverProfileId::Cie1931TwoDegreeV1 => 1, + } +} + +fn reference_white_tag(value: ReferenceWhiteId) -> u8 { + match value { + ReferenceWhiteId::Iec61966D65ChromaticityV1 => 1, + } +} + +fn scale_tag(value: TristimulusScale) -> u8 { + match value { + TristimulusScale::RelativeY1 => 1, + } +} + +fn frame_release_tag(value: ColorimetricFrameReleaseId) -> u8 { + match value { + ColorimetricFrameReleaseId::XyzV1 => 1, + #[cfg(test)] + ColorimetricFrameReleaseId::MutationSentinelV1 => 2, + } +} + +fn frame_bytes(value: ColorimetricFrameId) -> [u8; 4] { + [ + observer_tag(value.observer()), + reference_white_tag(value.reference_white()), + scale_tag(value.scale()), + frame_release_tag(value.release()), + ] +} + +fn modeled_occurrence_tag(value: ModeledLcsOccurrenceReleaseId) -> u8 { + match value { + ModeledLcsOccurrenceReleaseId::V1 => 1, + #[cfg(test)] + ModeledLcsOccurrenceReleaseId::MutationSentinelV1 => 2, + } +} + +fn context_schema_tag(value: AppearanceContextSchemaReleaseId) -> u8 { + match value { + AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1 => 1, + } +} + +fn surround_tag(value: SurroundProfileId) -> u8 { + match value { + SurroundProfileId::AverageV1 => 1, + SurroundProfileId::DimV1 => 2, + SurroundProfileId::DarkV1 => 3, + } +} + +fn cam16_view_tag(value: Cam16ViewReleaseId) -> u8 { + match value { + Cam16ViewReleaseId::LiEtAl2017Cie248ForwardV1 => 1, + } +} + +fn rectangular_view_tag(value: Cam16UcsViewReleaseId) -> u8 { + match value { + Cam16UcsViewReleaseId::LiEtAl2017Cam16UcsV1 => 1, + } +} + +fn formula_release_digest(value: ContextualRegionFormulaReleaseIdV1) -> [u8; 32] { + match value { + ContextualRegionFormulaReleaseIdV1::NominalExactRealLiftV1 => { + CONTEXTUAL_REGION_FORMULA_RELEASE_DIGEST_V1 + } + #[cfg(test)] + ContextualRegionFormulaReleaseIdV1::MutationSentinelV1 => [0xa5; 32], + } +} + +fn identity_encoding_tag(value: ContextualRegionIdentityEncodingReleaseIdV1) -> u8 { + match value { + ContextualRegionIdentityEncodingReleaseIdV1::LengthPrefixedBigEndianV1 => 1, + #[cfg(test)] + ContextualRegionIdentityEncodingReleaseIdV1::MutationSentinelV1 => 2, + } +} + +fn provider_release_tag(value: ContextualRegionFamilyProviderReleaseIdV1) -> u8 { + match value { + ContextualRegionFamilyProviderReleaseIdV1::V1 => 1, + #[cfg(test)] + ContextualRegionFamilyProviderReleaseIdV1::MutationSentinelV1 => 2, + } +} + +fn region_release_tag(value: PiecewiseLinearCartesianTubeReleaseIdV1) -> u8 { + match value { + PiecewiseLinearCartesianTubeReleaseIdV1::V1 => 1, + #[cfg(test)] + PiecewiseLinearCartesianTubeReleaseIdV1::MutationSentinelV1 => 2, + } +} + +fn write_identity( + sink: &mut impl CanonicalSinkV1, + releases: ContextualRegionDefinitionReleasesV1, + pipeline: ContextualRegionPipelineV1, + region: &PiecewiseLinearCartesianTubeV1, +) { + const DOMAIN: &[u8] = b"labcolors.contextual-region-family-provider.v1\0"; + + write_field(sink, DOMAIN); + write_field(sink, &[identity_encoding_tag(releases.identity_encoding)]); + write_field(sink, &[provider_release_tag(releases.provider)]); + write_field(sink, &[output_profile_tag(pipeline.output_profile)]); + write_field(sink, &[lowering_tag(pipeline.lowering)]); + write_field( + sink, + &[output_profile_tag( + pipeline.lowering.signal_output_profile(), + )], + ); + write_field( + sink, + &[transform_tag(pipeline.lowering.transform_release())], + ); + write_field(sink, &frame_bytes(pipeline.lowering.result_frame())); + write_field(sink, &[modeled_occurrence_tag(pipeline.modeled_occurrence)]); + write_field( + sink, + &[context_schema_tag(pipeline.context.schema_release())], + ); + write_field(sink, &frame_bytes(pipeline.context.frame())); + write_field( + sink, + &pipeline + .context + .adapting_luminance_cd_m2() + .to_bits() + .to_be_bytes(), + ); + write_field( + sink, + &pipeline + .context + .background_luminance_ratio() + .to_bits() + .to_be_bytes(), + ); + write_field(sink, &[surround_tag(pipeline.context.surround_profile())]); + write_field(sink, &[cam16_view_tag(pipeline.cam16_view)]); + write_field(sink, &[rectangular_view_tag(pipeline.rectangular_view)]); + write_field(sink, &formula_release_digest(pipeline.formula)); + write_field(sink, &[region_release_tag(releases.region)]); + write_field(sink, ®ion.shape.g00.bits().to_be_bytes()); + write_field(sink, ®ion.shape.g01.bits().to_be_bytes()); + write_field(sink, ®ion.shape.g11.bits().to_be_bytes()); + write_field(sink, &(region.knots.len() as u64).to_be_bytes()); + for knot in ®ion.knots { + write_field(sink, &knot.tone.bits().to_be_bytes()); + write_field(sink, &knot.center[0].bits().to_be_bytes()); + write_field(sink, &knot.center[1].bits().to_be_bytes()); + write_field(sink, &knot.radius_squared.bits().to_be_bytes()); + } +} + +/// Единственная V5b2b provider-поверхность: definition address без mint image. +pub(crate) struct ContextualRegionFamilyProviderV1; + +impl ContextualRegionFamilyProviderV1 { + pub(crate) fn definition_digest( + pipeline: ContextualRegionPipelineV1, + region: &PiecewiseLinearCartesianTubeV1, + ) -> FamilyDefinitionDigestV2 { + let mut hasher = Hasher::new(); + write_identity( + &mut hasher, + CONTEXTUAL_REGION_DEFINITION_RELEASES_V1, + pipeline, + region, + ); + FamilyDefinitionDigestV2::from_digest(*hasher.finalize().as_bytes()) + } + + #[cfg(test)] + pub(crate) fn definition_digest_with_releases_for_test( + releases: ContextualRegionDefinitionReleasesV1, + pipeline: ContextualRegionPipelineV1, + region: &PiecewiseLinearCartesianTubeV1, + ) -> FamilyDefinitionDigestV2 { + let mut hasher = Hasher::new(); + write_identity(&mut hasher, releases, pipeline, region); + FamilyDefinitionDigestV2::from_digest(*hasher.finalize().as_bytes()) + } + + #[cfg(test)] + pub(crate) fn canonical_identity_bytes_for_test( + pipeline: ContextualRegionPipelineV1, + region: &PiecewiseLinearCartesianTubeV1, + ) -> Vec { + let mut bytes = Vec::new(); + write_identity( + &mut bytes, + CONTEXTUAL_REGION_DEFINITION_RELEASES_V1, + pipeline, + region, + ); + bytes + } +} diff --git a/crates/labcolors-core/src/contextual_region_formula_tests.rs b/crates/labcolors-core/src/contextual_region_formula_tests.rs new file mode 100644 index 00000000..2cc23c3f --- /dev/null +++ b/crates/labcolors-core/src/contextual_region_formula_tests.rs @@ -0,0 +1,1330 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Write; +use std::sync::OnceLock; + +use proptest::prelude::*; + +use crate::Srgb8; +use crate::lcs_occurrence::{ + AdaptingLuminanceCdM2, AppearanceContextId, AppearanceContextSchemaReleaseId, AppearanceState, + BackgroundLuminanceRatio, ColorSignal, IEC_SRGB_D65_XYZ_FRAME_V1, LcsOccurrence, + SurroundProfileId, derive_modeled_tristimulus_v1, +}; +use crate::spaces::srgb::decode_8bit; +use crate::spaces::{cam16, cat16, srgb, vc}; + +const FORMULA_BYTES: &[u8] = include_bytes!("../contracts/contextual-region-formula-v1.lcir"); + +const TYPE_DECLARATIONS: [&str; 4] = [ + "type u8 unsigned_integer_0_255", + "type real mathematical_real", + "type bool exact_boolean", + "type surround_profile closed_enum", +]; + +const OPERATOR_DECLARATIONS: [&str; 20] = [ + "operator lookup 2 real table_u8_exact_dyadic_at_ordinal", + "operator eq 2 bool exact_same_type_equality", + "operator select 3 same bool_true_second_else_third", + "operator add 2 real exact_x_plus_y", + "operator sub 2 real exact_x_minus_y", + "operator mul 2 real exact_x_times_y", + "operator div 2 real domain_y_ne_zero_x_div_y_else_domain_unproven", + "operator min 2 real exact_lesser_real", + "operator max 2 real exact_greater_real", + "operator root3 1 real domain_x_ge_zero_unique_y_ge_zero_y_cubed_eq_x_else_domain_unproven", + "operator sqrt 1 real domain_x_ge_zero_unique_y_ge_zero_y_squared_eq_x_else_domain_unproven", + "operator exp 1 real analytic_natural_exponential", + "operator log 1 real domain_x_gt_zero_analytic_natural_logarithm_else_domain_unproven", + "operator sin 1 real analytic_sine_radians", + "operator cos 1 real analytic_cosine_radians", + "operator abs 1 real exact_absolute_value", + "operator sign 1 real negative_minus_one_zero_zero_positive_one", + "operator pow_pos 2 real domain_x_gt_zero_exp_y_mul_log_x_else_domain_unproven", + "operator pow_nn 2 real if_x_eq_zero_and_y_gt_zero_zero_else_pow_pos", + "operator ratio0 2 real if_x_eq_zero_and_y_eq_zero_zero_else_domain_y_gt_zero_x_div_y", +]; + +const DRIVER_RULES: [&str; 6] = [ + "rule tone_domain closed_first_last", + "rule out_of_tone_domain outside", + "rule one_knot_tone exact_equality_required", + "rule one_knot_predicate singleton_f_le_zero", + "rule multi_knot_predicate piecewise_linear_segment_f_le_zero", + "rule boundary inclusive", +]; + +const POINT_CHECKPOINTS: [(&str, &str); 39] = [ + ("decoded_r", "linear_r"), + ("decoded_g", "linear_g"), + ("decoded_b", "linear_b"), + ("xyz_x", "xyz_x"), + ("xyz_y", "xyz_y"), + ("xyz_z", "xyz_z"), + ("surround_f", "surround_f"), + ("surround_c", "surround_c"), + ("surround_nc", "surround_nc"), + ("n", "n"), + ("fl", "fl"), + ("nbb", "nbb"), + ("ncb", "nbb"), + ("z", "z"), + ("d", "d"), + ("rgb_d_l", "rgb_d_l"), + ("rgb_d_m", "rgb_d_m"), + ("rgb_d_s", "rgb_d_s"), + ("aw", "aw"), + ("t_inner", "t_inner"), + ("fl_pow_025", "fl_pow_025"), + ("lms_l", "lms_l"), + ("lms_m", "lms_m"), + ("lms_s", "lms_s"), + ("response_l", "response_l"), + ("response_m", "response_m"), + ("response_s", "response_s"), + ("opponent_a", "opponent_a"), + ("opponent_b", "opponent_b"), + ("opponent_norm", "opponent_norm"), + ("achrom", "achrom"), + ("j", "j"), + ("e_hue_times_norm", "e_hue_times_norm"), + ("t", "t"), + ("m", "m"), + ("jp", "jp"), + ("mp", "mp"), + ("ap", "ap"), + ("bp", "bp"), +]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ValueType { + U8, + Real, + Bool, + Surround, + DecodeTable, +} + +impl ValueType { + fn parse(token: &str) -> Result { + match token { + "u8" => Ok(Self::U8), + "real" => Ok(Self::Real), + "bool" => Ok(Self::Bool), + "surround_profile" => Ok(Self::Surround), + _ => Err(format!("unknown value type {token}")), + } + } +} + +#[derive(Clone, Debug)] +struct Node { + name: String, + result: ValueType, + operator: String, + arguments: Vec, +} + +#[derive(Clone, Debug)] +struct Program { + inputs: Vec<(String, ValueType)>, + nodes: Vec, + checkpoints: Vec<(String, String)>, + outputs: Vec<(String, ValueType)>, +} + +#[derive(Clone, Debug)] +struct Formula { + decode: [u64; 256], + literal_order: Vec<(String, u64)>, + literals: BTreeMap, + enum_values: BTreeMap, + point: Program, + segment: Program, + singleton: Program, +} + +struct Lines<'a> { + values: Vec<&'a str>, + cursor: usize, +} + +impl<'a> Lines<'a> { + fn next(&mut self) -> Result<&'a str, String> { + let index = self.cursor; + let value = self + .values + .get(index) + .copied() + .ok_or_else(|| format!("unexpected end at line {}", index + 1))?; + self.cursor += 1; + Ok(value) + } + + fn expect(&mut self, expected: &str) -> Result<(), String> { + let actual = self.next()?; + if actual == expected { + Ok(()) + } else { + Err(format!( + "line {}: expected {expected:?}, got {actual:?}", + self.cursor + )) + } + } +} + +fn parse_formula(bytes: &[u8]) -> Result { + if !bytes.is_ascii() { + return Err("formula is not ASCII".into()); + } + if !bytes.ends_with(b"\n") || bytes.ends_with(b"\n\n") { + return Err("formula must have exactly one final LF".into()); + } + if bytes.iter().any(|byte| matches!(byte, b'\r' | b'\t' | 0)) { + return Err("formula contains a forbidden control byte".into()); + } + let text = std::str::from_utf8(bytes).map_err(|error| error.to_string())?; + let raw_lines = text + .strip_suffix('\n') + .unwrap() + .split('\n') + .collect::>(); + for (index, line) in raw_lines.iter().enumerate() { + if line.is_empty() + || line.starts_with(' ') + || line.ends_with(' ') + || line.contains(" ") + || line.contains('#') + { + return Err(format!("line {} is not canonical", index + 1)); + } + if line + .split(' ') + .any(|token| token.is_empty() || !token.bytes().all(is_token_byte)) + { + return Err(format!("line {} contains an invalid token", index + 1)); + } + } + + let mut lines = Lines { + values: raw_lines.clone(), + cursor: 0, + }; + lines.expect("labcolors_exact_real_ssa 1")?; + lines.expect("arithmetic exact_real_v1")?; + lines.expect("types 4")?; + for declaration in TYPE_DECLARATIONS { + lines.expect(declaration)?; + } + lines.expect("operators 20")?; + for declaration in OPERATOR_DECLARATIONS { + lines.expect(declaration)?; + } + + lines.expect("decode_table decode_srgb8 256")?; + let mut decode = [0_u64; 256]; + for (ordinal, slot) in decode.iter_mut().enumerate() { + let fields = fields(lines.next()?, 3)?; + require(fields[0] == "decode", "invalid decode record")?; + require( + fields[1] == format!("{ordinal:02x}"), + "decode ordinals are not canonical", + )?; + *slot = parse_finite_bits(fields[2])?; + } + + lines.expect("literals 56")?; + let mut literals = BTreeMap::new(); + let mut literal_order = Vec::with_capacity(56); + let mut literal_payloads = BTreeSet::new(); + for _ in 0..56 { + let values = fields(lines.next()?, 3)?; + require(values[0] == "literal", "invalid literal record")?; + require(is_identifier(values[1]), "invalid literal name")?; + let bits = parse_finite_bits(values[2])?; + require( + literals.insert(values[1].to_owned(), bits).is_none(), + "duplicate literal name", + )?; + require(literal_payloads.insert(bits), "duplicate literal payload")?; + literal_order.push((values[1].to_owned(), bits)); + } + + lines.expect("enum_type surround_profile 3")?; + let mut enum_values = BTreeMap::new(); + for expected in [ + ("surround_average", "01"), + ("surround_dim", "02"), + ("surround_dark", "03"), + ] { + let values = fields(lines.next()?, 4)?; + require( + values == ["enum", "surround_profile", expected.0, expected.1], + "surround enum is not the closed registered set", + )?; + let byte = u8::from_str_radix(values[3], 16).map_err(|error| error.to_string())?; + require( + enum_values.insert(values[2].to_owned(), byte).is_none(), + "duplicate enum value", + )?; + } + + let mut global_names = BTreeSet::from(["decode_srgb8"]); + for name in literals.keys().chain(enum_values.keys()) { + require( + global_names.insert(name.as_str()), + "duplicate or shadowed global symbol", + )?; + } + + let globals = global_symbols(&literals, &enum_values); + let point = parse_program( + &mut lines, + "point", + 6, + 226, + Some(39), + 3, + &[ + ("r8", ValueType::U8), + ("g8", ValueType::U8), + ("b8", ValueType::U8), + ("adapting_luminance", ValueType::Real), + ("background_ratio", ValueType::Real), + ("surround", ValueType::Surround), + ], + &["jp", "ap", "bp"], + &globals, + )?; + require( + point + .checkpoints + .iter() + .map(|(name, target)| (name.as_str(), target.as_str())) + .eq(POINT_CHECKPOINTS), + "point checkpoints are not the registered diagnostic ABI", + )?; + let segment = parse_program( + &mut lines, + "segment", + 14, + 27, + None, + 1, + &[ + ("segment_t", ValueType::Real), + ("segment_a", ValueType::Real), + ("segment_b", ValueType::Real), + ("segment_t0", ValueType::Real), + ("segment_t1", ValueType::Real), + ("segment_c0a", ValueType::Real), + ("segment_c0b", ValueType::Real), + ("segment_c1a", ValueType::Real), + ("segment_c1b", ValueType::Real), + ("segment_rho0", ValueType::Real), + ("segment_rho1", ValueType::Real), + ("segment_g00", ValueType::Real), + ("segment_g01", ValueType::Real), + ("segment_g11", ValueType::Real), + ], + &["segment_f"], + &globals, + )?; + let singleton = parse_program( + &mut lines, + "singleton", + 8, + 12, + None, + 1, + &[ + ("singleton_a", ValueType::Real), + ("singleton_b", ValueType::Real), + ("singleton_ca", ValueType::Real), + ("singleton_cb", ValueType::Real), + ("singleton_rho", ValueType::Real), + ("singleton_g00", ValueType::Real), + ("singleton_g01", ValueType::Real), + ("singleton_g11", ValueType::Real), + ], + &["singleton_f"], + &globals, + )?; + lines.expect("driver 6")?; + for rule in DRIVER_RULES { + lines.expect(rule)?; + } + lines.expect("end")?; + require( + lines.cursor == lines.values.len(), + "trailing formula records", + )?; + + validate_reachability(&point, &segment, &singleton, &literals)?; + let formula = Formula { + decode, + literal_order, + literals, + enum_values, + point, + segment, + singleton, + }; + require( + emit_formula(&formula).as_bytes() == bytes, + "parse/emit is not byte-identical", + )?; + Ok(formula) +} + +fn registered_formula() -> &'static Formula { + static FORMULA: OnceLock = OnceLock::new(); + FORMULA.get_or_init(|| parse_formula(FORMULA_BYTES).unwrap()) +} + +fn emit_formula(formula: &Formula) -> String { + let mut output = String::new(); + writeln!(output, "labcolors_exact_real_ssa 1").unwrap(); + writeln!(output, "arithmetic exact_real_v1").unwrap(); + writeln!(output, "types {}", TYPE_DECLARATIONS.len()).unwrap(); + for declaration in TYPE_DECLARATIONS { + writeln!(output, "{declaration}").unwrap(); + } + writeln!(output, "operators {}", OPERATOR_DECLARATIONS.len()).unwrap(); + for declaration in OPERATOR_DECLARATIONS { + writeln!(output, "{declaration}").unwrap(); + } + writeln!(output, "decode_table decode_srgb8 {}", formula.decode.len()).unwrap(); + for (ordinal, bits) in formula.decode.iter().enumerate() { + writeln!(output, "decode {ordinal:02x} {bits:016x}").unwrap(); + } + writeln!(output, "literals {}", formula.literal_order.len()).unwrap(); + for (name, bits) in &formula.literal_order { + writeln!(output, "literal {name} {bits:016x}").unwrap(); + } + writeln!( + output, + "enum_type surround_profile {}", + formula.enum_values.len() + ) + .unwrap(); + for name in ["surround_average", "surround_dim", "surround_dark"] { + writeln!( + output, + "enum surround_profile {name} {:02x}", + formula.enum_values[name], + ) + .unwrap(); + } + emit_program(&mut output, "point", &formula.point, true); + emit_program(&mut output, "segment", &formula.segment, false); + emit_program(&mut output, "singleton", &formula.singleton, false); + writeln!(output, "driver {}", DRIVER_RULES.len()).unwrap(); + for rule in DRIVER_RULES { + writeln!(output, "{rule}").unwrap(); + } + writeln!(output, "end").unwrap(); + output +} + +fn emit_program(output: &mut String, prefix: &str, program: &Program, checkpoints: bool) { + writeln!(output, "{prefix}_inputs {}", program.inputs.len()).unwrap(); + for (name, value_type) in &program.inputs { + writeln!(output, "input {name} {}", value_type_name(*value_type)).unwrap(); + } + writeln!(output, "{prefix}_nodes {}", program.nodes.len()).unwrap(); + for node in &program.nodes { + writeln!( + output, + "node {} {} {} {}", + node.name, + value_type_name(node.result), + node.operator, + node.arguments.join(" "), + ) + .unwrap(); + } + if checkpoints { + writeln!(output, "{prefix}_checkpoints {}", program.checkpoints.len(),).unwrap(); + for (name, target) in &program.checkpoints { + writeln!(output, "checkpoint {name} {target}").unwrap(); + } + } + writeln!(output, "{prefix}_outputs {}", program.outputs.len()).unwrap(); + for (name, value_type) in &program.outputs { + writeln!(output, "output {name} {}", value_type_name(*value_type)).unwrap(); + } +} + +fn value_type_name(value_type: ValueType) -> &'static str { + match value_type { + ValueType::U8 => "u8", + ValueType::Real => "real", + ValueType::Bool => "bool", + ValueType::Surround => "surround_profile", + ValueType::DecodeTable => panic!("decode table is not a serializable value type"), + } +} + +#[allow(clippy::too_many_arguments)] +fn parse_program( + lines: &mut Lines<'_>, + prefix: &str, + input_count: usize, + node_count: usize, + checkpoint_count: Option, + output_count: usize, + expected_inputs: &[(&str, ValueType)], + expected_outputs: &[&str], + globals: &BTreeMap, +) -> Result { + lines.expect(&format!("{prefix}_inputs {input_count}"))?; + let mut symbols = globals.clone(); + let mut inputs = Vec::new(); + for expected in expected_inputs { + let values = fields(lines.next()?, 3)?; + require(values[0] == "input", "invalid input record")?; + let result = ValueType::parse(values[2])?; + require( + values[1] == expected.0 && result == expected.1, + "program inputs are not the registered interface", + )?; + insert_symbol(&mut symbols, values[1], result)?; + inputs.push((values[1].to_owned(), result)); + } + + lines.expect(&format!("{prefix}_nodes {node_count}"))?; + let mut nodes = Vec::new(); + for _ in 0..node_count { + let values = lines.next()?.split(' ').collect::>(); + require( + values.len() >= 5 && values[0] == "node", + "invalid node record", + )?; + require(is_identifier(values[1]), "invalid node name")?; + let result = ValueType::parse(values[2])?; + let arguments = values[4..] + .iter() + .map(|value| (*value).to_owned()) + .collect::>(); + validate_operation(values[3], result, &arguments, &symbols)?; + insert_symbol(&mut symbols, values[1], result)?; + nodes.push(Node { + name: values[1].to_owned(), + result, + operator: values[3].to_owned(), + arguments, + }); + } + + let mut checkpoints = Vec::new(); + if let Some(count) = checkpoint_count { + lines.expect(&format!("{prefix}_checkpoints {count}"))?; + let mut names = BTreeSet::new(); + let node_names = nodes + .iter() + .map(|node| node.name.as_str()) + .collect::>(); + for _ in 0..count { + let values = fields(lines.next()?, 3)?; + require(values[0] == "checkpoint", "invalid checkpoint record")?; + require(names.insert(values[1]), "duplicate checkpoint name")?; + require( + node_names.contains(values[2]) && symbols.get(values[2]) == Some(&ValueType::Real), + "checkpoint must reference a preceding real node", + )?; + checkpoints.push((values[1].to_owned(), values[2].to_owned())); + } + } + + lines.expect(&format!("{prefix}_outputs {output_count}"))?; + let mut outputs = Vec::new(); + for expected in expected_outputs { + let values = fields(lines.next()?, 3)?; + require( + values[0] == "output" && values[1] == *expected, + "program outputs are not the registered interface", + )?; + let result = ValueType::parse(values[2])?; + require( + result == ValueType::Real && symbols.get(values[1]) == Some(&result), + "output must name a preceding real node", + )?; + outputs.push((values[1].to_owned(), result)); + } + Ok(Program { + inputs, + nodes, + checkpoints, + outputs, + }) +} + +fn validate_operation( + operator: &str, + result: ValueType, + arguments: &[String], + symbols: &BTreeMap, +) -> Result<(), String> { + let types = arguments + .iter() + .map(|name| { + symbols + .get(name) + .copied() + .ok_or_else(|| format!("unknown or forward reference {name}")) + }) + .collect::, _>>()?; + match operator { + "lookup" => require( + result == ValueType::Real + && types.as_slice() == [ValueType::DecodeTable, ValueType::U8], + "lookup type/arity mismatch", + ), + "eq" => require( + result == ValueType::Bool + && types.len() == 2 + && types[0] == types[1] + && types[0] != ValueType::DecodeTable, + "eq type/arity mismatch", + ), + "select" => require( + types.len() == 3 + && types[0] == ValueType::Bool + && types[1] == types[2] + && types[1] != ValueType::DecodeTable + && result == types[1], + "select type/arity mismatch", + ), + "root3" | "sqrt" | "exp" | "log" | "sin" | "cos" | "abs" | "sign" => require( + result == ValueType::Real && types.as_slice() == [ValueType::Real], + "unary real type/arity mismatch", + ), + "add" | "sub" | "mul" | "div" | "min" | "max" | "pow_pos" | "pow_nn" | "ratio0" => require( + result == ValueType::Real && types.as_slice() == [ValueType::Real, ValueType::Real], + "binary real type/arity mismatch", + ), + _ => Err(format!("unknown operator {operator}")), + } +} + +fn validate_reachability( + point: &Program, + segment: &Program, + singleton: &Program, + literals: &BTreeMap, +) -> Result<(), String> { + let mut used_globals = BTreeSet::new(); + let mut used_operators = BTreeSet::new(); + for program in [point, segment, singleton] { + let mut program_symbols = BTreeSet::new(); + let nodes = program + .nodes + .iter() + .map(|node| (node.name.as_str(), node)) + .collect::>(); + let mut roots = program + .outputs + .iter() + .map(|(name, _)| name.as_str()) + .collect::>(); + let mut reachable = BTreeSet::new(); + while let Some(name) = roots.pop() { + if let Some(node) = nodes.get(name) { + if !reachable.insert(name) { + continue; + } + used_operators.insert(node.operator.as_str()); + roots.extend(node.arguments.iter().map(String::as_str)); + } else { + used_globals.insert(name); + program_symbols.insert(name); + } + } + require( + reachable.len() == program.nodes.len(), + "program contains an unreachable node", + )?; + for (input, _) in &program.inputs { + require( + program_symbols.contains(input.as_str()), + "program contains an unused input", + )?; + } + } + for literal in literals.keys() { + require( + used_globals.contains(literal.as_str()), + "formula contains an unused literal", + )?; + } + require( + used_globals.contains("decode_srgb8"), + "formula contains an unused decode table", + )?; + let declared_operators = OPERATOR_DECLARATIONS + .iter() + .map(|declaration| declaration.split(' ').nth(1).unwrap()) + .collect::>(); + require( + used_operators == declared_operators, + "formula contains an unused operator declaration", + ) +} + +fn global_symbols( + literals: &BTreeMap, + enum_values: &BTreeMap, +) -> BTreeMap { + let mut symbols = BTreeMap::from([("decode_srgb8".to_owned(), ValueType::DecodeTable)]); + symbols.extend(literals.keys().map(|name| (name.clone(), ValueType::Real))); + symbols.extend( + enum_values + .keys() + .map(|name| (name.clone(), ValueType::Surround)), + ); + symbols +} + +fn insert_symbol( + symbols: &mut BTreeMap, + name: &str, + value_type: ValueType, +) -> Result<(), String> { + require(is_identifier(name), "invalid symbol name")?; + require( + symbols.insert(name.to_owned(), value_type).is_none(), + "duplicate or shadowed symbol", + ) +} + +fn fields(line: &str, expected: usize) -> Result, String> { + let fields = line.split(' ').collect::>(); + require(fields.len() == expected, "record arity mismatch")?; + Ok(fields) +} + +fn parse_finite_bits(token: &str) -> Result { + require( + token.len() == 16 + && token + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)), + "binary64 payload is not 16 lowercase hexadecimal digits", + )?; + let bits = u64::from_str_radix(token, 16).map_err(|error| error.to_string())?; + let value = f64::from_bits(bits); + require(value.is_finite(), "binary64 payload is not finite")?; + require( + bits != (-0.0_f64).to_bits(), + "negative zero is not canonical", + )?; + Ok(bits) +} + +fn is_token_byte(byte: u8) -> bool { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_') +} + +fn is_identifier(value: &str) -> bool { + value + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase()) + && value.bytes().all(is_token_byte) +} + +fn require(condition: bool, message: &str) -> Result<(), String> { + if condition { + Ok(()) + } else { + Err(message.to_owned()) + } +} + +fn validate_owner_literal_binding(formula: &Formula) -> Result<(), String> { + const EXACT_REAL_LITERALS: [(&str, f64); 1] = [("zero", 0.0)]; + let owners: [(&str, &[(&str, f64)]); 5] = [ + ("exact-real SSA", &EXACT_REAL_LITERALS), + ("sRGB/D65", srgb::contextual_region_formula_literals_v1()), + ("CAT16", cat16::contextual_region_formula_literals_v1()), + ( + "CAM16 viewing conditions", + vc::contextual_region_formula_literals_v1(), + ), + ( + "CAM16/CAM16-UCS", + cam16::contextual_region_formula_literals_v1(), + ), + ]; + let mut registered = BTreeMap::<&str, (u64, &str)>::new(); + for (owner, literals) in owners { + for &(name, value) in literals { + let bits = value.to_bits(); + if let Some(&(previous, previous_owner)) = registered.get(name) { + if previous != bits { + return Err(format!( + "owner conflict for {name}: {previous_owner}={previous:016x}, {owner}={bits:016x}", + )); + } + } else { + registered.insert(name, (bits, owner)); + } + } + } + require( + registered.len() == formula.literals.len(), + "owner registry does not cover exactly the formula literal set", + )?; + for (name, artifact_bits) in &formula.literals { + let Some(&(owner_bits, owner)) = registered.get(name.as_str()) else { + return Err(format!("formula literal {name} has no numeric owner")); + }; + if *artifact_bits != owner_bits { + return Err(format!( + "formula literal {name}={artifact_bits:016x} differs from {owner} owner={owner_bits:016x}", + )); + } + } + Ok(()) +} + +#[test] +fn exact_real_formula_has_one_strict_typed_canonical_parse() { + let formula = parse_formula(FORMULA_BYTES).unwrap(); + validate_owner_literal_binding(&formula).unwrap(); + assert_eq!(emit_formula(&formula).as_bytes(), FORMULA_BYTES); + assert_eq!(formula.decode.len(), 256); + assert_eq!(formula.literals.len(), 56); + assert_eq!(formula.enum_values.len(), 3); + assert_eq!(formula.point.inputs.len(), 6); + assert_eq!(formula.point.nodes.len(), 226); + assert_eq!(formula.point.checkpoints.len(), 39); + assert_eq!(formula.point.outputs.len(), 3); + assert_eq!(formula.segment.inputs.len(), 14); + assert_eq!(formula.segment.nodes.len(), 27); + assert_eq!(formula.singleton.inputs.len(), 8); + assert_eq!(formula.singleton.nodes.len(), 12); + for (ordinal, bits) in formula.decode.into_iter().enumerate() { + assert_eq!(bits, decode_8bit(ordinal as u8).to_bits()); + } +} + +#[test] +fn exact_real_formula_parser_rejects_semantic_and_canonical_mutants() { + for (needle, replacement) in [ + ("types 4", "types 5"), + ( + "operator add 2 real exact_x_plus_y", + "operator add 2 real exact_x_minus_y", + ), + ( + "node xyz_x_r real mul srgb_m00 linear_r", + "node xyz_x_r real unknown srgb_m00 linear_r", + ), + ( + "node xyz_x_r real mul srgb_m00 linear_r", + "node xyz_x_r real mul xyz_x_g linear_r", + ), + ( + "node xyz_x real add xyz_x_rg xyz_x_b", + "node xyz_x real add xyz_x_r xyz_x_b", + ), + ( + "node n real add background_ratio zero", + "node n real add p0_2 zero", + ), + ( + "literal zero 0000000000000000", + "literal zero 7ff0000000000000", + ), + ("input surround surround_profile", "input surround u8"), + ( + "enum surround_profile surround_dark 03", + "enum surround_profile surround_dark 04", + ), + ("checkpoint n n", "checkpoint n background_ratio"), + ("rule boundary inclusive", "rule boundary exclusive"), + ] { + let mutant = replace_once(FORMULA_BYTES, needle.as_bytes(), replacement.as_bytes()); + assert!( + parse_formula(&mutant).is_err(), + "mutant unexpectedly parsed: {replacement}", + ); + } + for mutant in [ + [FORMULA_BYTES, b"\n"].concat(), + replace_once(FORMULA_BYTES, b"\n", b"\r\n"), + replace_once(FORMULA_BYTES, b"types 4\n", b"types 4 \n"), + ] { + assert!(parse_formula(&mutant).is_err()); + } +} + +#[test] +fn one_ulp_owner_literal_mutation_changes_identity_and_fails_exact_source_binding() { + let mutant_bytes = replace_once( + FORMULA_BYTES, + b"literal p1_7 3ffb333333333333", + b"literal p1_7 3ffb333333333334", + ); + let mutant = parse_formula(&mutant_bytes).expect("well-typed semantic mutant must parse"); + assert_ne!( + crate::sha256::digest(FORMULA_BYTES), + crate::sha256::digest(&mutant_bytes), + ); + + let error = validate_owner_literal_binding(&mutant) + .expect_err("one-ULP UCS J-scale drift must fail exact source binding"); + assert!(error.contains("p1_7"), "unexpected binding error: {error}"); +} + +#[test] +fn formula_decode_and_xyz_checkpoint_are_bit_bound_to_the_runtime_transform() { + let formula = parse_formula(FORMULA_BYTES).unwrap(); + let channels = [0, 1, 10, 11, 127, 128, 254, 255]; + for red in channels { + for green in channels { + for blue in channels { + let rgb = [red, green, blue]; + let values = evaluate_point_values(&formula, rgb, 64.0, 0.2, 1); + let signal = ColorSignal::from_srgb8(Srgb8::new(rgb)); + let xyz = derive_modeled_tristimulus_v1(signal) + .unwrap() + .sample() + .xyz(); + assert_eq!(real(values["xyz_x"]).to_bits(), xyz[0].to_bits()); + assert_eq!(real(values["xyz_y"]).to_bits(), xyz[1].to_bits()); + assert_eq!(real(values["xyz_z"]).to_bits(), xyz[2].to_bits()); + } + } + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(512))] + + #[test] + fn segment_ssa_matches_an_independent_exact_integer_oracle( + left in ( + -8_i64..=8, + 1_i64..=8, + 0_i64..=8, + -8_i64..=8, + -8_i64..=8, + -8_i64..=8, + -8_i64..=8, + ), + right in ( + -8_i64..=8, + -8_i64..=8, + 0_i64..=8, + 0_i64..=8, + 1_i64..=4, + -1_i64..=1, + 1_i64..=4, + ), + ) { + let (t0, d, raw_beta, a, b, c0a, c0b) = left; + let (c1a, c1b, rho0, rho1, g00, g01, g11) = right; + prop_assume!(g00 * g11 > g01 * g01); + let beta = raw_beta % (d + 1); + let alpha = d - beta; + let t = t0 + beta; + let t1 = t0 + d; + let formula = registered_formula(); + let values = evaluate_program( + formula, + &formula.segment, + &[ + ("segment_t", Value::Real(t as f64)), + ("segment_a", Value::Real(a as f64)), + ("segment_b", Value::Real(b as f64)), + ("segment_t0", Value::Real(t0 as f64)), + ("segment_t1", Value::Real(t1 as f64)), + ("segment_c0a", Value::Real(c0a as f64)), + ("segment_c0b", Value::Real(c0b as f64)), + ("segment_c1a", Value::Real(c1a as f64)), + ("segment_c1b", Value::Real(c1b as f64)), + ("segment_rho0", Value::Real(rho0 as f64)), + ("segment_rho1", Value::Real(rho1 as f64)), + ("segment_g00", Value::Real(g00 as f64)), + ("segment_g01", Value::Real(g01 as f64)), + ("segment_g11", Value::Real(g11 as f64)), + ], + ); + + let d = i128::from(d); + let alpha = i128::from(alpha); + let beta = i128::from(beta); + let ua = d * i128::from(a) + - alpha * i128::from(c0a) + - beta * i128::from(c1a); + let ub = d * i128::from(b) + - alpha * i128::from(c0b) + - beta * i128::from(c1b); + let quadratic = i128::from(g00) * ua * ua + + 2 * i128::from(g01) * ua * ub + + i128::from(g11) * ub * ub; + let radius = d * (alpha * i128::from(rho0) + beta * i128::from(rho1)); + let oracle = (quadratic - radius) as f64; + prop_assert_eq!(real(values["segment_f"]).to_bits(), oracle.to_bits()); + } + + #[test] + fn singleton_ssa_matches_an_independent_exact_integer_oracle( + values in ( + -8_i64..=8, + -8_i64..=8, + -8_i64..=8, + -8_i64..=8, + 0_i64..=8, + 1_i64..=4, + -1_i64..=1, + 1_i64..=4, + ), + ) { + let (a, b, ca, cb, rho, g00, g01, g11) = values; + prop_assume!(g00 * g11 > g01 * g01); + let formula = registered_formula(); + let evaluated = evaluate_program( + formula, + &formula.singleton, + &[ + ("singleton_a", Value::Real(a as f64)), + ("singleton_b", Value::Real(b as f64)), + ("singleton_ca", Value::Real(ca as f64)), + ("singleton_cb", Value::Real(cb as f64)), + ("singleton_rho", Value::Real(rho as f64)), + ("singleton_g00", Value::Real(g00 as f64)), + ("singleton_g01", Value::Real(g01 as f64)), + ("singleton_g11", Value::Real(g11 as f64)), + ], + ); + let ua = i128::from(a - ca); + let ub = i128::from(b - cb); + let oracle = (i128::from(g00) * ua * ua + + 2 * i128::from(g01) * ua * ub + + i128::from(g11) * ub * ub + - i128::from(rho)) as f64; + prop_assert_eq!(real(evaluated["singleton_f"]).to_bits(), oracle.to_bits()); + } +} + +fn replace_once(source: &[u8], needle: &[u8], replacement: &[u8]) -> Vec { + let start = source + .windows(needle.len()) + .position(|window| window == needle) + .expect("registered mutation anchor is present"); + let mut result = Vec::with_capacity(source.len() - needle.len() + replacement.len()); + result.extend_from_slice(&source[..start]); + result.extend_from_slice(replacement); + result.extend_from_slice(&source[start + needle.len()..]); + result +} + +#[derive(Clone, Copy, Debug)] +enum Value { + U8(u8), + Real(f64), + Bool(bool), + Surround(u8), + DecodeTable, +} + +fn evaluate_point( + formula: &Formula, + rgb: [u8; 3], + adapting_luminance: f64, + background_ratio: f64, + surround: u8, +) -> [f64; 3] { + let values = + evaluate_point_values(formula, rgb, adapting_luminance, background_ratio, surround); + formula + .point + .outputs + .iter() + .map(|(name, _)| real(values[name])) + .collect::>() + .try_into() + .unwrap() +} + +fn evaluate_point_values( + formula: &Formula, + rgb: [u8; 3], + adapting_luminance: f64, + background_ratio: f64, + surround: u8, +) -> BTreeMap { + evaluate_program( + formula, + &formula.point, + &[ + ("r8", Value::U8(rgb[0])), + ("g8", Value::U8(rgb[1])), + ("b8", Value::U8(rgb[2])), + ("adapting_luminance", Value::Real(adapting_luminance)), + ("background_ratio", Value::Real(background_ratio)), + ("surround", Value::Surround(surround)), + ], + ) +} + +fn evaluate_program( + formula: &Formula, + program: &Program, + inputs: &[(&str, Value)], +) -> BTreeMap { + let mut values = BTreeMap::new(); + values.insert("decode_srgb8".to_owned(), Value::DecodeTable); + values.extend( + formula + .literals + .iter() + .map(|(name, bits)| (name.clone(), Value::Real(f64::from_bits(*bits)))), + ); + values.extend( + formula + .enum_values + .iter() + .map(|(name, value)| (name.clone(), Value::Surround(*value))), + ); + assert_eq!(inputs.len(), program.inputs.len()); + for ((name, value), (expected_name, expected_type)) in inputs.iter().zip(&program.inputs) { + assert_eq!(*name, expected_name); + assert_eq!(value_type(*value), *expected_type); + assert!(values.insert((*name).to_owned(), *value).is_none()); + } + for node in &program.nodes { + let arguments = node + .arguments + .iter() + .map(|name| values[name]) + .collect::>(); + let value = evaluate_operation(&node.operator, &arguments, &formula.decode); + assert_eq!(value_type(value), node.result); + assert!(values.insert(node.name.clone(), value).is_none()); + } + values +} + +fn evaluate_operation(operator: &str, values: &[Value], decode: &[u64; 256]) -> Value { + let result = match (operator, values) { + ("lookup", [Value::DecodeTable, Value::U8(value)]) => { + Value::Real(f64::from_bits(decode[usize::from(*value)])) + } + ("eq", [Value::U8(left), Value::U8(right)]) => Value::Bool(left == right), + ("eq", [Value::Real(left), Value::Real(right)]) => Value::Bool(left == right), + ("eq", [Value::Bool(left), Value::Bool(right)]) => Value::Bool(left == right), + ("eq", [Value::Surround(left), Value::Surround(right)]) => Value::Bool(left == right), + ("select", [Value::Bool(condition), left, right]) => { + if *condition { + *left + } else { + *right + } + } + ("add", [left, right]) => Value::Real(real(*left) + real(*right)), + ("sub", [left, right]) => Value::Real(real(*left) - real(*right)), + ("mul", [left, right]) => Value::Real(real(*left) * real(*right)), + ("div", [left, right]) => { + let denominator = real(*right); + assert_ne!(denominator, 0.0, "formula div domain is unproven"); + Value::Real(real(*left) / denominator) + } + ("min", [left, right]) => Value::Real(real(*left).min(real(*right))), + ("max", [left, right]) => Value::Real(real(*left).max(real(*right))), + ("root3", [value]) => { + let value = real(*value); + assert!(value >= 0.0, "formula root3 domain is unproven"); + Value::Real(value.cbrt()) + } + ("sqrt", [value]) => { + let value = real(*value); + assert!(value >= 0.0, "formula sqrt domain is unproven"); + Value::Real(value.sqrt()) + } + ("exp", [value]) => Value::Real(real(*value).exp()), + ("log", [value]) => { + let value = real(*value); + assert!(value > 0.0, "formula log domain is unproven"); + Value::Real(value.ln()) + } + ("sin", [value]) => Value::Real(real(*value).sin()), + ("cos", [value]) => Value::Real(real(*value).cos()), + ("abs", [value]) => Value::Real(real(*value).abs()), + ("sign", [value]) => Value::Real(if real(*value) < 0.0 { + -1.0 + } else if real(*value) > 0.0 { + 1.0 + } else { + 0.0 + }), + ("pow_pos", [left, right]) => { + let (left, right) = (real(*left), real(*right)); + assert!(left > 0.0, "formula pow_pos domain is unproven"); + Value::Real((right * left.ln()).exp()) + } + ("pow_nn", [left, right]) => { + let (left, right) = (real(*left), real(*right)); + if left == 0.0 && right > 0.0 { + Value::Real(0.0) + } else { + assert!(left > 0.0, "formula pow_nn domain is unproven"); + Value::Real((right * left.ln()).exp()) + } + } + ("ratio0", [left, right]) => { + let (left, right) = (real(*left), real(*right)); + Value::Real(if left == 0.0 && right == 0.0 { + 0.0 + } else { + assert!(right > 0.0, "formula ratio0 domain is unproven"); + left / right + }) + } + _ => panic!("typed parser admitted an unevaluable operation {operator}"), + }; + match result { + Value::Real(value) => { + assert!( + value.is_finite(), + "binary64 characterization left the finite domain" + ); + Value::Real(if value == 0.0 { 0.0 } else { value }) + } + other => other, + } +} + +fn value_type(value: Value) -> ValueType { + match value { + Value::U8(_) => ValueType::U8, + Value::Real(_) => ValueType::Real, + Value::Bool(_) => ValueType::Bool, + Value::Surround(_) => ValueType::Surround, + Value::DecodeTable => ValueType::DecodeTable, + } +} + +fn real(value: Value) -> f64 { + let Value::Real(value) = value else { + panic!("typed formula value is not real"); + }; + value +} + +fn production_point( + rgb: [u8; 3], + adapting_luminance: f64, + background_ratio: f64, + surround: SurroundProfileId, +) -> [f64; 3] { + let context = AppearanceContextId::from_inputs( + AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1, + IEC_SRGB_D65_XYZ_FRAME_V1, + AdaptingLuminanceCdM2::try_new(adapting_luminance).unwrap(), + BackgroundLuminanceRatio::try_new(background_ratio).unwrap(), + surround, + ); + let signal = ColorSignal::from_srgb8(Srgb8::new(rgb)); + let sample = derive_modeled_tristimulus_v1(signal).unwrap().sample(); + let occurrence = LcsOccurrence::in_context(sample, context).unwrap(); + let view = AppearanceState::derive_v1(occurrence) + .unwrap() + .cam16_ucs() + .unwrap(); + [view.j_prime(), view.a_prime(), view.b_prime()] +} + +fn ordered_bits(value: f64) -> u64 { + let bits = value.to_bits(); + if bits >> 63 == 0 { + bits | (1_u64 << 63) + } else { + !bits + } +} + +fn ulp_distance(left: f64, right: f64) -> u64 { + ordered_bits(left).abs_diff(ordered_bits(right)) +} + +#[test] +fn formula_point_graph_matches_the_registered_binary64_characterization() { + let formula = parse_formula(FORMULA_BYTES).unwrap(); + let channels = [0, 1, 10, 11, 127, 128, 254, 255]; + let contexts = [(1.0, 0.01), (64.0, 0.2), (1000.0, 0.9)]; + let surrounds = [ + (SurroundProfileId::AverageV1, 1), + (SurroundProfileId::DimV1, 2), + (SurroundProfileId::DarkV1, 3), + ]; + let mut maximum = [0_u64; 3]; + let mut maximum_absolute = [0.0_f64; 3]; + let mut maximum_relative = [0.0_f64; 3]; + let mut witness = [([0_u8; 3], 0.0, 0.0, SurroundProfileId::AverageV1); 3]; + let mut witness_values = [([0.0_f64; 3], [0.0_f64; 3]); 3]; + for red in channels { + for green in channels { + for blue in channels { + for (adapting_luminance, background_ratio) in contexts { + for (surround, tag) in surrounds { + let rgb = [red, green, blue]; + let lifted = evaluate_point( + &formula, + rgb, + adapting_luminance, + background_ratio, + tag, + ); + let production = + production_point(rgb, adapting_luminance, background_ratio, surround); + for coordinate in 0..3 { + let distance = ulp_distance(lifted[coordinate], production[coordinate]); + let absolute = (lifted[coordinate] - production[coordinate]).abs(); + let relative = absolute + / lifted[coordinate] + .abs() + .max(production[coordinate].abs()) + .max(f64::MIN_POSITIVE); + maximum_absolute[coordinate] = + maximum_absolute[coordinate].max(absolute); + maximum_relative[coordinate] = + maximum_relative[coordinate].max(relative); + if distance > maximum[coordinate] { + maximum[coordinate] = distance; + witness[coordinate] = + (rgb, adapting_luminance, background_ratio, surround); + witness_values[coordinate] = (lifted, production); + } + } + } + } + } + } + } + eprintln!( + "formula/runtime ULP maxima: {maximum:?}; abs: {maximum_absolute:?}; rel: {maximum_relative:?}; witnesses: {witness:?}; values: {witness_values:?}", + ); + // Это empirical binary64 ratchet, не физический допуск и не доказательство + // exact-real эквивалентности. Первичный registered reduced corpus 8³ × 3 × 3 на macOS + // дал [4.264e-14, 4.746e-13, 9.975e-14]; десятичные ceilings округлены вверх + // и затем проверяются тем же тестом на CI Linux. Изменение требует нового + // corpus/artifact receipt. V5b2c Arb ∥ MPFI доказывает all-domain + // classification только относительно этого artifact; связь artifact ↔ numeric owners + // остаётся отдельным exact source-binding обязательством выше. + let absolute_ratcheted_ceilings = [1.0e-13, 1.0e-12, 2.0e-13]; + for coordinate in 0..3 { + assert!( + maximum_absolute[coordinate] <= absolute_ratcheted_ceilings[coordinate], + "coordinate {coordinate} binary64 characterization drifted at {:?}: {} > {}", + witness[coordinate], + maximum_absolute[coordinate], + absolute_ratcheted_ceilings[coordinate], + ); + } +} diff --git a/crates/labcolors-core/src/contextual_region_tests.rs b/crates/labcolors-core/src/contextual_region_tests.rs new file mode 100644 index 00000000..0f50b142 --- /dev/null +++ b/crates/labcolors-core/src/contextual_region_tests.rs @@ -0,0 +1,754 @@ +use core::cmp::Ordering; + +use proptest::prelude::*; + +use crate::contextual_region::{ + CONTEXTUAL_REGION_FORMULA_RELEASE_V1, ContextualRegionDefinitionReleasesV1, + ContextualRegionFamilyProviderReleaseIdV1, ContextualRegionFamilyProviderV1, + ContextualRegionFormulaReleaseIdV1, ContextualRegionIdentityEncodingReleaseIdV1, + ContextualRegionPipelineErrorV1, ContextualRegionPipelineV1, ExactDyadic64ErrorV1, + ExactDyadic64V1, PiecewiseLinearCartesianTubeErrorV1, PiecewiseLinearCartesianTubeReleaseIdV1, + PiecewiseLinearCartesianTubeV1, Shape2BitsV1, Shape2ErrorV1, TubeCoordinateV1, TubeKnotBitsV1, +}; +use crate::family::FamilyDefinitionDigestV2; +use crate::lcs_occurrence::{ + ADMITTED_SRGB8_TRISTIMULUS_BINDING_V1, AdaptingLuminanceCdM2, AppearanceContextId, + AppearanceContextSchemaReleaseId, BackgroundLuminanceRatio, CAM16_UCS_VIEW_RELEASE_V1, + CAM16_VIEW_RELEASE_V1, IEC_SRGB_D65_XYZ_FRAME_V1, MODELED_LCS_OCCURRENCE_RELEASE_V1, + MUTATION_SENTINEL_XYZ_FRAME_V1, ModeledLcsOccurrenceReleaseId, OutputProfileId, + SurroundProfileId, +}; +const FORMULA_SPEC_BYTES: &[u8] = include_bytes!("../contracts/contextual-region-formula-v1.lcir"); +const FORMULA_SPEC_DOMAIN: &[u8] = b"labcolors.nominal-exact-real-lift.ascii-ssa.v1\0"; + +const POSITIVE_ZERO: u64 = 0x0000_0000_0000_0000; +const NEGATIVE_ZERO: u64 = 0x8000_0000_0000_0000; +const MIN_SUBNORMAL: u64 = 0x0000_0000_0000_0001; +const MIN_NORMAL: u64 = 0x0010_0000_0000_0000; +const NEGATIVE_MIN_SUBNORMAL: u64 = 0x8000_0000_0000_0001; +const ONE: u64 = 0x3ff0_0000_0000_0000; +const ONE_AND_HALF: u64 = 0x3ff8_0000_0000_0000; +const TWO: u64 = 0x4000_0000_0000_0000; +const TWO_AND_HALF: u64 = 0x4004_0000_0000_0000; +const THREE: u64 = 0x4008_0000_0000_0000; +const FOUR: u64 = 0x4010_0000_0000_0000; +const HALF: u64 = 0x3fe0_0000_0000_0000; +const NEGATIVE_ONE: u64 = 0xbff0_0000_0000_0000; +const MAX_FINITE: u64 = 0x7fef_ffff_ffff_ffff; +const NEGATIVE_MAX_FINITE: u64 = 0xffef_ffff_ffff_ffff; + +fn context(frame: crate::lcs_occurrence::ColorimetricFrameId) -> AppearanceContextId { + context_with(frame, 64.0, 0.2, SurroundProfileId::AverageV1) +} + +fn context_with( + frame: crate::lcs_occurrence::ColorimetricFrameId, + adapting_luminance: f64, + background_ratio: f64, + surround: SurroundProfileId, +) -> AppearanceContextId { + AppearanceContextId::from_inputs( + AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1, + frame, + AdaptingLuminanceCdM2::try_new(adapting_luminance).unwrap(), + BackgroundLuminanceRatio::try_new(background_ratio).unwrap(), + surround, + ) +} + +fn knot(tone: u64, center_a: u64, center_b: u64, radius_squared: u64) -> TubeKnotBitsV1 { + TubeKnotBitsV1::new(tone, center_a, center_b, radius_squared) +} + +fn region_with_centers(centers: [[u64; 2]; 2]) -> PiecewiseLinearCartesianTubeV1 { + PiecewiseLinearCartesianTubeV1::try_from_bits( + Shape2BitsV1::new(ONE, POSITIVE_ZERO, ONE), + &[ + knot(ONE, centers[0][0], centers[0][1], FOUR), + knot(TWO, centers[1][0], centers[1][1], FOUR), + ], + ) + .unwrap() +} + +fn pipeline(context: AppearanceContextId) -> ContextualRegionPipelineV1 { + pipeline_with_release(context, MODELED_LCS_OCCURRENCE_RELEASE_V1) +} + +fn pipeline_with_release( + context: AppearanceContextId, + modeled: ModeledLcsOccurrenceReleaseId, +) -> ContextualRegionPipelineV1 { + ContextualRegionPipelineV1::try_new( + OutputProfileId::Iec61966Srgb8D65V1, + ADMITTED_SRGB8_TRISTIMULUS_BINDING_V1, + modeled, + context, + CAM16_VIEW_RELEASE_V1, + CAM16_UCS_VIEW_RELEASE_V1, + CONTEXTUAL_REGION_FORMULA_RELEASE_V1, + ) + .unwrap() +} + +fn pipeline_with_formula_release( + context: AppearanceContextId, + formula: ContextualRegionFormulaReleaseIdV1, +) -> ContextualRegionPipelineV1 { + ContextualRegionPipelineV1::try_new( + OutputProfileId::Iec61966Srgb8D65V1, + ADMITTED_SRGB8_TRISTIMULUS_BINDING_V1, + MODELED_LCS_OCCURRENCE_RELEASE_V1, + context, + CAM16_VIEW_RELEASE_V1, + CAM16_UCS_VIEW_RELEASE_V1, + formula, + ) + .unwrap() +} + +proptest! { + #[test] + fn exact_spd_matches_an_independent_integer_oracle( + g00 in -1_000_i16..=1_000, + g01 in -1_000_i16..=1_000, + g11 in -1_000_i16..=1_000, + ) { + // Каждое сгенерированное целое и произведение точно представимо в + // безопасном binary64-диапазоне. Oracle использует только integer math. + let expected = g00 > 0 + && i64::from(g00) * i64::from(g11) > i64::from(g01) * i64::from(g01); + let actual = PiecewiseLinearCartesianTubeV1::try_from_bits( + Shape2BitsV1::new( + f64::from(g00).to_bits(), + f64::from(g01).to_bits(), + f64::from(g11).to_bits(), + ), + &[knot(ONE, POSITIVE_ZERO, POSITIVE_ZERO, ONE)], + ) + .is_ok(); + prop_assert_eq!(actual, expected); + } +} + +#[test] +fn exact_dyadic_parser_preserves_every_finite_binary64_class() { + for bits in [ + NEGATIVE_MAX_FINITE, + NEGATIVE_ONE, + NEGATIVE_MIN_SUBNORMAL, + POSITIVE_ZERO, + MIN_SUBNORMAL, + ONE, + MAX_FINITE, + ] { + assert_eq!(ExactDyadic64V1::try_from_bits(bits).unwrap().bits(), bits); + } + + let ordered = [ + NEGATIVE_MAX_FINITE, + NEGATIVE_ONE, + NEGATIVE_MIN_SUBNORMAL, + POSITIVE_ZERO, + MIN_SUBNORMAL, + ONE, + MAX_FINITE, + ] + .map(|bits| ExactDyadic64V1::try_from_bits(bits).unwrap()); + assert!(ordered.windows(2).all(|pair| pair[0] < pair[1])); + assert_eq!(ordered[3].cmp(&ordered[3]), Ordering::Equal); +} + +#[test] +fn exact_dyadic_parser_rejects_only_nonfinite_and_negative_zero() { + assert_eq!( + ExactDyadic64V1::try_from_bits(NEGATIVE_ZERO), + Err(ExactDyadic64ErrorV1::NegativeZero), + ); + for bits in [ + f64::INFINITY.to_bits(), + f64::NEG_INFINITY.to_bits(), + f64::NAN.to_bits(), + 0x7ff0_0000_0000_0001, + 0xfff8_0000_0000_0001, + ] { + assert_eq!( + ExactDyadic64V1::try_from_bits(bits), + Err(ExactDyadic64ErrorV1::NonFinite), + ); + } +} + +#[test] +fn shape_spd_is_exact_when_platform_products_overflow() { + let admitted = PiecewiseLinearCartesianTubeV1::try_from_bits( + Shape2BitsV1::new(MAX_FINITE, POSITIVE_ZERO, MAX_FINITE), + &[knot(ONE, POSITIVE_ZERO, POSITIVE_ZERO, ONE)], + ) + .unwrap(); + assert_eq!(admitted.shape().g00().bits(), MAX_FINITE); + assert_eq!(admitted.shape().g01().bits(), POSITIVE_ZERO); + assert_eq!(admitted.shape().g11().bits(), MAX_FINITE); + + let singular = PiecewiseLinearCartesianTubeV1::try_from_bits( + Shape2BitsV1::new(MAX_FINITE, MAX_FINITE, MAX_FINITE), + &[knot(ONE, POSITIVE_ZERO, POSITIVE_ZERO, ONE)], + ); + assert_eq!( + singular, + Err(PiecewiseLinearCartesianTubeErrorV1::Shape( + Shape2ErrorV1::NonPositiveDeterminant, + )), + ); +} + +#[test] +fn shape_spd_is_exact_when_platform_products_underflow() { + PiecewiseLinearCartesianTubeV1::try_from_bits( + Shape2BitsV1::new(MIN_SUBNORMAL, POSITIVE_ZERO, MIN_SUBNORMAL), + &[knot(ONE, POSITIVE_ZERO, POSITIVE_ZERO, ONE)], + ) + .unwrap(); + + let singular = PiecewiseLinearCartesianTubeV1::try_from_bits( + Shape2BitsV1::new(MIN_SUBNORMAL, MIN_SUBNORMAL, MIN_SUBNORMAL), + &[knot(ONE, POSITIVE_ZERO, POSITIVE_ZERO, ONE)], + ); + assert_eq!( + singular, + Err(PiecewiseLinearCartesianTubeErrorV1::Shape( + Shape2ErrorV1::NonPositiveDeterminant, + )), + ); +} + +#[test] +fn shape_spd_compares_subnormal_and_normal_products_on_one_exact_scale() { + PiecewiseLinearCartesianTubeV1::try_from_bits( + Shape2BitsV1::new(MIN_SUBNORMAL, MIN_NORMAL, MAX_FINITE), + &[knot(ONE, POSITIVE_ZERO, POSITIVE_ZERO, ONE)], + ) + .unwrap(); + + let outside = PiecewiseLinearCartesianTubeV1::try_from_bits( + Shape2BitsV1::new(MIN_SUBNORMAL, ONE, MAX_FINITE), + &[knot(ONE, POSITIVE_ZERO, POSITIVE_ZERO, ONE)], + ); + assert_eq!( + outside, + Err(PiecewiseLinearCartesianTubeErrorV1::Shape( + Shape2ErrorV1::NonPositiveDeterminant, + )), + ); +} + +#[test] +fn shape_spd_exactly_aligns_products_from_adjacent_binades() { + PiecewiseLinearCartesianTubeV1::try_from_bits( + Shape2BitsV1::new(ONE, ONE_AND_HALF, TWO_AND_HALF), + &[knot(ONE, POSITIVE_ZERO, POSITIVE_ZERO, ONE)], + ) + .unwrap(); +} + +#[test] +fn region_parser_rejects_every_invalid_structural_state() { + assert_eq!( + PiecewiseLinearCartesianTubeV1::try_from_bits( + Shape2BitsV1::new(POSITIVE_ZERO, POSITIVE_ZERO, ONE), + &[knot(ONE, POSITIVE_ZERO, POSITIVE_ZERO, ONE)], + ), + Err(PiecewiseLinearCartesianTubeErrorV1::Shape( + Shape2ErrorV1::NonPositiveLeadingMinor, + )), + ); + assert_eq!( + PiecewiseLinearCartesianTubeV1::try_from_bits( + Shape2BitsV1::new(ONE, POSITIVE_ZERO, ONE), + &[], + ), + Err(PiecewiseLinearCartesianTubeErrorV1::EmptyToneDomain), + ); + assert_eq!( + PiecewiseLinearCartesianTubeV1::try_from_bits( + Shape2BitsV1::new(ONE, POSITIVE_ZERO, ONE), + &[ + knot(ONE, POSITIVE_ZERO, POSITIVE_ZERO, ONE), + knot(ONE, POSITIVE_ZERO, POSITIVE_ZERO, ONE), + ], + ), + Err(PiecewiseLinearCartesianTubeErrorV1::ToneNotStrictlyIncreasing { index: 1 },), + ); + assert_eq!( + PiecewiseLinearCartesianTubeV1::try_from_bits( + Shape2BitsV1::new(ONE, POSITIVE_ZERO, ONE), + &[ + knot(TWO, POSITIVE_ZERO, POSITIVE_ZERO, ONE), + knot(ONE, POSITIVE_ZERO, POSITIVE_ZERO, ONE), + ], + ), + Err(PiecewiseLinearCartesianTubeErrorV1::ToneNotStrictlyIncreasing { index: 1 },), + ); + assert_eq!( + PiecewiseLinearCartesianTubeV1::try_from_bits( + Shape2BitsV1::new(ONE, POSITIVE_ZERO, ONE), + &[knot(ONE, POSITIVE_ZERO, POSITIVE_ZERO, NEGATIVE_ONE)], + ), + Err(PiecewiseLinearCartesianTubeErrorV1::NegativeRadiusSquared { index: 0 }), + ); + assert_eq!( + PiecewiseLinearCartesianTubeV1::try_from_bits( + Shape2BitsV1::new(ONE, POSITIVE_ZERO, ONE), + &[knot(ONE, f64::NAN.to_bits(), POSITIVE_ZERO, ONE)], + ), + Err(PiecewiseLinearCartesianTubeErrorV1::Coordinate { + index: Some(0), + coordinate: TubeCoordinateV1::CenterA, + reason: ExactDyadic64ErrorV1::NonFinite, + }), + ); + for (shape_index, coordinate) in [ + (0, TubeCoordinateV1::ShapeG00), + (1, TubeCoordinateV1::ShapeG01), + (2, TubeCoordinateV1::ShapeG11), + ] { + let mut shape = [ONE, POSITIVE_ZERO, ONE]; + shape[shape_index] = f64::NAN.to_bits(); + assert_eq!( + PiecewiseLinearCartesianTubeV1::try_from_bits( + Shape2BitsV1::new(shape[0], shape[1], shape[2]), + &[knot(ONE, POSITIVE_ZERO, POSITIVE_ZERO, ONE)], + ), + Err(PiecewiseLinearCartesianTubeErrorV1::Coordinate { + index: None, + coordinate, + reason: ExactDyadic64ErrorV1::NonFinite, + }), + ); + } + for (raw_knot, coordinate, reason) in [ + ( + knot(NEGATIVE_ZERO, POSITIVE_ZERO, POSITIVE_ZERO, ONE), + TubeCoordinateV1::Tone, + ExactDyadic64ErrorV1::NegativeZero, + ), + ( + knot(ONE, POSITIVE_ZERO, f64::NAN.to_bits(), ONE), + TubeCoordinateV1::CenterB, + ExactDyadic64ErrorV1::NonFinite, + ), + ( + knot(ONE, POSITIVE_ZERO, POSITIVE_ZERO, f64::NAN.to_bits()), + TubeCoordinateV1::RadiusSquared, + ExactDyadic64ErrorV1::NonFinite, + ), + ] { + assert_eq!( + PiecewiseLinearCartesianTubeV1::try_from_bits( + Shape2BitsV1::new(ONE, POSITIVE_ZERO, ONE), + &[raw_knot], + ), + Err(PiecewiseLinearCartesianTubeErrorV1::Coordinate { + index: Some(0), + coordinate, + reason, + }), + ); + } +} + +#[test] +fn all_center_strengths_round_trip_as_data_of_one_region_law() { + let zero = region_with_centers([ + [POSITIVE_ZERO, POSITIVE_ZERO], + [POSITIVE_ZERO, POSITIVE_ZERO], + ]); + let weak = region_with_centers([[MIN_SUBNORMAL, HALF], [ONE, HALF]]); + let chromatic = region_with_centers([[TWO, THREE], [THREE, FOUR]]); + + assert_eq!(zero.knots().len(), weak.knots().len()); + assert_eq!(weak.knots().len(), chromatic.knots().len()); + assert_eq!(zero.shape(), weak.shape()); + assert_eq!(weak.shape(), chromatic.shape()); + assert_eq!(zero.knots()[0].tone().bits(), ONE); + assert_eq!(zero.knots()[0].center().map(ExactDyadic64V1::bits), [0; 2]); + assert_eq!( + weak.knots()[0].center().map(ExactDyadic64V1::bits), + [MIN_SUBNORMAL, HALF], + ); + assert_eq!( + weak.knots()[1].center().map(ExactDyadic64V1::bits), + [ONE, HALF], + ); + assert_eq!( + chromatic.knots()[0].center().map(ExactDyadic64V1::bits), + [TWO, THREE], + ); + assert_eq!( + chromatic.knots()[1].center().map(ExactDyadic64V1::bits), + [THREE, FOUR], + ); + assert_eq!(zero.knots()[0].radius_squared().bits(), FOUR); +} + +#[test] +fn contextual_pipeline_rejects_a_foreign_context_frame() { + let error = ContextualRegionPipelineV1::try_new( + OutputProfileId::Iec61966Srgb8D65V1, + ADMITTED_SRGB8_TRISTIMULUS_BINDING_V1, + MODELED_LCS_OCCURRENCE_RELEASE_V1, + context(MUTATION_SENTINEL_XYZ_FRAME_V1), + CAM16_VIEW_RELEASE_V1, + CAM16_UCS_VIEW_RELEASE_V1, + CONTEXTUAL_REGION_FORMULA_RELEASE_V1, + ); + assert_eq!( + error, + Err(ContextualRegionPipelineErrorV1::FrameMismatch { + lowering: IEC_SRGB_D65_XYZ_FRAME_V1, + context: MUTATION_SENTINEL_XYZ_FRAME_V1, + }), + ); +} + +#[test] +fn provider_returns_only_the_definition_digest() { + let digest: FamilyDefinitionDigestV2 = ContextualRegionFamilyProviderV1::definition_digest( + pipeline(context(IEC_SRGB_D65_XYZ_FRAME_V1)), + ®ion_with_centers([[POSITIVE_ZERO; 2]; 2]), + ); + assert_ne!(digest.as_bytes(), &[0; 32]); +} + +#[test] +fn typed_pipeline_and_region_mutations_change_definition_identity() { + let base_context = context(IEC_SRGB_D65_XYZ_FRAME_V1); + let base_region = region_with_centers([[POSITIVE_ZERO; 2]; 2]); + let baseline = + ContextualRegionFamilyProviderV1::definition_digest(pipeline(base_context), &base_region); + let assert_changed = |pipeline, region: &PiecewiseLinearCartesianTubeV1| { + assert_ne!( + ContextualRegionFamilyProviderV1::definition_digest(pipeline, region), + baseline, + ); + }; + + assert_changed( + pipeline_with_release( + base_context, + ModeledLcsOccurrenceReleaseId::MutationSentinelV1, + ), + &base_region, + ); + assert_changed( + pipeline_with_formula_release( + base_context, + ContextualRegionFormulaReleaseIdV1::MutationSentinelV1, + ), + &base_region, + ); + for changed_context in [ + context_with( + IEC_SRGB_D65_XYZ_FRAME_V1, + 32.0, + 0.2, + SurroundProfileId::AverageV1, + ), + context_with( + IEC_SRGB_D65_XYZ_FRAME_V1, + 64.0, + 0.3, + SurroundProfileId::AverageV1, + ), + context_with( + IEC_SRGB_D65_XYZ_FRAME_V1, + 64.0, + 0.2, + SurroundProfileId::DimV1, + ), + ] { + assert_changed(pipeline(changed_context), &base_region); + } + + let region = |shape, knots: &[TubeKnotBitsV1]| { + PiecewiseLinearCartesianTubeV1::try_from_bits(shape, knots).unwrap() + }; + for changed_region in [ + region( + Shape2BitsV1::new(TWO, POSITIVE_ZERO, ONE), + &[ + knot(ONE, POSITIVE_ZERO, POSITIVE_ZERO, FOUR), + knot(TWO, POSITIVE_ZERO, POSITIVE_ZERO, FOUR), + ], + ), + region( + Shape2BitsV1::new(ONE, HALF, ONE), + &[ + knot(ONE, POSITIVE_ZERO, POSITIVE_ZERO, FOUR), + knot(TWO, POSITIVE_ZERO, POSITIVE_ZERO, FOUR), + ], + ), + region( + Shape2BitsV1::new(ONE, POSITIVE_ZERO, TWO), + &[ + knot(ONE, POSITIVE_ZERO, POSITIVE_ZERO, FOUR), + knot(TWO, POSITIVE_ZERO, POSITIVE_ZERO, FOUR), + ], + ), + region( + Shape2BitsV1::new(ONE, POSITIVE_ZERO, ONE), + &[ + knot(HALF, POSITIVE_ZERO, POSITIVE_ZERO, FOUR), + knot(TWO, POSITIVE_ZERO, POSITIVE_ZERO, FOUR), + ], + ), + region( + Shape2BitsV1::new(ONE, POSITIVE_ZERO, ONE), + &[ + knot(ONE, MIN_SUBNORMAL, POSITIVE_ZERO, FOUR), + knot(TWO, POSITIVE_ZERO, POSITIVE_ZERO, FOUR), + ], + ), + region( + Shape2BitsV1::new(ONE, POSITIVE_ZERO, ONE), + &[ + knot(ONE, POSITIVE_ZERO, NEGATIVE_MIN_SUBNORMAL, FOUR), + knot(TWO, POSITIVE_ZERO, POSITIVE_ZERO, FOUR), + ], + ), + region( + Shape2BitsV1::new(ONE, POSITIVE_ZERO, ONE), + &[ + knot(ONE, POSITIVE_ZERO, POSITIVE_ZERO, THREE), + knot(TWO, POSITIVE_ZERO, POSITIVE_ZERO, FOUR), + ], + ), + region( + Shape2BitsV1::new(ONE, POSITIVE_ZERO, ONE), + &[ + knot(ONE, POSITIVE_ZERO, POSITIVE_ZERO, FOUR), + knot(THREE, POSITIVE_ZERO, POSITIVE_ZERO, FOUR), + ], + ), + region( + Shape2BitsV1::new(ONE, POSITIVE_ZERO, ONE), + &[ + knot(ONE, POSITIVE_ZERO, POSITIVE_ZERO, FOUR), + knot(TWO, MIN_SUBNORMAL, POSITIVE_ZERO, FOUR), + ], + ), + region( + Shape2BitsV1::new(ONE, POSITIVE_ZERO, ONE), + &[ + knot(ONE, POSITIVE_ZERO, POSITIVE_ZERO, FOUR), + knot(TWO, POSITIVE_ZERO, NEGATIVE_MIN_SUBNORMAL, FOUR), + ], + ), + region( + Shape2BitsV1::new(ONE, POSITIVE_ZERO, ONE), + &[ + knot(ONE, POSITIVE_ZERO, POSITIVE_ZERO, FOUR), + knot(TWO, POSITIVE_ZERO, POSITIVE_ZERO, THREE), + ], + ), + region( + Shape2BitsV1::new(ONE, POSITIVE_ZERO, ONE), + &[ + knot(ONE, POSITIVE_ZERO, POSITIVE_ZERO, FOUR), + knot(TWO, POSITIVE_ZERO, POSITIVE_ZERO, FOUR), + knot(THREE, POSITIVE_ZERO, POSITIVE_ZERO, FOUR), + ], + ), + ] { + assert_changed(pipeline(base_context), &changed_region); + } +} + +#[test] +fn typed_local_release_mutations_change_definition_identity() { + let pipeline = pipeline(context(IEC_SRGB_D65_XYZ_FRAME_V1)); + let region = region_with_centers([[POSITIVE_ZERO; 2]; 2]); + let baseline = ContextualRegionFamilyProviderV1::definition_digest(pipeline, ®ion); + let stable_encoding = ContextualRegionIdentityEncodingReleaseIdV1::LengthPrefixedBigEndianV1; + let stable_provider = ContextualRegionFamilyProviderReleaseIdV1::V1; + let stable_region = PiecewiseLinearCartesianTubeReleaseIdV1::V1; + + for releases in [ + ContextualRegionDefinitionReleasesV1::new( + ContextualRegionIdentityEncodingReleaseIdV1::MutationSentinelV1, + stable_provider, + stable_region, + ), + ContextualRegionDefinitionReleasesV1::new( + stable_encoding, + ContextualRegionFamilyProviderReleaseIdV1::MutationSentinelV1, + stable_region, + ), + ContextualRegionDefinitionReleasesV1::new( + stable_encoding, + stable_provider, + PiecewiseLinearCartesianTubeReleaseIdV1::MutationSentinelV1, + ), + ] { + assert_ne!( + ContextualRegionFamilyProviderV1::definition_digest_with_releases_for_test( + releases, pipeline, ®ion, + ), + baseline, + ); + } +} + +#[test] +fn exact_real_formula_release_is_domain_separated_canonical_content() { + let mut hasher = crate::sha256::Hasher::new(); + hasher.update(FORMULA_SPEC_DOMAIN); + hasher.update(&(FORMULA_SPEC_BYTES.len() as u64).to_be_bytes()); + hasher.update(FORMULA_SPEC_BYTES); + let release = *hasher.finalize().as_bytes(); + assert_eq!( + release, + [ + 0x2c, 0x62, 0x6d, 0x8e, 0xe6, 0x0e, 0xeb, 0x62, 0xae, 0x4d, 0xb5, 0x36, 0x60, 0xd6, + 0x1b, 0xbc, 0x25, 0xe0, 0xef, 0xd4, 0xe5, 0x57, 0xf0, 0xdc, 0x1e, 0x77, 0x56, 0x5c, + 0x13, 0x0b, 0x6e, 0x52, + ], + ); + + let mut mutated = FORMULA_SPEC_BYTES.to_vec(); + let payload = mutated + .windows(b"literal p3_8 400e666666666666".len()) + .position(|window| window == b"literal p3_8 400e666666666666") + .expect("hue-offset literal is present") + + b"literal p3_8 ".len(); + mutated[payload] = b'5'; + let mut mutant_hasher = crate::sha256::Hasher::new(); + mutant_hasher.update(FORMULA_SPEC_DOMAIN); + mutant_hasher.update(&(mutated.len() as u64).to_be_bytes()); + mutant_hasher.update(&mutated); + assert_ne!(mutant_hasher.finalize().as_bytes(), &release); +} + +#[test] +fn canonical_identity_is_length_prefixed_big_endian_and_golden() { + let pipeline = pipeline(context(IEC_SRGB_D65_XYZ_FRAME_V1)); + let region = region_with_centers([[POSITIVE_ZERO; 2]; 2]); + let bytes = + ContextualRegionFamilyProviderV1::canonical_identity_bytes_for_test(pipeline, ®ion); + + let fields = decode_fields(&bytes); + assert_eq!(fields.len(), 30); + assert_eq!( + fields[0], + b"labcolors.contextual-region-family-provider.v1\0" + ); + assert_eq!(fields[1], &[1]); + assert_eq!(fields[2], &[1]); + assert_eq!(fields[3], &[1]); + assert_eq!(fields[4], &[1]); + assert_eq!(fields[5], &[1]); + assert_eq!(fields[6], &[1]); + assert_eq!(fields[7], &[1, 1, 1, 1]); + assert_eq!(fields[8], &[1]); + assert_eq!(fields[9], &[1]); + assert_eq!(fields[10], &[1, 1, 1, 1]); + assert_eq!(fields[11], &64.0_f64.to_bits().to_be_bytes()); + assert_eq!(fields[12], &0.2_f64.to_bits().to_be_bytes()); + assert_eq!(fields[13], &[1]); + assert_eq!(fields[14], &[1]); + assert_eq!(fields[15], &[1]); + assert_eq!( + fields[16], + &[ + 0x2c, 0x62, 0x6d, 0x8e, 0xe6, 0x0e, 0xeb, 0x62, 0xae, 0x4d, 0xb5, 0x36, 0x60, 0xd6, + 0x1b, 0xbc, 0x25, 0xe0, 0xef, 0xd4, 0xe5, 0x57, 0xf0, 0xdc, 0x1e, 0x77, 0x56, 0x5c, + 0x13, 0x0b, 0x6e, 0x52, + ], + ); + assert_eq!(fields[17], &[1]); + assert_eq!(fields[18], &ONE.to_be_bytes()); + assert_eq!(fields[19], &POSITIVE_ZERO.to_be_bytes()); + assert_eq!(fields[20], &ONE.to_be_bytes()); + assert_eq!(fields[21], &2_u64.to_be_bytes()); + assert_eq!(fields[22], &ONE.to_be_bytes()); + assert_eq!(fields[23], &POSITIVE_ZERO.to_be_bytes()); + assert_eq!(fields[24], &POSITIVE_ZERO.to_be_bytes()); + assert_eq!(fields[25], &FOUR.to_be_bytes()); + assert_eq!(fields[26], &TWO.to_be_bytes()); + assert_eq!(fields[27], &POSITIVE_ZERO.to_be_bytes()); + assert_eq!(fields[28], &POSITIVE_ZERO.to_be_bytes()); + assert_eq!(fields[29], &FOUR.to_be_bytes()); + + assert_eq!( + hex(ContextualRegionFamilyProviderV1::definition_digest(pipeline, ®ion,).as_bytes()), + "0a8d1c3d2f0052be84b5783071699861aad0ac83dae62de3275267754681cdc9", + ); +} + +#[test] +fn every_canonical_identity_field_changes_the_digest() { + let pipeline = pipeline(context(IEC_SRGB_D65_XYZ_FRAME_V1)); + let region = region_with_centers([[POSITIVE_ZERO; 2]; 2]); + let bytes = + ContextualRegionFamilyProviderV1::canonical_identity_bytes_for_test(pipeline, ®ion); + let baseline = crate::sha256::digest(&bytes); + let ranges = field_payload_ranges(&bytes); + assert_eq!(ranges.len(), 30); + + for (field, range) in ranges.into_iter().enumerate() { + let mut mutated = bytes.clone(); + mutated[range.start] ^= 0x01; + assert_ne!( + crate::sha256::digest(&mutated), + baseline, + "canonical field {field} is not content-bound", + ); + } +} + +fn decode_fields(bytes: &[u8]) -> Vec<&[u8]> { + field_payload_ranges(bytes) + .into_iter() + .map(|range| &bytes[range]) + .collect() +} + +fn field_payload_ranges(bytes: &[u8]) -> Vec> { + let mut fields = Vec::new(); + let mut cursor = 0; + while cursor < bytes.len() { + let length_end = cursor + .checked_add(8) + .expect("length-prefix offset overflows usize"); + assert!( + length_end <= bytes.len(), + "truncated length prefix at offset {cursor}", + ); + let length = usize::try_from(u64::from_be_bytes( + bytes[cursor..length_end].try_into().unwrap(), + )) + .expect("field length exceeds platform usize"); + let start = length_end; + let end = start + .checked_add(length) + .expect("field payload end overflows usize"); + assert!( + end <= bytes.len(), + "field payload at offset {start} exceeds the buffer", + ); + fields.push(start..end); + cursor = end; + } + assert_eq!(cursor, bytes.len()); + fields +} + +fn hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut encoded = String::with_capacity(bytes.len() * 2); + for byte in bytes { + encoded.push(char::from(DIGITS[usize::from(byte >> 4)])); + encoded.push(char::from(DIGITS[usize::from(byte & 0x0f)])); + } + encoded +} diff --git a/crates/labcolors-core/src/generic_boundary_tests.rs b/crates/labcolors-core/src/generic_boundary_tests.rs index ce1e4a67..843a6d8d 100644 --- a/crates/labcolors-core/src/generic_boundary_tests.rs +++ b/crates/labcolors-core/src/generic_boundary_tests.rs @@ -7,6 +7,7 @@ mod source_scanner; const APPEARANCE_SOURCE: &str = include_str!("appearance.rs"); const CLEAN_SET_SOURCE: &str = include_str!("clean_set.rs"); const CONSTRAINTS_SOURCE: &str = include_str!("constraints/mod.rs"); +const CONTEXTUAL_REGION_SOURCE: &str = include_str!("contextual_region.rs"); const EXACT_CONSTRAINT_SOURCE: &str = include_str!("constraints/exact.rs"); const FAMILY_CONSTRAINT_SOURCE: &str = include_str!("constraints/family.rs"); const FAMILY_SOURCE: &str = include_str!("family.rs"); @@ -25,10 +26,11 @@ const RELATION_SOURCE: &str = include_str!("relation.rs"); const SESSION_SOURCE: &str = include_str!("session.rs"); const WCAG22_CONSTRAINT_SOURCE: &str = include_str!("constraints/wcag22.rs"); -const GENERIC_SOURCES: [(&str, &str); 9] = [ +const GENERIC_SOURCES: [(&str, &str); 10] = [ ("appearance.rs", APPEARANCE_SOURCE), ("constraints/family.rs", FAMILY_CONSTRAINT_SOURCE), ("constraints/relation.rs", RELATION_CONSTRAINT_SOURCE), + ("contextual_region.rs", CONTEXTUAL_REGION_SOURCE), ("family.rs", FAMILY_SOURCE), ("lcs_occurrence.rs", LCS_OCCURRENCE_SOURCE), ("program/attachment.rs", PROGRAM_ATTACHMENT_SOURCE), @@ -279,6 +281,7 @@ fn generic_source_inventory_covers_relation_topology_and_evaluators() { "constraints/family.rs", "relation.rs", "constraints/relation.rs", + "contextual_region.rs", ] { assert!( GENERIC_SOURCES.iter().any(|(path, _)| *path == required), @@ -287,6 +290,45 @@ fn generic_source_inventory_covers_relation_topology_and_evaluators() { } } +#[test] +fn contextual_region_is_definition_only_and_semantically_agnostic() { + let production = normalized_production_code(CONTEXTUAL_REGION_SOURCE); + let compact = compact_production_syntax(CONTEXTUAL_REGION_SOURCE).to_ascii_lowercase(); + for forbidden in [ + "familyid", + "semanticfamilyreleaseidv2", + "canonicalfamilyimagedigestv2", + "familyartifact", + "familymembership", + "familyimagecertificate", + "colorsignal", + "hue", + "neutral", + "clean", + ] { + assert!( + !production.contains(forbidden), + "contextual_region.rs must remain a generic definition provider; found `{forbidden}`", + ); + } + // Это узкий syntax-guard, а не доказательство отсутствия любого ветвления: + // он не даёт незаметно вернуть прежнюю center-conditioned рецептуру, пока + // round-trip/property tests отдельно доказывают, что центр остаётся данными. + for explicit_recipe_syntax in [ + "if center", + "match center", + "if knot.center", + "match knot.center", + ] { + assert!( + !production.contains(explicit_recipe_syntax), + "direct center-conditioned recipe syntax is forbidden; found `{explicit_recipe_syntax}`", + ); + } + assert!(compact.contains("->familydefinitiondigestv2")); + assert!(compact.contains("familydefinitiondigestv2::from_digest")); +} + #[test] fn generic_physical_and_transport_modules_contain_no_client_or_legacy_vocabulary() { for (path, source) in GENERIC_SOURCES { @@ -1878,6 +1920,7 @@ fn existing_encoded_evaluators_delegate_program_targets_without_parallel_formula #[test] fn staged_program_and_lcs_occurrence_modules_remain_private() { for required in [ + "pub(crate) mod contextual_region;", "pub(crate) mod lcs_occurrence;", "pub(crate) mod program;", "pub(crate) mod program_session;", @@ -1888,6 +1931,7 @@ fn staged_program_and_lcs_occurrence_modules_remain_private() { ); } for forbidden in [ + "pub mod contextual_region;", "pub mod lcs_occurrence;", "pub mod program;", "pub mod program_session;", diff --git a/crates/labcolors-core/src/lib.rs b/crates/labcolors-core/src/lib.rs index 5eaf6b09..df65cb19 100644 --- a/crates/labcolors-core/src/lib.rs +++ b/crates/labcolors-core/src/lib.rs @@ -9,6 +9,14 @@ pub mod wcag22_evidence; pub(crate) mod clean_set; pub(crate) mod composition; +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "the contextual family definition is staged before its offline proof kernel" + ) +)] +pub(crate) mod contextual_region; mod family; mod family_artifact; pub(crate) mod spaces; @@ -177,6 +185,12 @@ mod family_artifact_tests; #[cfg(test)] mod clean_set_tests; +#[cfg(test)] +mod contextual_region_tests; + +#[cfg(test)] +mod contextual_region_formula_tests; + #[cfg(test)] mod wcag22_tests; diff --git a/crates/labcolors-core/src/spaces/cam16.rs b/crates/labcolors-core/src/spaces/cam16.rs index 54d5aace..52aba318 100644 --- a/crates/labcolors-core/src/spaces/cam16.rs +++ b/crates/labcolors-core/src/spaces/cam16.rs @@ -27,9 +27,55 @@ use crate::spaces::vc::ViewingConditions; const RESPONSE_SCALE: f64 = 400.0; const RESPONSE_OFFSET: f64 = 27.13; const RESPONSE_EXPONENT: f64 = 0.42; +const ONE: f64 = 1.0; const ACHROMATIC_RED_WEIGHT: f64 = 2.0; const ACHROMATIC_BLUE_DIVISOR: f64 = 20.0; const ACHROMATIC_WEIGHTS: [f64; 3] = [ACHROMATIC_RED_WEIGHT, 1.0, 1.0 / ACHROMATIC_BLUE_DIVISOR]; +const OPPONENT_A_M_WEIGHT: f64 = 12.0; +const OPPONENT_A_DIVISOR: f64 = 11.0; +const OPPONENT_B_DIVISOR: f64 = 9.0; +const ECCENTRICITY_SCALE: f64 = 0.25; +const ECCENTRICITY_PHASE_RADIANS: f64 = 2.0; +const ECCENTRICITY_OFFSET: f64 = 3.8; +const T_NUMERATOR_SCALE: f64 = 50_000.0; +const T_NUMERATOR_DIVISOR: f64 = 13.0; +const T_BLUE_WEIGHT: f64 = 1.05; +const T_DENOMINATOR_OFFSET: f64 = 0.305; +const COLORFULNESS_EXPONENT: f64 = 0.9; +const HUNDRED: f64 = 100.0; +const UCS_J_SCALE: f64 = 1.7; +const UCS_J_CURVATURE: f64 = 0.007; +const UCS_M_CURVATURE: f64 = 0.0228; + +/// Точные числовые владельцы, используемые artifact-ом contextual-region. +/// +/// Test-only view открывает константы, реально используемые production-математикой: +/// он не дублирует их значения и не добавляет данные или ветви в release-сборку. +#[cfg(test)] +pub(crate) fn contextual_region_formula_literals_v1() -> &'static [(&'static str, f64)] { + &[ + ("one", ONE), + ("p0_007", UCS_J_CURVATURE), + ("p0_0228", UCS_M_CURVATURE), + ("p0_25", ECCENTRICITY_SCALE), + ("p0_305", T_DENOMINATOR_OFFSET), + ("p0_42", RESPONSE_EXPONENT), + ("p0_9", COLORFULNESS_EXPONENT), + ("p1_05", T_BLUE_WEIGHT), + ("p1_7", UCS_J_SCALE), + ("two", ACHROMATIC_RED_WEIGHT), + ("p3_8", ECCENTRICITY_OFFSET), + ("nine", OPPONENT_B_DIVISOR), + ("eleven", OPPONENT_A_DIVISOR), + ("twelve", OPPONENT_A_M_WEIGHT), + ("thirteen", T_NUMERATOR_DIVISOR), + ("twenty", ACHROMATIC_BLUE_DIVISOR), + ("hundred", HUNDRED), + ("p27_13", RESPONSE_OFFSET), + ("four_hundred", RESPONSE_SCALE), + ("fifty_thousand", T_NUMERATOR_SCALE), + ] +} #[cfg(test)] thread_local! { @@ -49,7 +95,7 @@ thread_local! { /// compression), later formalised in CIE 248:2022. (Not CIE 170-2:2015, which /// is the cone-fundamental standard and does not specify CAM16.) pub(crate) fn adapt(c: f64, fl: f64) -> f64 { - let x = fl * c.abs() / 100.0; + let x = fl * c.abs() / HUNDRED; let y = x.powf(RESPONSE_EXPONENT); // Магнитуда `400·y/(y+27.13)` ≥ 0 (y ≥ 0), а знак берётся у входа `c`. Раньше // это делалось умножением на `c.signum()` (∈ {−1, +1} на конечных входах); @@ -75,7 +121,7 @@ pub(crate) fn unadapt(a: f64, fl: f64) -> f64 { // магнитуда 0.0 → copysign(0.0, ±0.0)=±0.0). Свип 0/6.86M расхождений; вход `a` // (инверсия конечного J'/M') на этом пути всегда конечен. Убирает ветвящийся // `signum` и лишнее умножение из пер-цветового обратного хода (`to_xyz`). - (100.0 * y.powf(1.0 / RESPONSE_EXPONENT) / fl).copysign(a) + (HUNDRED * y.powf(ONE / RESPONSE_EXPONENT) / fl).copysign(a) } /// CAM16 lightness `J` of a D65-axis stimulus with relative luminance `y`. @@ -107,9 +153,9 @@ pub(crate) fn gray_y_analytic(j: f64, vc: &ViewingConditions) -> f64 { } let rgb_w = cat16::xyz_to_cone([ - D65_WHITE[0] * 100.0, - D65_WHITE[1] * 100.0, - D65_WHITE[2] * 100.0, + D65_WHITE[0] * HUNDRED, + D65_WHITE[1] * HUNDRED, + D65_WHITE[2] * HUNDRED, ]); let k = [ rgb_w[0] * vc.rgb_d[0], @@ -118,7 +164,7 @@ pub(crate) fn gray_y_analytic(j: f64, vc: &ViewingConditions) -> f64 { ]; let w_sum = ACHROMATIC_WEIGHTS[0] + ACHROMATIC_WEIGHTS[1] + ACHROMATIC_WEIGHTS[2]; - let target = vc.aw * (j / 100.0).powf(1.0 / (vc.c * vc.z)) / vc.nbb; + let target = vc.aw * (j / HUNDRED).powf(ONE / (vc.c * vc.z)) / vc.nbb; let s = target / w_sum; if s <= 0.0 { return 0.0; @@ -132,14 +178,14 @@ pub(crate) fn gray_y_analytic(j: f64, vc: &ViewingConditions) -> f64 { + ACHROMATIC_WEIGHTS[2] * k[2]) / w_sum; let p = RESPONSE_OFFSET * s / (RESPONSE_SCALE - s); - let mut y = p.powf(1.0 / RESPONSE_EXPONENT) * 100.0 / (vc.fl * k_eff); + let mut y = p.powf(ONE / RESPONSE_EXPONENT) * HUNDRED / (vc.fl * k_eff); let residual_slope = |y: f64| -> (f64, f64) { let mut f = 0.0; let mut df = 0.0; for i in 0..3 { let c = k[i] * y; - let x = vc.fl * c / 100.0; + let x = vc.fl * c / HUNDRED; let pp = x.powf(RESPONSE_EXPONENT); let denominator = pp + RESPONSE_OFFSET; f += ACHROMATIC_WEIGHTS[i] * RESPONSE_SCALE * pp / denominator; @@ -154,7 +200,7 @@ pub(crate) fn gray_y_analytic(j: f64, vc: &ViewingConditions) -> f64 { y -= error / slope; } - y.clamp(0.0, 1.0) + y.clamp(0.0, ONE) } /// Fixed-iteration oracle for [`gray_y_analytic`]. @@ -343,7 +389,7 @@ pub(crate) fn forward_correlates_v1(xyz: [f64; 3], vc: &ViewingConditions) -> Ca /// The CIECAM16 forward math itself (cache-free); see [`forward`]. fn forward_compute(xyz: [f64; 3], vc: &ViewingConditions) -> (f64, f64, f64) { - let xyz = [xyz[0] * 100.0, xyz[1] * 100.0, xyz[2] * 100.0]; + let xyz = [xyz[0] * HUNDRED, xyz[1] * HUNDRED, xyz[2] * HUNDRED]; let lms = cat16::xyz_to_cone(xyz); let lms_a = [ @@ -357,8 +403,9 @@ fn forward_compute(xyz: [f64; 3], vc: &ViewingConditions) -> (f64, f64, f64) { adapt(lms_a[2], vc.fl), ]; - let a = lms_aa[0] - 12.0 * lms_aa[1] / 11.0 + lms_aa[2] / 11.0; - let b = (lms_aa[0] + lms_aa[1] - 2.0 * lms_aa[2]) / 9.0; + let a = lms_aa[0] - OPPONENT_A_M_WEIGHT * lms_aa[1] / OPPONENT_A_DIVISOR + + lms_aa[2] / OPPONENT_A_DIVISOR; + let b = (lms_aa[0] + lms_aa[1] - ACHROMATIC_RED_WEIGHT * lms_aa[2]) / OPPONENT_B_DIVISOR; // `atan2` даёт угол в (−π, π], `to_degrees()` → (−180, 180]. На этом диапазоне // `rem_euclid(360)` тождественно сводится к одной условной прибавке 360: для // deg < 0 это deg + 360 (floor(deg/360) = −1), иначе deg без изменений. Замена @@ -370,23 +417,24 @@ fn forward_compute(xyz: [f64; 3], vc: &ViewingConditions) -> (f64, f64, f64) { let h = if deg < 0.0 { deg + 360.0 } else { deg }; let hr = h.to_radians(); - let e_hue = 0.25 * ((hr + 2.0).cos() + 3.8); + let e_hue = + ECCENTRICITY_SCALE * ((hr + ECCENTRICITY_PHASE_RADIANS).cos() + ECCENTRICITY_OFFSET); let a_achrom = (ACHROMATIC_RED_WEIGHT * lms_aa[0] + lms_aa[1] + lms_aa[2] / ACHROMATIC_BLUE_DIVISOR) * vc.nbb; - let j = 100.0 * (a_achrom / vc.aw).powf(vc.c * vc.z); + let j = HUNDRED * (a_achrom / vc.aw).powf(vc.c * vc.z); let u = (a * a + b * b).sqrt(); // N_c · N_cb per the CAM16 `t` equation; N_cb = N_bb by construction (vc.rs // sets `ncb: nbb`), so `vc.ncb` is byte-identical to the prior `vc.nbb`. - let t = (50000.0 / 13.0) * e_hue * vc.nc * vc.ncb * u - / (lms_aa[0] + lms_aa[1] + 1.05 * lms_aa[2] + 0.305); + let t = (T_NUMERATOR_SCALE / T_NUMERATOR_DIVISOR) * e_hue * vc.nc * vc.ncb * u + / (lms_aa[0] + lms_aa[1] + T_BLUE_WEIGHT * lms_aa[2] + T_DENOMINATOR_OFFSET); // `vc.t_inner` == `(1.64 - 0.29^n)^0.73`, `vc.fl_pow_025` == `fl^0.25` — обе // вынесены в `ViewingConditions::build` (пер-VC константы, считаются раз на // резолв, а не на каждый цвет). Те же операнды, тот же порядок умножения // слева-направо → байт-идентично инлайн-форме, которую оракул ниже // (`forward_reference`) по-прежнему расписывает явно. - let m = t.powf(0.9) * (j / 100.0).sqrt() * vc.t_inner * vc.fl_pow_025; + let m = t.powf(COLORFULNESS_EXPONENT) * (j / HUNDRED).sqrt() * vc.t_inner * vc.fl_pow_025; (j, m, h) } @@ -409,22 +457,22 @@ fn forward_compute(xyz: [f64; 3], vc: &ViewingConditions) -> (f64, f64, f64) { /// CAM16-UCS lightness rescale `J → J'`. pub(crate) fn ucs_j(j: f64) -> f64 { - 1.7 * j / (1.0 + 0.007 * j) + UCS_J_SCALE * j / (ONE + UCS_J_CURVATURE * j) } /// Inverse CAM16-UCS lightness rescale `J' → J`. pub(crate) fn ucs_j_inv(jp: f64) -> f64 { - jp / (1.7 - 0.007 * jp) + jp / (UCS_J_SCALE - UCS_J_CURVATURE * jp) } /// CAM16-UCS colourfulness rescale `M → M'`. pub(crate) fn ucs_m(m: f64) -> f64 { - (1.0 + 0.0228 * m).ln() / 0.0228 + (ONE + UCS_M_CURVATURE * m).ln() / UCS_M_CURVATURE } /// Inverse CAM16-UCS colourfulness rescale `M' → M`. pub(crate) fn ucs_m_inv(mp: f64) -> f64 { - (0.0228 * mp).exp_m1() / 0.0228 + (UCS_M_CURVATURE * mp).exp_m1() / UCS_M_CURVATURE } #[cfg(test)] diff --git a/crates/labcolors-core/src/spaces/cat16.rs b/crates/labcolors-core/src/spaces/cat16.rs index a96f5c4a..07b09bd9 100644 --- a/crates/labcolors-core/src/spaces/cat16.rs +++ b/crates/labcolors-core/src/spaces/cat16.rs @@ -15,6 +15,25 @@ const XYZ_TO_CONE: [[f64; 3]; 3] = [ [-0.002079, 0.048952, 0.953127], ]; +/// Точные числовые владельцы, используемые artifact-ом contextual-region. +/// +/// Accessor существует только в тестах, а каждое возвращаемое значение — та же +/// константа, которую использует production-умножение CAT16. +#[cfg(test)] +pub(crate) fn contextual_region_formula_literals_v1() -> &'static [(&'static str, f64)] { + &[ + ("cat_m00", XYZ_TO_CONE[0][0]), + ("cat_m01", XYZ_TO_CONE[0][1]), + ("cat_m02", XYZ_TO_CONE[0][2]), + ("cat_m10", XYZ_TO_CONE[1][0]), + ("cat_m11", XYZ_TO_CONE[1][1]), + ("cat_m12", XYZ_TO_CONE[1][2]), + ("cat_m20", XYZ_TO_CONE[2][0]), + ("cat_m21", XYZ_TO_CONE[2][1]), + ("cat_m22", XYZ_TO_CONE[2][2]), + ] +} + /// Inverse CAT16: cone responses → CIE XYZ. /// /// The *printed* inverse from Li et al. 2017 (8-decimal published values), not a diff --git a/crates/labcolors-core/src/spaces/srgb.rs b/crates/labcolors-core/src/spaces/srgb.rs index c5392669..8a89bb57 100644 --- a/crates/labcolors-core/src/spaces/srgb.rs +++ b/crates/labcolors-core/src/spaces/srgb.rs @@ -36,6 +36,28 @@ const SRGB_TO_XYZ_D65: [[f64; 3]; 3] = [ [ 0.019_330_818_715_591_82, 0.119_194_779_794_625_98, 0.950_532_152_249_660_7 ], ]; +/// Точные числовые владельцы, используемые artifact-ом contextual-region. +/// +/// Test-only доступ связывает artifact с production-константами преобразования, +/// не добавляя символов или работы в release-сборку. +#[cfg(test)] +pub(crate) fn contextual_region_formula_literals_v1() -> &'static [(&'static str, f64)] { + &[ + ("d65_x", D65_WHITE[0]), + ("one", D65_WHITE[1]), + ("d65_z", D65_WHITE[2]), + ("srgb_m00", SRGB_TO_XYZ_D65[0][0]), + ("srgb_m01", SRGB_TO_XYZ_D65[0][1]), + ("srgb_m02", SRGB_TO_XYZ_D65[0][2]), + ("srgb_m10", SRGB_TO_XYZ_D65[1][0]), + ("srgb_m11", SRGB_TO_XYZ_D65[1][1]), + ("srgb_m12", SRGB_TO_XYZ_D65[1][2]), + ("srgb_m20", SRGB_TO_XYZ_D65[2][0]), + ("srgb_m21", SRGB_TO_XYZ_D65[2][1]), + ("srgb_m22", SRGB_TO_XYZ_D65[2][2]), + ] +} + // ------------------------------------------------------------------ // XYZ(D65) → sRGB linear // ------------------------------------------------------------------ diff --git a/crates/labcolors-core/src/spaces/vc.rs b/crates/labcolors-core/src/spaces/vc.rs index 5bf159ed..cc738242 100644 --- a/crates/labcolors-core/src/spaces/vc.rs +++ b/crates/labcolors-core/src/spaces/vc.rs @@ -17,6 +17,63 @@ use std::sync::OnceLock; use super::{cam16::adapt, cat16::xyz_to_cone}; +// Именованные коэффициенты модели общие для production-математики и test-only +// exact-real binding ниже. Константы компилируются без runtime-цены, а имена не +// позволяют копии формулы получить второй независимо изменяемый набор чисел. +const ZERO: f64 = 0.0; +const ONE: f64 = 1.0; +const TWO: f64 = 2.0; +const FIVE: f64 = 5.0; +const TWENTY: f64 = 20.0; +const FORTY_TWO: f64 = 42.0; +const NINETY_TWO: f64 = 92.0; +const HUNDRED: f64 = 100.0; +const FL_INDIRECT_SCALE: f64 = 0.1; +const NBB_EXPONENT_MAGNITUDE: f64 = 0.2; +const FL_QUARTER_EXPONENT: f64 = 0.25; +const T_INNER_POWER_BASE: f64 = 0.29; +const SURROUND_DARK_C: f64 = 0.525; +const SURROUND_DIM_C: f64 = 0.59; +const SURROUND_AVERAGE_C: f64 = 0.69; +const NBB_SCALE: f64 = 0.725; +const T_INNER_EXPONENT: f64 = 0.73; +const SURROUND_DARK_F_NC: f64 = 0.8; +const SURROUND_DIM_F_NC: f64 = 0.9; +const Z_OFFSET: f64 = 1.48; +const T_INNER_OFFSET: f64 = 1.64; +const ADAPTATION_DECAY_DIVISOR: f64 = 3.6; + +/// Точные числовые владельцы, используемые artifact-ом contextual-region. +/// +/// Повторяющиеся имена намеренно сверяют общие с CAM16 коэффициенты: потребитель +/// registry отвергает владельцев, которые разошлись хотя бы одним битом. +#[cfg(test)] +pub(crate) fn contextual_region_formula_literals_v1() -> &'static [(&'static str, f64)] { + &[ + ("one", ONE), + ("p0_1", FL_INDIRECT_SCALE), + ("p0_2", NBB_EXPONENT_MAGNITUDE), + ("p0_25", FL_QUARTER_EXPONENT), + ("p0_29", T_INNER_POWER_BASE), + ("p0_525", SURROUND_DARK_C), + ("p0_59", SURROUND_DIM_C), + ("p0_69", SURROUND_AVERAGE_C), + ("p0_725", NBB_SCALE), + ("p0_73", T_INNER_EXPONENT), + ("p0_8", SURROUND_DARK_F_NC), + ("p0_9", SURROUND_DIM_F_NC), + ("p1_48", Z_OFFSET), + ("p1_64", T_INNER_OFFSET), + ("two", TWO), + ("p3_6", ADAPTATION_DECAY_DIVISOR), + ("five", FIVE), + ("twenty", TWENTY), + ("forty_two", FORTY_TWO), + ("ninety_two", NINETY_TWO), + ("hundred", HUNDRED), + ] +} + /// Closed CIECAM16 surround tuple admitted by the F0 occurrence context. /// /// Keeping the triplets behind variants prevents callers from independently @@ -98,13 +155,13 @@ impl ViewingConditions { surround: Cam16SurroundV1, ) -> Self { let (f, c, nc) = match surround { - Cam16SurroundV1::Average => (1.0, 0.69, 1.0), - Cam16SurroundV1::Dim => (0.9, 0.59, 0.9), - Cam16SurroundV1::Dark => (0.8, 0.525, 0.8), + Cam16SurroundV1::Average => (ONE, SURROUND_AVERAGE_C, ONE), + Cam16SurroundV1::Dim => (SURROUND_DIM_F_NC, SURROUND_DIM_C, SURROUND_DIM_F_NC), + Cam16SurroundV1::Dark => (SURROUND_DARK_F_NC, SURROUND_DARK_C, SURROUND_DARK_F_NC), }; Self::build( adapting_luminance_cd_m2, - background_luminance_ratio * 100.0, + background_luminance_ratio * HUNDRED, f, c, nc, @@ -172,7 +229,7 @@ impl ViewingConditions { /// colour-science in `golden_tests`. pub fn srgb() -> Self { // colour-science / colorjs.io surroundMap["average"] = [1.0, 0.69, 1.0] - Self::build(64.0, 20.0, 1.0, 0.69, 1.0) + Self::build(64.0, TWENTY, ONE, SURROUND_AVERAGE_C, ONE) } /// Standard sRGB viewing conditions with Increased Contrast (IC). @@ -208,7 +265,13 @@ impl ViewingConditions { /// ``` pub fn dim_surround() -> Self { // colour-science / colorjs.io surroundMap["dim"] = [0.9, 0.59, 0.9] - Self::build(64.0, 20.0, 0.9, 0.59, 0.9) + Self::build( + 64.0, + TWENTY, + SURROUND_DIM_F_NC, + SURROUND_DIM_C, + SURROUND_DIM_F_NC, + ) } /// Dim surround viewing conditions with Increased Contrast (IC). @@ -223,7 +286,13 @@ impl ViewingConditions { /// grey-axis LUT's fall-back-to-bisection path for an unsupported VC. #[cfg(test)] pub(crate) fn dark_surround() -> Self { - Self::build(64.0, 20.0, 0.8, 0.525, 0.8) + Self::build( + 64.0, + TWENTY, + SURROUND_DARK_F_NC, + SURROUND_DARK_C, + SURROUND_DARK_F_NC, + ) } /// Core constructor shared by all surround presets. @@ -234,25 +303,27 @@ impl ViewingConditions { /// * `c` — chromatic adaptation induction factor from surround table. /// * `nc` — chromatic induction factor from surround table. fn build(la: f64, y_b: f64, f: f64, c: f64, nc: f64) -> Self { - let k = 1.0_f64 / (5.0 * la + 1.0); + let k = ONE / (FIVE * la + ONE); let k4 = k * k * k * k; - let fl = k4 * la + 0.1_f64 * (1.0 - k4).powi(2) * (5.0 * la).cbrt(); + let fl = k4 * la + FL_INDIRECT_SCALE * (ONE - k4).powi(2) * (FIVE * la).cbrt(); - let n = y_b / 100.0_f64; - let nbb = 0.725_f64 * n.powf(-0.2); - let z = 1.48_f64 + n.sqrt(); + let n = y_b / HUNDRED; + let nbb = NBB_SCALE * n.powf(-NBB_EXPONENT_MAGNITUDE); + let z = Z_OFFSET + n.sqrt(); let xyz_w = [ - D65_WHITE[0] * 100.0, - D65_WHITE[1] * 100.0, - D65_WHITE[2] * 100.0, + D65_WHITE[0] * HUNDRED, + D65_WHITE[1] * HUNDRED, + D65_WHITE[2] * HUNDRED, ]; let rgb_w = xyz_to_cone(xyz_w); - let d = (f * (1.0 - (1.0 / 3.6) * ((-la - 42.0) / 92.0).exp())).clamp(0.0, 1.0); + let d = (f + * (ONE - (ONE / ADAPTATION_DECAY_DIVISOR) * ((-la - FORTY_TWO) / NINETY_TWO).exp())) + .clamp(ZERO, ONE); let rgb_d = [ - d * (100.0 / rgb_w[0]) + 1.0 - d, - d * (100.0 / rgb_w[1]) + 1.0 - d, - d * (100.0 / rgb_w[2]) + 1.0 - d, + d * (HUNDRED / rgb_w[0]) + ONE - d, + d * (HUNDRED / rgb_w[1]) + ONE - d, + d * (HUNDRED / rgb_w[2]) + ONE - d, ]; let rgb_w_adapted = [ @@ -265,7 +336,7 @@ impl ViewingConditions { adapt(rgb_w_adapted[1], fl), adapt(rgb_w_adapted[2], fl), ]; - let aw = (2.0 * rgb_aw[0] + rgb_aw[1] + rgb_aw[2] / 20.0) * nbb; + let aw = (TWO * rgb_aw[0] + rgb_aw[1] + rgb_aw[2] / TWENTY) * nbb; // Пер-VC константы колорфулнесс, вынесенные из пер-цветового прямого и // обратного хода. Считаются здесь ровно на тех операндах, что использовали @@ -273,8 +344,8 @@ impl ViewingConditions { // это устранение общего подвыражения (CSE), а не численное изменение // (пинится `derived_constants_are_bit_identical_to_inline_recompute` и, // ниже по потоку, bit-identity оракулом `cam16::forward`). - let fl_pow_025 = fl.powf(0.25); - let t_inner = (1.64 - 0.29_f64.powf(n)).powf(0.73); + let fl_pow_025 = fl.powf(FL_QUARTER_EXPONENT); + let t_inner = (T_INNER_OFFSET - T_INNER_POWER_BASE.powf(n)).powf(T_INNER_EXPONENT); Self { n, diff --git a/scripts/verify_point_support_surplus.py b/scripts/verify_point_support_surplus.py index 61d3e1d6..8408275b 100755 --- a/scripts/verify_point_support_surplus.py +++ b/scripts/verify_point_support_surplus.py @@ -58,7 +58,7 @@ SOURCE_BINDING_LAW = "point-support-rust-whole-file-semantic-cone-v2" SOURCE_BINDING_DOMAIN = b"labcolors.point-support.rust-whole-file-semantic-cone.v2" EXPECTED_SOURCE_CAPSULE_SHA256 = ( - "faef3711ffbfeb823e7187996d2f2a06171dea273fab2cd0aa89a56d6fefc95d" + "8c6688559f108204423e9efe364dc8a108d203be07260077912b37a0b8811803" ) EXPECTED_Q55_PROOF_SHA256 = ( "ac59cf89503170c789223b91d775213a19d4e571ef930f2ea609fcd51b14defd"