API-контракт
00 / consumes → provides
CONSUMES
Compo0.Canvas
Compo0.Constraint[]
Point[]
Point.role
Point.positionN
Point.radiusShortN
Point.visualWeightBase
Point.visualWeightContextual?
PointPairGeometry[]?
ClusterProfile?
PROVIDES
PointClusterGeometry
cluster.centroidN
cluster.weightedCentroidN
cluster.radius95ShortN
cluster.spreadMeanShortN
cluster.spreadSigmaShortN
cluster.principalAxisDeg
cluster.anisotropy
cluster.localDensityDescriptor
cluster.localMetrics
PointClusterCandidate[]
DEFERRED INPUTS
point.conflict_score ← 1.09
point.local_contrast ← 1.10
grid.fit_score ← 1.06
global.negative_space_score ← future
global.balance_score ← future
global.hierarchy_score ← future
global.eye_flow_score ← future
text/image interaction metrics ← future
Исправление архитектуры: кластер больше не включает global_balance_contribution, negative_space_preservation, global_hierarchy или eye_flow в собственный score. Эти значения приходят только от владельцев соответствующих метрик.
Геометрия кластера
01 / deterministic
C
centroid
Среднее положение центров точек.
Cw
weighted centroid
Центр, взвешенный локальным visual weight.
R95
robust radius
95-й перцентиль расстояний от centroid.
σ
spread
Средний и стандартный разброс.
shortSide = min(W,H)
for each point i:
Xi = (xN_i * W, yN_i * H)
centroidPx = mean(Xi)
centroidN = (centroidPx.x/W, centroidPx.y/H)
// Weight source policy:
wi = Point.visualWeightContextual if available
else Point.visualWeightBase
weightedCentroidPx = Σ(wi * Xi) / Σ(wi)
ri = distance(Xi, centroidPx)
R95px = percentile(ri, 95)
radius95ShortN = R95px / shortSide
spreadMeanPx = mean(ri)
spreadSigmaPx = stddev(ri)
DERIVED: centroid, R95, spread и covariance следуют из уже размещённых точек. Если точки перемещены или их contextual weight изменился, модуль пересчитывает descriptor, но не принимает глобального решения сам.
Форма облака
02 / covariance shape
Covariance → principal axis
dx_i = Xi.x - C.x
dy_i = Xi.y - C.y
Sxx = mean(dx_i²)
Syy = mean(dy_i²)
Sxy = mean(dx_i*dy_i)
λ1 ≥ λ2 = eigenvalues([[Sxx,Sxy],[Sxy,Syy]])
principalAxis = eigenvector(λ1)
anisotropy = sqrt(λ1 / max(λ2, EPS))
DERIVED Это геометрическое описание, не эстетическая оценка.
ClusterProfile
ClusterProfile {
role,
shapeTarget?: compact|elongated|radial|tail|bimodal,
pointCountRange?,
anisotropyRange?,
densityProfile?,
localHierarchyProfile?,
spacingProfile?,
weights
}
Профиль задаёт желаемое поведение. Диапазоны — HEURISTIC или STYLE_PRESET, а не универсальные законы.
| Shape target | Стартовая эвристика | Назначение |
| compact | anisotropy 1.00..1.35 | локальное сгущение |
| elongated | 1.35..2.50 | направленное облако |
| tail | core + monotonically thinning extension | переход / направление |
| bimodal | 2 локальных maxima | двойной узел |
Плотность
03 / local-only descriptor
kNN density
d_k(i) = distance to k-th nearest point
localDensity_i = 1 / max(d_k(i), EPS)^2
// normalize only inside cluster:
densityN_i = localDensity_i / max(localDensity)
Не смешивает px² и normalized area.
Core density
core = points where
distance(Xi,C) ≤ q * R95
q default = 0.45
source = INITIAL_HEURISTIC
Core — локальный дескриптор, а не «правильная» композиция.
Gradient
densityGradient = corr(
distanceFromCentroid,
densityN
)
negative → density decreases outward
Используется только если profile требует градиент.
Локальная иерархия
04 / cluster-internal only
Что можно считать здесь
weights = [point.visualWeightContextual ?? point.visualWeightBase]
w1 = max(weights)
w2 = secondMax(weights)
meanRest = mean(weights excluding w1)
anchorDominance = w1 / max(meanRest, EPS)
secondaryRatio = w2 / max(w1, EPS)
Это только внутреннее распределение веса в группе.
Что запрещено считать здесь
global_hierarchy_score
composition_dominance_score
headline_competition
image_dominance
balance_contribution
negative_space_preservation
Эти метрики принадлежат другим модулям.
Кластер может иметь сильную локальную anchor-точку и при этом оставаться второстепенным относительно фотографии или заголовка. Поэтому anchorDominance ≠ global.hierarchy_score.
Интервалы и связность
05 / local topology
Nearest-neighbour
nn_i = min_j≠i distance(Xi,Xj)
meanNN = mean(nn_i)
sigmaNN = stddev(nn_i)
cvNN = sigmaNN / max(meanNN,EPS)
DERIVED descriptor.
Connectivity
edge(i,j) if distance(Xi,Xj)
≤ connectRadius
components = connectedComponents(graph)
cohesionDescriptor = 1 / components
connectRadius задаётся profile/preset.
Outlier
z_i = (ri - mean(r)) / max(stddev(r),EPS)
outlierCandidate if z_i > zThreshold
zThreshold default = 1.8
source = INITIAL_HEURISTIC
Outlier не удаляется автоматически: только маркируется.
Metric ownership
06 / no double counting
| Metric | Owner | Смысл | Статус |
cluster.shape_fit | 1.04 | соответствие covariance shape целевому profile | LOCAL |
cluster.cohesion_fit | 1.04 | локальная связность группы | LOCAL |
cluster.spacing_fit | 1.04 | соответствие spacing profile | LOCAL |
cluster.density_fit | 1.04 | локальный density target | LOCAL |
cluster.local_hierarchy_fit | 1.04 | внутренняя anchor/support структура | LOCAL |
cluster.outlier_penalty | 1.04 | необъяснимые локальные выбросы | LOCAL |
global.balance_score | future owner | не вычислять здесь | DEFERRED |
global.negative_space_score | future owner | не вычислять здесь | DEFERRED |
global.hierarchy_score | future owner | не вычислять здесь | DEFERRED |
grid.fit_score | 1.06 | consume only | EXTERNAL |
point.conflict_score | 1.09 | collision/tangency | EXTERNAL |
Локальный scoring
07 / applicable metrics only
Разрешённый score
localClusterScore = weightedApplicableMean({
shape_fit,
cohesion_fit,
spacing_fit,
density_fit,
local_hierarchy_fit,
1 - outlier_penalty
}, ClusterProfile.weights)
// not_applicable metrics are omitted
// remaining weights are renormalized
Не добавлять
global_balance_score
negative_space_score
global_hierarchy_score
global_eye_flow_score
grid_fit_score
external_collision_score
contrast_score
typography_score
image_interaction_score
Context interactions
08 / override graph
| Owner | Что может сделать | Что делает 1.04 после override |
| Compo 1.01 | пересчитать contextual weight точек | пересчитать weighted centroid и local hierarchy descriptors |
| Compo 1.02 | переместить отдельную точку | пересчитать cluster geometry |
| Compo 1.06 | предложить snap к grid | оценить новое локальное shape/spacing состояние |
| Compo 1.09 | обнаружить collision/tangency | repair/remove/move candidate point |
| Global negative-space owner | потребовать уменьшить/сдвинуть кластер | генерировать локально допустимые варианты под новый target |
| Global balance owner | изменить позицию/масштаб группы | не спорить; пересчитать descriptor |
Визуальная лаборатория
09 / machine-readable examples
Интерактивная лаборатория
10 / local descriptor only
Лаборатория демонстрирует только локальные cluster descriptors. Она намеренно не показывает «финальную композицию»: balance, negative space и global hierarchy сюда не встроены.
Порядок выполнения
11 / local pipeline
1 INPUT
points + role + profile
2 GEOMETRY
centroid, R95, covariance
3 DESCRIBE
density, spacing, hierarchy
4 LOCAL SCORE
owned metrics only
5 HARD FILTER
known local hard violations
6 EXPORT
top-N candidates
7 CONTEXT
external owner metrics
8 RECOMPUTE
after any override
function evaluatePointCluster(points, profile, external = null):
geometry = computeClusterGeometry(points)
local = computeOwnedMetrics(geometry, points, profile)
score = weightedApplicableMean(local, profile.weights)
candidate = {
points,
geometry,
localMetrics: local,
localClusterScore: score,
deferred: external ?? "UNRESOLVED"
}
return candidate
// Global solver later may move/scale/repair the cluster.
// After mutation call evaluatePointCluster() again.
// Compo 1.04 never synthesizes missing global metrics.
Детерминированные тесты
12 / regression
TEST 01 — centroid
canvas 1000×1000
points:
(0.2,0.4)
(0.4,0.4)
(0.6,0.4)
expected centroidN = (0.4,0.4)
source = DERIVED
TEST 02 — weight fallback
contextualWeight unavailable
base weights = [1,2,1]
expected:
weighted centroid uses base weights
NO fabricated contextual modifier
TEST 03 — reclassify pattern
regular 3×4 array
role = pattern
expected:
1.04 does not apply organic CV target
spacing_fit = not_applicable
forward to pattern/rhythm owner
TEST 04 — deferred global
localClusterScore = 0.91
global.balance_score = unresolved
expected:
local score remains 0.91
final score = NOT_FINAL
no neutral guess inserted
TEST 05 — dirty propagation
point P3 moved
invalidate:
centroid
R95
covariance
density
spacing
local hierarchy if weights changed
TEST 06 — ownership
Compo 1.09 returns conflict score
expected:
1.04 may consume it
1.04 MUST NOT recompute it
or rename duplicate metric
DATA FOR LAYOUT ENGINE
13 / canonical contract
{
"module": "Compo 1.04",
"name": "point_cluster_system",
"version": "2.0",
"finality": "LOCAL_PROPOSAL_ONLY",
"consumes": [
"Compo0.Canvas",
"Compo0.Constraint[]",
"Compo1.00.Point.role",
"Point.positionN",
"Compo1.01.Point.radiusShortN",
"Compo1.01.Point.visualWeightBase",
"Compo1.01.Point.visualWeightContextual?",
"Compo1.03.PointPairGeometry[]?",
"ClusterProfile?"
],
"provides": [
"PointClusterGeometry",
"cluster.centroidN",
"cluster.weightedCentroidN",
"cluster.radius95ShortN",
"cluster.spreadMeanShortN",
"cluster.spreadSigmaShortN",
"cluster.principalAxisDeg",
"cluster.anisotropy",
"cluster.localDensityDescriptor",
"cluster.localMetrics",
"cluster.localClusterScore",
"PointClusterCandidate[]"
],
"owner_of": [
"cluster.shape_fit",
"cluster.cohesion_fit",
"cluster.spacing_fit",
"cluster.density_fit",
"cluster.local_hierarchy_fit",
"cluster.outlier_penalty"
],
"forbidden_metric_ownership": [
"global.balance_score",
"global.negative_space_score",
"global.hierarchy_score",
"global.eye_flow_score",
"grid.fit_score",
"point.conflict_score",
"point.local_contrast"
],
"deferred_inputs": {
"point.conflict_score": "Compo 1.09",
"point.local_contrast": "Compo 1.10",
"grid.fit_score": "Compo 1.06",
"global.balance.score": "future owner",
"global.negative_space.score": "future owner",
"global.hierarchy.score": "future owner",
"global.eye_flow.score": "future owner"
},
"initial_heuristics": {
"compact_anisotropy_max": 1.35,
"elongated_anisotropy_range": [1.35, 2.50],
"core_radius_ratio": 0.45,
"outlier_z_threshold": 1.8,
"candidate_count_default": 32
},
"rules": [
"use contextual point weight only when owner has computed it",
"fallback to base point weight without inventing context",
"not_applicable metrics are omitted and remaining local weights renormalized",
"external metrics are consumed, never recreated",
"after any point movement or weight mutation recompute dependent cluster descriptors",
"global solver may override local cluster proposal"
]
}
Финальная проверка
14 / migration checklist
- У кластера задан profile/role?
- Centroid и weighted centroid пересчитаны после изменения точек?
- Contextual weight используется только если уже рассчитан владельцем?
- Все локальные метрики имеют owner = 1.04?
- Global balance не считается локально?
- Negative space не считается локально?
- Grid fit не дублируется?
- Collision/tangency не дублируются?
- Regular pattern не штрафуется как organic cluster без основания?
- HEURISTIC числа промаркированы и калибруемы?
- После override выполняется recompute?
- Локальный score не выдаётся за финальный composition score?