Skip to content
Closed
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
459650b
scientific finite color core
Jul 9, 2026
ec7a041
Merge 459650bfef08a30f895d98d12ddecb6575d8e6b8 into c767ce427f71875c1…
lemone112 Jul 9, 2026
83ddf4c
Merge ec7a041e061de312332f22979f9217fad014c880 into c8c10c6d48062b403…
lemone112 Jul 9, 2026
edd0d38
ci(docs-drift): учесть cleanliness-v2 в инвентаре (#226)
lemone112 Jul 9, 2026
bcc57d9
test(neutral): expose inverse-wall state loss (#226)
lemone112 Jul 10, 2026
4d3d526
test(neutral): довести RED до алгоритмического дефекта (#226)
lemone112 Jul 10, 2026
dba6615
revert(test): не канонизировать libm-артефакт как sRGB8-состояние (#226)
lemone112 Jul 10, 2026
459bce0
Merge dba6615c032ac019bc66c305208cd2150eba7d33 into 7981a1ef9487cd5ee…
lemone112 Jul 10, 2026
baeb022
test(neutral): restore live-quantizer counterexample (#226)
lemone112 Jul 10, 2026
9c4a4ed
fix(neutral): enumerate transitions through the live sRGB8 quantizer …
lemone112 Jul 10, 2026
7e53eee
ci(temp): capture rustfmt patch for autonomous repair
lemone112 Jul 10, 2026
ada7cf3
ci(temp): let pinned rustfmt apply its exact patch
lemone112 Jul 10, 2026
8771b6c
style(neutral): apply pinned rustfmt 1.96
github-actions[bot] Jul 10, 2026
e5bd358
ci(temp): remove completed rustfmt probe
lemone112 Jul 10, 2026
8fd407f
test(color-quality): specify screen-native audit contract (#230 #231)
lemone112 Jul 10, 2026
fcda641
feat(color-quality): add typed screen appearance audit skeleton (#230…
lemone112 Jul 10, 2026
0d06965
refactor(cleanliness): isolate frozen legacy model from ColorQuality …
lemone112 Jul 10, 2026
80f11ab
feat(color-quality): expose typed audit without Clean/Dirty verdict (…
lemone112 Jul 10, 2026
3f3ccbd
docs(color-quality): replace Dirt verdict with screen-native audit co…
lemone112 Jul 10, 2026
39a436f
ci(temp): apply pinned rustfmt to ColorQuality slice
lemone112 Jul 10, 2026
ca3d07a
style(color-quality): apply pinned rustfmt 1.96
github-actions[bot] Jul 10, 2026
33edf92
ci(temp): remove completed ColorQuality rustfmt probe
lemone112 Jul 10, 2026
ff9f5be
ci(temp): remove stale Dirt V2 guidance from frozen V1
lemone112 Jul 10, 2026
3995d27
ci(temp): remove non-triggering legacy doc workflow
lemone112 Jul 10, 2026
8284441
ci(temp): repair stale legacy ColorQuality guidance
lemone112 Jul 10, 2026
0697b4b
ci(temp): trigger stale legacy guidance repair
lemone112 Jul 10, 2026
5effc1e
ci(temp): remove non-production legacy guidance editor
lemone112 Jul 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/fmt-probe.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: fmt-probe

on:
pull_request:
paths:
- "crates/labcolors-core/src/neutral.rs"
- "crates/labcolors-core/tests/neutral_quantization_exactness.rs"
- ".github/workflows/fmt-probe.yml"

permissions:
contents: write

env:
RUST_TOOLCHAIN: 1.96.0

jobs:
apply:
runs-on: [self-hosted, Linux, X64]
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: agent/scientific-color-core
- name: isolated toolchain environment
run: |
echo "RUSTUP_HOME=$RUNNER_TEMP/rustup-$GITHUB_JOB" >> "$GITHUB_ENV"
echo "CARGO_HOME=$RUNNER_TEMP/cargo-$GITHUB_JOB" >> "$GITHUB_ENV"
echo "TMPDIR=$RUNNER_TEMP/tmp-$GITHUB_JOB" >> "$GITHUB_ENV"
mkdir -p "$RUNNER_TEMP/tmp-$GITHUB_JOB"
- uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master @ 2026-03-27
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
components: rustfmt
- name: apply and commit exact rustfmt output
run: |
cargo fmt --all
if ! git diff --quiet; then
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add crates/labcolors-core/src/neutral.rs crates/labcolors-core/tests/neutral_quantization_exactness.rs
git commit -m "style(neutral): apply pinned rustfmt 1.96"
git push origin HEAD:agent/scientific-color-core
fi
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Компилятор цветовых систем. Как компилятор превращает исходник в детерминированный бинарь, Lab Colors превращает бренд и законы цвета в детерминированную цветовую систему — один и тот же вход всегда даёт байт-идентичный выход.

**Вход:** бренд (якорные цвета) · физика (перцептуальное пространство LCS, гамут sRGB, контраст) · психофизика (эффект Гельмгольца-Кольрауша, перцептуальная светлота, адаптация зрения к окружению, категориальные зоны хью у сентиментов).
**Вход:** бренд и клиентские anchors · физика (перцептуальное пространство LCS, гамут sRGB, контраст) · психофизика (эффект Гельмгольца-Кольрауша, перцептуальная светлота, адаптация зрения к окружению). Сентименты проверяются по фактической попарной геометрии anchors, без универсальных hue-зон.

**Выход:** цветовая система целиком — таблица именованных ролей, темы, контраст с юридическим полом WCAG 2.1 AA.

Expand Down Expand Up @@ -207,7 +207,7 @@ let config = ThemeConfig::new(
edge: None, inverted: None,
},
vec![],
SentimentsConfig { categories: vec![], hardness: 5.0, chroma_fraction: 0.88 },
SentimentsConfig { categories: vec![] },
ThemesConfig { entries: vec![("light".into(), VcPreset::Srgb)] },
vec![(
"label-primary".into(),
Expand Down Expand Up @@ -358,6 +358,7 @@ crates/
packages/
└── colors — npm-пакет @labpics/colors поверх WASM
docs/
├── cleanliness-v2.md — научный контракт чистоты, область применимости и фальсифицируемость
├── whitepaper.md, empirical-inventory.md, decisions/, conformance/, …
└── palette.svg
```
Expand Down Expand Up @@ -411,7 +412,8 @@ CI на каждом PR — пять джобов на Rust 1.96.0:
- **lcs** — roundtrip под обеими VC, дрейф при чужой VC
- **neutral** — монотонность J', непрерывность кривой, точность якорей, chroma envelope, hue-дрейф, dim-тема
- **scale** — акцентная кривая: монотонность, попадание в гамут, dim-тема
- **sentiment** — покой сентимента на фокусе своей категориальной зоны
- **sentiment** — формула неравных радиусов, независимость от порядка, отказ на
недостижимой паре и финальная проверка всех попарных anchor-дистанций
- **lpc** — полярность контраста, H-K-буст со сверкой против Hellwig 2022
- **golden_tests** — кросс-валидация CIECAM16 против colour-science с задокументированными допусками
- **spaces** (oklab, vc) — Oklab hue и roundtrip, параметры окружения
Expand Down
8 changes: 4 additions & 4 deletions crates/labcolors-conformance/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
//! | `ladders.json` | позиция лестницы → (α_light, α_dark) | `LadderPosition::alpha_pair` |
//! | `alpha.json` | подложка→α: композит и α_min | `alpha::composite_hex` / `alpha::min_alpha_hex` |
//! | `solve.json` | (bg, контракт, тема) → резолв или честный отказ | `solve` |
//! | `muddiness.json` | hex → мутность | `cleanliness::muddiness_from_hex` |
//! | `muddiness.json` | замороженная характеристика Закона Грязи V1 | `cleanliness::muddiness_from_hex` |
//! | `manifest.json` | версии пака/ядра, дайджест, счётчики | — |
//!
//! Версия пака ([`PACK_VERSION`]) привязана к версии ядра ([`core_version`]):
Expand Down Expand Up @@ -384,17 +384,17 @@ pub fn generate_solve() -> Vec<SolveVector> {
// Семейство: мутность
// ─────────────────────────────────────────────────────────────────────────────

/// Один вектор мутности: hex и его оценка «грязи» `[0,1]`.
/// Один замороженный вектор воспроизводимости исторической модели V1.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MuddinessVector {
/// Цвет, `#RRGGBB`.
pub hex: String,
/// Оценка мутности `[0,1]` (0 — чистый, 1 — грязный).
/// Историческое значение V1 `[0,1]`; не вероятность наблюдателя.
pub score: f64,
}

/// Цвета для мутности: грязная олива, чистый серый, чистый бренд, грязный хаки.
/// Исторические стимулы V1; названия классов не являются научным оракулом.
const MUDDINESS_CASES: [&str; 4] = ["#6B6B2E", "#808080", "#007AFF", "#8A7A50"];

/// Дериватор векторов мутности.
Expand Down
2 changes: 1 addition & 1 deletion crates/labcolors-core/src/accent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ mod tests {
(Accent::Red, "#FF3B30", "red", 28.6592),
(Accent::Orange, "#FFA100", "orange", 68.6070),
(Accent::Yellow, "#FFD000", "yellow", 92.2265),
(Accent::Green, "#34C759", "green", 147.4439),
(Accent::Green, "#34C759", "green", 147.4440),
(Accent::Teal, "#5AC8FA", "teal", 230.8271),
(Accent::Mint, "#00C7BE", "mint", 189.0284),
(Accent::Blue, "#3E87FF", "blue", 259.8918),
Expand Down
103 changes: 44 additions & 59 deletions crates/labcolors-core/src/accent_balance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,25 +12,23 @@
//!
//! Разрешение конфликта одно: найти светлоту `L`, удовлетворяющую ФУНКЦИИ, и на
//! ней взять **МАКСИМАЛЬНУЮ** хрому оттенка (`max_chroma` — стена гамута).
//! Тогда идентичность держится ровно так сильно, как позволяет гамут на нужной
//! светлоте, а не срезается произвольной долей. Если даже стена гамута на этой
//! `L` оставляет красочность ниже перцептивного пола — оттенок физически
//! вырожден, и это объявляется ЧЕСТНЫМ флагом [`BalancedAccent::hue_vanished`],
//! не молчанием.
//! Тогда этот примитив отвечает на узкий геометрический вопрос: какой максимум
//! данного направления допускает sRGB на требуемой светлоте. Он не утверждает,
//! что максимум красивее или лучше сохраняет конкретный клиентский якорь.
//! Потеря оттенка фиксируется только как точное состояние выходной решётки:
//! эмитированный `#RRGGBB` стал ахроматическим (`R = G = B`). Перцептивный порог
//! без эксперимента с наблюдателями здесь не выдумывается.
//!
//! # Строгая агностичность (frozen northInvariant)
//!
//! Ноль labui-имён ролей, ноль хардкод-оттенков, ноль резерваций
//! (сентимент→оттенок): целевая светлота, оттенок и тема приходят ПАРАМЕТРАМИ.
//! Порог вырождения — НЕ новая ручка, а уже заземлённый перцептивный пол крейта
//! `TINT_PERCEPTIBLE_MP_FLOOR` (`M' = 1.5`, тот же, которым
//! [`crate::semantic`] объявляет `hue_vanished` у лейблов). Провенанс нового
//! кода: единственная новая величина — этот примитив — не несёт НИ ОДНОЙ новой
//! числовой константы (переиспользует `max_chroma` и существующий пол).
//! Модуль не содержит порога «видимости оттенка»: такой порог был бы свойством
//! психофизического протокола, а не геометрии гамута. Флаг ниже выводится из
//! конечного sRGB8-результата без коэффициента и epsilon.

use crate::lcs::LcsColor;
use crate::scale::{lcs_from_oklab_lch, max_chroma};
use crate::semantic::TINT_PERCEPTIBLE_MP_FLOOR;
use crate::spaces::vc::ViewingConditions;

/// Результат применения закона баланса на требуемой светлоте.
Expand All @@ -44,13 +42,22 @@ pub struct BalancedAccent {
pub hue_deg: f64,
/// Отрисованный цвет ([`LcsColor`] под переданными `vc`).
pub color: LcsColor,
/// Честный флаг вырождения: на этой светлоте даже МАКСИМАЛЬНАЯ хрома
/// оставляет красочность `M'` ниже `TINT_PERCEPTIBLE_MP_FLOOR` — оттенок
/// физически потерян (near-white/near-black), функция задавила
/// идентичность. Вызывающий видит потерю цвета флагом, а не тихо.
/// Точный флаг вырождения на выходе: после реальной sRGB8-квантизации цвет
/// имеет одинаковые байты `R = G = B`, поэтому направления hue у
/// эмитируемого значения уже нет. Это не заявление о пороге восприятия.
pub hue_vanished: bool,
}

/// Проверяет потерю hue на том представлении, которое действительно получит
/// клиент. Сравнивать `M'` с придуманным epsilon нельзя: CAM16 может оставить
/// числовой opponent-шум у серого, тогда как равенство трёх байтов однозначно.
pub(crate) fn hue_vanished_on_srgb8(color: &LcsColor, vc: &ViewingConditions) -> bool {
let hex = color.to_hex_with_vc(vc);
let encoded = crate::spaces::srgb::srgb_encoded_from_hex(&hex)
.expect("hex, эмитированный LcsColor, обязан разбираться ядром");
encoded[0] == encoded[1] && encoded[1] == encoded[2]
}

/// Закон баланса акцента: на требуемой Oklab-светлоте `target_l_ok` взять
/// МАКСИМАЛЬНУЮ хрому оттенка `hue_deg` в гамуте sRGB.
///
Expand All @@ -65,7 +72,7 @@ pub fn accent_balanced(target_l_ok: f64, hue_deg: f64, vc: &ViewingConditions) -
// ЗАКОН: максимум хромы на требуемой светлоте (не фикс-доля источника).
let c_ok = max_chroma(target_l_ok, hue_deg);
let color = lcs_from_oklab_lch(target_l_ok, c_ok, hue_deg, vc);
let hue_vanished = color.mp() < TINT_PERCEPTIBLE_MP_FLOOR;
let hue_vanished = hue_vanished_on_srgb8(&color, vc);
BalancedAccent {
l_ok: target_l_ok,
c_ok,
Expand All @@ -78,13 +85,6 @@ pub fn accent_balanced(target_l_ok: f64, hue_deg: f64, vc: &ViewingConditions) -
#[cfg(test)]
mod tests {
use super::*;
use crate::scale::jp_to_oklab_l;

/// Функциональный пол яркости центра свечения из светлоты источника: полпути
/// к белому по J' (пересвет центра — та же деривация, что в [`crate::glow`]).
fn glow_core_l(src_jp: f64, vc: &ViewingConditions) -> f64 {
jp_to_oklab_l((src_jp + 100.0) * 0.5, vc)
}

/// ЯКОРЬ ЗАКОНА (и RED-proof primary): на ЛЮБОЙ светлоте и оттенке баланс
/// берёт РОВНО стену гамута — не долю. Мутация примитива к фикс-дельте
Expand All @@ -109,51 +109,36 @@ mod tests {
}
}

/// ГЛАВНОЕ СВОЙСТВО (RED-proof): на ФУНКЦИОНАЛЬНОМ ПОЛУ glow-центра для
/// каждого оттенка баланс держит красочность выше порога вырождения —
/// оттенок НЕ вымывается. Пол намеренно яркий (пересвет центра), это
/// маргинальный режим: под фикс-дельтой (`0.5 × max_chroma`) красочность
/// падает ниже пола и `hue_vanished` краснит; под законом (полная стена) —
/// держится. Мутация примитива к доле → тест краснит.
/// Флаг обязан быть тождественен ахроматичности эмитированного sRGB8, а не
/// зависеть от CAM16-шума или скрытого порога.
#[test]
fn hue_survives_at_glow_core_functional_floor() {
fn hue_flag_equals_emitted_byte_achromaticity() {
for vc in [ViewingConditions::srgb(), ViewingConditions::dim_surround()] {
// Умеренно-светлый источник (J'≈52) — типовой акцент; центр ≈ J'76.
let l = glow_core_l(52.0, &vc);
for h_step in 0..72 {
let h = f64::from(h_step) * 5.0;
let b = accent_balanced(l, h, &vc);
assert!(
!b.hue_vanished,
"оттенок {h}° вымылся на функциональном полу glow-центра \
(M'={:.3} < {TINT_PERCEPTIBLE_MP_FLOOR}), L={l:.4}",
b.color.mp()
);
assert!(
b.color.mp() >= TINT_PERCEPTIBLE_MP_FLOOR,
"красочность выше порога вырождения: оттенок {h}°"
);
for l_step in 0..=10 {
let l = f64::from(l_step) / 10.0;
for h_step in 0..72 {
let h = f64::from(h_step) * 5.0;
let balanced = accent_balanced(l, h, &vc);
assert_eq!(
balanced.hue_vanished,
hue_vanished_on_srgb8(&balanced.color, &vc),
"L={l}, h={h}"
);
}
}
}
}

/// ЧЕСТНОСТЬ ФЛАГА (обе стороны): у самой стены белого даже МАКСИМАЛЬНАЯ
/// хрома оставляет цвет near-white — `hue_vanished` обязан краснеть (не
/// молчать). Ловит примитив, который никогда не выставляет флаг.
/// В точках чёрного и белого радиус гамута равен нулю, поэтому выходной hue
/// отсутствует точно, без понятия «почти исчез».
#[test]
fn flag_fires_when_hue_physically_degenerates() {
fn flag_fires_at_exact_achromatic_endpoints() {
let vc = ViewingConditions::srgb();
let l = 0.9999; // у самой вершины гамута: стена хромы → 0, красочность гаснет
let mut any_vanished = false;
for h_step in 0..72 {
let h = f64::from(h_step) * 5.0;
if accent_balanced(l, h, &vc).hue_vanished {
any_vanished = true;
for l in [0.0, 1.0] {
for h_step in 0..72 {
let h = f64::from(h_step) * 5.0;
assert!(accent_balanced(l, h, &vc).hue_vanished, "L={l}, h={h}");
}
}
assert!(
any_vanished,
"у стены белого (L={l}) оттенок физически вырожден — флаг обязан краснеть"
);
}
}
Loading
Loading