Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
48 changes: 48 additions & 0 deletions packages/components/src/lib/geometry-discovery/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# `components/src/lib/geometry-discovery` — heuristic layout discovery

`CLAUDE.md` is a symlink to this file. Edit `AGENTS.md` only. Package
[AGENTS.md](../../../AGENTS.md) and [src/lib/AGENTS.md](../AGENTS.md) apply.

The authored path — `geometry-contracts.json`, its compiled contracts and the ratchet in
[tests/e2e](../../../tests/e2e/AGENTS.md) — only finds what a reviewer already wrote down,
and names its members by DOM shape. This directory is the other half: expectations are
MINED from what the product repeatedly renders, and nobody writes the number down.

Not yet wired to the capture run or the gate. It is a report, and it stays one until its
findings have been triaged once: a recall-first pass that blocks CI has exactly one natural
remedy, raising its thresholds, which destroys the recall it exists for.

## Grouping is visual. Never structural.

Atoms are grouped by what RENDERS alike — the geometry-derived primitive kind, folded where
the difference is not painted (`link`/`button`, `numeric-text`/`text`), and quantised height,
never content-sized width. Never key a group on row family, role, accessible name, or DOM
ancestry.

The reason is not purity. A layout defect almost always comes from two code paths rendering
one visual thing differently — the sidebar's 26px tree indent slot and the mobile screen's
32px one are separate constants in separate files. So the defect CORRELATES with the
structural difference, and a structural key files the two paths into different groups and
never compares them: the more real the bug, the more reliably it is hidden. The reader
perceives a column because pixels line up, not because elements share a tag.

`VisualAtom.id` exists to name a finding across runs and must stay out of grouping. The
moment identity decides who is compared with whom, that blindness is back.

## Levels grow to an ANCHOR, not to a neighbour

A coordinate joins a level by distance to the level's anchor. Single linkage would let a
run of intermediate values walk one level into the next and merge two indentation depths
into one expectation — the merged level then reads as internally perfect and the deviation
disappears. Same failure the geometric row band avoids on Y.

## Recall is the bias, and ranking is not filtering

Nothing is dropped for looking weak. Candidates carry a `score` and are sorted; no
threshold removes one. A legitimate indent ladder therefore comes back as deviations too,
because without being told which level was intended it has to — it ranks low since `score`
falls as a level's own support rises, so a value two boxes share outranks one forty share.

A missed misalignment is invisible forever; a false one costs a triage glance. Any tie
breaks towards reporting more. Series orientation is measured rather than declared, and
only the axis perpendicular to the run carries expectations worth mining.
1 change: 1 addition & 0 deletions packages/components/src/lib/geometry-discovery/CLAUDE.md
311 changes: 311 additions & 0 deletions packages/components/src/lib/geometry-discovery/visual-repetition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,311 @@
/**
* Heuristic discovery of layout deviations from visual repetition alone.
*
* The authored path (`geometry-contracts.json`) can only find what someone
* already wrote down, and it names its members by DOM shape — role, accessible
* name, row family. That is the wrong ground truth twice over. A layout bug
* almost always comes from two code paths rendering the same visual thing
* differently, so the defect CORRELATES with the DOM difference: grouping by
* DOM shape files the two paths into separate families and never compares
* them. And a reader perceives a column of avatars as a column because the
* pixels line up, not because the elements share a tag.
*
* So nothing here reads structure. Atoms are grouped by what they look like,
* a repeating series is whatever renders as a repeating series, and the
* expected coordinate is mined from what the majority of that series actually
* does. Nobody writes down that the indent step is 26px; it is counted.
*
* The bias is recall. A missed misalignment is invisible forever, while a
* false one costs a triage glance, so nothing is dropped for looking weak —
* candidates are ranked, not filtered, and every tie is broken towards
* reporting more.
*/

/**
* A rendered box. `id` exists so a finding can be named across runs and never
* takes part in grouping: the moment identity decides who is compared with
* whom, the DOM blindness described above is back.
*/
export type VisualAtom = Readonly<{
id: string;
/** Geometry-derived primitive kind, never a semantic contract name. */
kind: string;
xStart: number;
xEnd: number;
yStart: number;
yEnd: number;
}>;

export type VisualDeviationMeasure = 'start' | 'end' | 'center' | 'pitch';

export type VisualDeviation = Readonly<{
atomId: string;
/** The visual signature whose series this atom deviates inside. */
signature: string;
axis: 'x' | 'y';
measure: VisualDeviationMeasure;
value: number;
/** Median of the best-supported level in the same series. */
expected: number;
delta: number;
/** How many series members share this atom's value. */
peerSupport: number;
/** How many share the level it deviates from. */
dominantSupport: number;
seriesSize: number;
/** Higher is more suspicious. Ranking only; never a pass/fail threshold. */
score: number;
}>;

export type VisualRepetitionOptions = Readonly<{
/**
* Shortest run that can carry an expectation at all. Two boxes agreeing is
* a coincidence; three is the weakest thing that can be called usual.
*/
minimumSeriesLength?: number;
/**
* How far apart two coordinates may be and still count as the same level.
* Sits at measurement noise (1/devicePixelRatio of the coarsest capture),
* not at a design tolerance — a real indent step is an order of magnitude
* above it.
*/
levelTolerance?: number;
/** Heights within this distance describe the same kind of box. */
heightTolerance?: number;
/**
* A gap this many times the series median ends the series. Purely visual
* locality: it separates two lists that happen to render alike, without
* severing a list that a date header interrupts.
*/
seriesBreakRatio?: number;
}>;

const DEFAULTS = {
minimumSeriesLength: 3,
levelTolerance: 1,
heightTolerance: 1,
seriesBreakRatio: 3,
} as const;

function median(values: readonly number[]): number {
const sorted = [...values].sort((left, right) => left - right);
const middle = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0 ? (sorted[middle - 1]! + sorted[middle]!) / 2 : sorted[middle]!;
}

/**
* `link` versus `button` is a tag difference and `numeric-text` versus `text`
* is a content difference; neither is visible. Folding them widens each group,
* which is the direction that finds more.
*/
function normalizeKind(kind: string): string {
if (kind === 'link') return 'button';
if (kind === 'numeric-text') return 'text';
return kind;
}

/**
* Width is left out on purpose. A row's label is as wide as its text, so
* keying on width would split one visual series into one group per string
* length and leave nothing to compare.
*/
function visualSignature(atom: VisualAtom, heightTolerance: number): string {
const height = atom.yEnd - atom.yStart;
const bucket = heightTolerance > 0 ? Math.round(height / heightTolerance) : height;
return `${normalizeKind(atom.kind)}|h${bucket}`;
}

type Level = { readonly values: number[]; readonly atoms: VisualAtom[]; anchor: number };

/**
* Levels grow by distance to the level's anchor, never to its nearest member.
* Single linkage would let a chain of intermediate coordinates walk one level
* into the next and quietly merge two indentation depths into one expectation
* — the merged level then looks internally perfect and the deviation vanishes.
*/
function buildLevels(
entries: readonly { readonly atom: VisualAtom; readonly value: number }[],
tolerance: number
): Level[] {
const levels: Level[] = [];
for (const entry of [...entries].sort((left, right) => left.value - right.value)) {
const existing = levels.find((level) => Math.abs(entry.value - level.anchor) <= tolerance);
if (existing) {
existing.values.push(entry.value);
existing.atoms.push(entry.atom);
continue;
}
levels.push({ values: [entry.value], atoms: [entry.atom], anchor: entry.value });
}
return levels;
}

/**
* Every member of every non-dominant level is reported. In a legitimate indent
* ladder that means the whole indented half comes back as deviations — which
* is the accepted cost of not knowing in advance which of the two levels was
* intended. They rank low because they have each other: score falls as a
* level's own support rises, so a value only two boxes share outranks one
* forty boxes share, and the stray middle row sorts above the ladder.
*/
function scoreLevels(
levels: readonly Level[],
seriesSize: number,
signature: string,
axis: 'x' | 'y',
measure: VisualDeviationMeasure,
levelTolerance: number
): VisualDeviation[] {
if (levels.length < 2) return [];
const dominant = levels.reduce((best, level) =>
level.atoms.length > best.atoms.length ? level : best
);
const dominantSupport = dominant.atoms.length;
const expected = median(dominant.values);
const deviations: VisualDeviation[] = [];
for (const level of levels) {
if (level === dominant) continue;
const peerSupport = level.atoms.length;
for (const [index, atom] of level.atoms.entries()) {
const value = level.values[index]!;
const delta = Math.abs(value - expected);
if (delta <= levelTolerance) continue;
deviations.push({
atomId: atom.id,
signature,
axis,
measure,
value,
expected,
delta,
peerSupport,
dominantSupport,
seriesSize,
score: (delta * dominantSupport) / peerSupport,
});
}
}
return deviations;
}

/**
* Splits one signature group into runs that read as a single series. Members
* are ordered along the series axis and cut where the gap jumps far past the
* run's own median gap.
*/
function splitIntoSeries(
atoms: readonly VisualAtom[],
axis: 'x' | 'y',
breakRatio: number,
minimumLength: number
): VisualAtom[][] {
const center = (atom: VisualAtom) =>
axis === 'y' ? (atom.yStart + atom.yEnd) / 2 : (atom.xStart + atom.xEnd) / 2;
const ordered = [...atoms].sort((left, right) => center(left) - center(right));
const gaps: number[] = [];
for (let index = 1; index < ordered.length; index += 1) {
gaps.push(center(ordered[index]!) - center(ordered[index - 1]!));
}
if (gaps.length === 0) return [];
const typicalGap = median(gaps);
const series: VisualAtom[][] = [];
let current: VisualAtom[] = [ordered[0]!];
for (let index = 1; index < ordered.length; index += 1) {
const gap = gaps[index - 1]!;
if (typicalGap > 0 && gap > typicalGap * breakRatio) {
series.push(current);
current = [];
}
current.push(ordered[index]!);
}
series.push(current);
return series.filter((run) => run.length >= minimumLength);
}

/**
* Orientation is measured, not declared: whichever axis the boxes spread along
* is the series axis, and the expectations worth mining are the ones
* perpendicular to it. Mining the series axis itself would only rediscover
* that a list advances down the page.
*/
function seriesAxis(atoms: readonly VisualAtom[]): 'x' | 'y' {
const xs = atoms.map((atom) => (atom.xStart + atom.xEnd) / 2);
const ys = atoms.map((atom) => (atom.yStart + atom.yEnd) / 2);
const spread = (values: readonly number[]) => Math.max(...values) - Math.min(...values);
return spread(ys) >= spread(xs) ? 'y' : 'x';
}

export function mineVisualDeviations(
atoms: readonly VisualAtom[],
options: VisualRepetitionOptions = {}
): readonly VisualDeviation[] {
const minimumSeriesLength = options.minimumSeriesLength ?? DEFAULTS.minimumSeriesLength;
const levelTolerance = options.levelTolerance ?? DEFAULTS.levelTolerance;
const heightTolerance = options.heightTolerance ?? DEFAULTS.heightTolerance;
const seriesBreakRatio = options.seriesBreakRatio ?? DEFAULTS.seriesBreakRatio;

const groups = new Map<string, VisualAtom[]>();
for (const atom of atoms) {
const signature = visualSignature(atom, heightTolerance);
const group = groups.get(signature);
if (group) group.push(atom);
else groups.set(signature, [atom]);
}

const deviations: VisualDeviation[] = [];
for (const [signature, group] of groups) {
if (group.length < minimumSeriesLength) continue;
const axis = seriesAxis(group);
for (const run of splitIntoSeries(group, axis, seriesBreakRatio, minimumSeriesLength)) {
Comment on lines +258 to +259

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Infer orientation after isolating each visual series

When one signature occurs in multiple side-by-side vertical lists, seriesAxis(group) measures the entire page-wide group before those lists are separated; if their horizontal separation exceeds their vertical span, it classifies them as one horizontal series. splitIntoSeries then orders by X (often producing zero gaps for same-column atoms), emits false Y/pitch deviations for an otherwise regular grid, and never checks either list's X alignment. Partition local series before selecting an orientation for each one.

AGENTS.md reference: packages/components/src/lib/geometry-discovery/AGENTS.md:L46-L48

Useful? React with 👍 / 👎.

const edges =
axis === 'y'
? ([
['x', 'start', (atom: VisualAtom) => atom.xStart],
['x', 'end', (atom: VisualAtom) => atom.xEnd],
['x', 'center', (atom: VisualAtom) => (atom.xStart + atom.xEnd) / 2],
] as const)
: ([
['y', 'start', (atom: VisualAtom) => atom.yStart],
['y', 'end', (atom: VisualAtom) => atom.yEnd],
['y', 'center', (atom: VisualAtom) => (atom.yStart + atom.yEnd) / 2],
] as const);

for (const [edgeAxis, measure, read] of edges) {
const entries = run.map((atom) => ({ atom, value: read(atom) }));
deviations.push(
...scoreLevels(
buildLevels(entries, levelTolerance),
run.length,
signature,
edgeAxis,
measure,
levelTolerance
)
);
}

// Irregular spacing is the same kind of defect seen along the series
// axis, and the series is already ordered, so it costs nothing to mine.
const along = (atom: VisualAtom) =>
axis === 'y' ? (atom.yStart + atom.yEnd) / 2 : (atom.xStart + atom.xEnd) / 2;
const pitchEntries = run.slice(1).map((atom, index) => ({
atom,
value: along(atom) - along(run[index]!),
}));
if (pitchEntries.length >= minimumSeriesLength) {
deviations.push(
...scoreLevels(
buildLevels(pitchEntries, levelTolerance),
run.length,
signature,
axis,
'pitch',
levelTolerance
)
);
}
}
}

return deviations.sort((left, right) => right.score - left.score);
}
24 changes: 24 additions & 0 deletions packages/components/tests/e2e/scratch-dump.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { writeFile } from 'node:fs/promises';

import { test } from '@playwright/test';

import type { GeometryObservationCache } from '../../src/lib/geometry-constraint-system';
import {
GEOMETRY_REPRESENTATIVE_CAPTURE,
runGeometryCapturePlan,
} from './support/geometry-capture-plan';

test('dump one real capture', async ({ browser }) => {
test.setTimeout(600_000);
const observationCache: GeometryObservationCache = new Map();
const blockedRequests: string[] = [];
const capture = await runGeometryCapturePlan(browser, [GEOMETRY_REPRESENTATIVE_CAPTURE], {
observationCache,
blockedRequests,
});
await writeFile(
'/tmp/geom-run/capture-one.json',
`${JSON.stringify(capture, null, 2)}\n`,
'utf8'
);
});
Loading