Skip to content
Merged
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
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@
"prototypes/de-morgan-circuit/main.ts",
"prototypes/de-morgan-circuit/playwright.config.ts",
"prototypes/de-morgan-circuit/browser.check.ts",
"prototypes/weight-chambers/main.ts",
"prototypes/weight-chambers/playwright.config.ts",
"prototypes/weight-chambers/browser.check.ts",
"prototypes/octahedron-gray-net/net.mjs"
]
},
Expand Down
23 changes: 23 additions & 0 deletions prototypes/weight-chambers/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Weight chambers prototype

Theoryとは独立した試作。A1(加法的スコアの三条件)の重み空間を三角形で表し、三条件がその中の一つの部屋を選ぶこと(Theorem 1)と、補色・上方閉集合との関係を示す。

- 重みは w_G + w_R + w_B = 168 の整数格子上を動く。168 は 2・3・4・7 で割り切れるので、4:2:1(96:48:24)、中心(56:56:56)、壁 w_i = 84 がすべて格子点になり、同点と壁を丸めなしで判定できる。
- 三角形は6本の壁(w_i = w_j が3本、w_i = w_j + w_k が3本)で12部屋に分かれる。壁の上では2色または2組が同点になり、越えると入れ替わる。
- 包含と矛盾しない八状態の全順序48通りのうち、補色でちょうど逆順になるのは12通りで、12部屋の順序とも、de Finetti の打ち消し則を満たす順序とも一致する。順位が原色の順位の和になるのはそのうち6通りで、G > M と R > B を加えると K<B<R<M<G<C<Y<W の1通りになる。
- 順位4〜7の上半分は、Gを含む色・Rを含む色・Bを含む色(立方体の面)か、原色を二つ以上含む色 {M,C,Y,W}(多数決)のどれかになる。支配条件 w_G > w_R + w_B は、上半分が「Gを含む色」になることと同じである。
- 支配の壁(w_i = w_j + w_k)では原色とその補色、等号の壁(w_i = w_j)では互いに補色の関係にある二組が入れ替わる。
- 測光・映像の係数は置かない(研究ノートの Evidence Boundary)。表示するのは加法的スコアの順序で、知覚的・測光的な明るさではない。
- 上の事実は全列挙で確かめたものである。比較確率の文献との関係と新規性は調べていない。
- Theoryのコンポーネント、状態、スタイルを読み込まない。本番ビルドの入口にもアプリからのリンクにも追加しない。

Vite起動後、`/chromalum/prototypes/weight-chambers/` を開く。`?w=96,48,24` のように重みを直接指定でき、合計168の非負整数でなければ4:2:1に戻る。点はドラッグするか、フォーカスして矢印キーで動かす。左右は w_R と w_B、上下は w_G と残り二つを交換する。Shift で6目盛りずつ動き、Home で4:2:1に戻る。

```sh
npm run dev -- --host 127.0.0.1 --port 4173
npx tsc --noEmit -p prototypes/weight-chambers/tsconfig.json
npx vitest run prototypes/weight-chambers/model.test.ts
npx playwright test -c prototypes/weight-chambers/playwright.config.ts --workers=1
```

`model.test.ts` は格子点14,365個すべてで同点の有無、属する部屋の順序、三条件と K<B<R<M<G<C<Y<W の一致、上半分を検査し、48・12・6・1、de Finetti の一致、各壁で入れ替わる組を確かめる。期待値は {G,R,B} の部分集合から組み立て、モデルのビット演算とは独立に求める。hue-euler と同じく、このファイルは `npm test` にも含まれる。ブラウザ検証は、初期状態、12部屋の順序、キーボードでの壁越え、各壁の同点、クリックでの移動、約分した比の表示、URL の検証、320〜1186pxでの表示とレイアウトの不動を対象とする。
181 changes: 181 additions & 0 deletions prototypes/weight-chambers/browser.check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { expect, test, type Page } from "@playwright/test";

// Expected orders and ties are rebuilt from subsets of {G,R,B}, independently of the page's model.
const SETS = [[], ["B"], ["R"], ["R", "B"], ["G"], ["G", "B"], ["G", "R"], ["G", "R", "B"]];
const NAMES = "KBRMGCYW";
const sigma = (w: number[], level: number) => SETS[level].reduce((sum, c) => sum + w[["G", "R", "B"].indexOf(c)], 0);
const PAGE = "prototypes/weight-chambers/";
const marks = (page: Page, list: string) =>
page
.locator(`${list} .mark`)
.allTextContents()
.then((all) => all.join(""));

function reversedExtensions() {
const subset = (a: number, b: number) => SETS[a].every((c) => SETS[b].includes(c));
const complement = (a: number) => SETS.findIndex((s) => s.length === 3 - SETS[a].length && s.every((c) => !SETS[a].includes(c)));
const out: string[] = [];
const walk = (prefix: number[]) => {
if (prefix.length === 8) {
if (prefix.every((x, i) => prefix[7 - i] === complement(x))) out.push(prefix.map((x) => NAMES[x]).join(""));
return;
}
for (let x = 0; x < 8; x++)
if (!prefix.includes(x) && SETS.every((_, y) => y === x || !subset(y, x) || prefix.includes(y))) walk([...prefix, x]);
};
walk([]);
return out.sort();
}

test("starts at 4:2:1 in the chamber where all three conditions hold", async ({ page }) => {
await page.goto(PAGE);
await expect(page.locator(".order code")).toHaveText("K<B<R<M<G<C<Y<W");
await expect(page.locator(".weights-value")).toHaveText(" 96 : 48 : 24");
await expect(page.locator(".ratio")).toHaveText("= 4 : 2 : 1");
expect(await marks(page, ".conditions")).toBe("✓✓✓");
expect(await marks(page, ".funnel")).toBe("✓✓✓✓");
await expect(page.locator(".funnel .count")).toHaveText(["48", "12", "6", "1"]);
await expect(page.locator(".chamber[data-current]")).toHaveAttribute("data-a1", "");
await expect(page.locator(".half-labels [aria-hidden=false]")).toHaveAttribute("data-half", "G");
await expect(page.locator(".hasse-node[data-upper]")).toHaveCount(4);
await expect(page).toHaveURL(/\?w=96,48,24$/);
});

test("gives the twelve chambers the twelve inclusion orders that complement reverses", async ({ page }) => {
await page.goto(PAGE);
const orders = await page.locator(".chamber").evaluateAll((paths) => paths.map((path) => path.getAttribute("data-order")!));
expect(orders.sort()).toEqual(reversedExtensions());
const halves = await page.locator(".chamber").evaluateAll((paths) => paths.map((path) => path.getAttribute("data-half")).sort());
expect(halves).toEqual(["B", "B", "G", "G", "R", "R", ...Array(6).fill("majority")].sort());
});

test("crossing the G–M wall ties G with M, then hands the upper half to majority", async ({ page }) => {
await page.goto(PAGE);
const handle = page.getByRole("slider");
await handle.focus();
for (let i = 0; i < 6; i++) await handle.press("ArrowDown");
await expect(page.locator(".weights-value")).toHaveText(" 84 : 54 : 30");
await expect(page.locator(".order code")).toHaveText("K<B<R<M=G<C<Y<W");
await expect(page.locator(".walls tbody tr[data-on] th")).toHaveText(["wG = wR + wB"]);
await expect(page.locator(".wall[data-on]")).toHaveCount(1);
await expect(page.locator(".chamber[data-current]")).toHaveCount(0);
await expect(page.locator(".half-labels [aria-hidden=false]")).toHaveAttribute("data-half", "none");
expect(await marks(page, ".conditions")).toBe("✓✓✗");
expect(await marks(page, ".funnel")).toBe("————");
await handle.press("ArrowDown");
await expect(page.locator(".order code")).toHaveText("K<B<R<G<M<C<Y<W");
await expect(page.locator(".half-labels [aria-hidden=false]")).toHaveAttribute("data-half", "majority");
expect(await marks(page, ".funnel")).toBe("✓✓✗✗");
await expect(handle).toBeFocused();
await handle.press("Home");
await expect(page.locator(".order code")).toHaveText("K<B<R<M<G<C<Y<W");
});

test("on each wall exactly the pairs listed for it tie", async ({ page }) => {
const points = [
[84, 50, 34],
[50, 84, 34],
[50, 34, 84],
[60, 60, 48],
[60, 48, 60],
[80, 44, 44],
];
await page.goto(PAGE);
await expect(page.locator(".walls tbody tr")).toHaveCount(points.length);
for (const [i, w] of points.entries()) {
await page.goto(`${PAGE}?w=${w.join(",")}`);
await expect(page.locator(".walls tbody tr[data-on]")).toHaveCount(1);
await expect(page.locator(".walls tbody tr").nth(i)).toHaveAttribute("data-on", "");
const ties = SETS.flatMap((_, x) => SETS.map((_, y) => [x, y]).filter(([, y]) => x < y && sigma(w, x) === sigma(w, y)))
.map(([x, y]) => [NAMES[x], NAMES[y]].sort().join(""))
.sort();
const listed = (await page.locator(".walls tbody tr").nth(i).locator("td").textContent())!
.split("、")
.map((pair) => pair.split(" ↔ ").sort().join(""))
.sort();
expect(listed).toEqual(ties);
const shown = (await page.locator(".order code").textContent())!.match(/\w(=\w)+/g)!.map((run) => run.split("=").sort().join(""));
expect(shown.sort()).toEqual(ties);
}
});

test("clicking inside a chamber moves the point into it", async ({ page }) => {
await page.goto(PAGE);
const centroids = await page.locator(".chamber").evaluateAll((paths) =>
paths.map((path) => {
const [x0, y0, x1, y1, x2, y2] = path
.getAttribute("d")!
.match(/-?[\d.]+/g)!
.map(Number);
const box = path.closest("svg")!.getBoundingClientRect();
return { order: path.getAttribute("data-order")!, x: box.left + (x0 + x1 + x2) / 3, y: box.top + (y0 + y1 + y2) / 3 };
}),
);
for (const { order, x, y } of centroids) {
await page.mouse.click(x, y);
await expect(page.locator(".chamber[data-current]")).toHaveAttribute("data-order", order);
const text = (await page.locator(".weights-value").textContent())!;
expect(text.split(":").reduce((sum, v) => sum + Number(v), 0)).toBe(168);
}
await expect(page.getByRole("slider")).toBeFocused();
});

test("shows the reduced ratio whenever the weights share a factor, on every edge", async ({ page }) => {
for (const [w, ratio] of [
["84,84,0", "= 1 : 1 : 0"],
["84,0,84", "= 1 : 0 : 1"],
["0,84,84", "= 0 : 1 : 1"],
["0,0,168", "= 0 : 0 : 1"],
["82,55,31", ""],
]) {
await page.goto(`${PAGE}?w=${w}`);
await expect(page.locator(".ratio")).toHaveText(ratio);
}
});

test("falls back to 4:2:1 when the URL weights are off the lattice", async ({ page }) => {
for (const bad of ["1,2,3", "96,48", "96,-1,73", "a,b,c"]) {
await page.goto(`${PAGE}?w=${bad}`);
await expect(page.locator(".weights-value")).toHaveText(" 96 : 48 : 24");
}
await page.goto(`${PAGE}?w=0,0,168`);
await expect(page.locator(".order code")).toHaveText("K=R=G=Y<B=M=C=W");
await expect(page.locator(".half-labels [aria-hidden=false]")).toHaveAttribute("data-half", "B");
});

test("keeps every readout in place and every label inside its figure across states and widths", async ({ page }) => {
const states = ["96,48,24", "84,54,30", "82,55,31", "82,0,86", "56,56,56", "0,168,0"];
for (const width of [320, 390, 576, 834, 1186]) {
await page.setViewportSize({ width, height: 800 });
const layouts = new Set<string>();
for (const w of states) {
await page.goto(`${PAGE}?w=${w}`);
await expect
.poll(() =>
page.evaluate(() =>
[...document.querySelectorAll<SVGSVGElement>(".triangle-svg, .strip-svg")].every(
(svg) => Math.abs(svg.viewBox.baseVal.width - svg.getBoundingClientRect().width) < 1,
),
),
)
.toBe(true);
const state = await page.evaluate(() => ({
fits: document.documentElement.scrollWidth <= innerWidth,
labels: [...document.querySelectorAll<SVGTextElement>("svg text")].every((label) => {
const r = label.getBoundingClientRect();
const box = label.ownerSVGElement!.getBoundingClientRect();
return r.left >= box.left - 1 && r.right <= box.right + 1 && r.top >= box.top - 1 && r.bottom <= box.bottom + 1;
}),
layout: [...document.querySelectorAll(".triangle, .readouts > *, .walls-section, footer")]
.map((el) => {
const r = el.getBoundingClientRect();
return `${Math.round(r.left)},${Math.round(r.top + scrollY)},${Math.round(r.width)},${Math.round(r.height)}`;
})
.join(" "),
}));
expect(state.fits && state.labels, `${w} at ${width}px`).toBe(true);
layouts.add(state.layout);
}
expect(layouts.size, `${width}px`).toBe(1);
}
});
14 changes: 14 additions & 0 deletions prototypes/weight-chambers/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<meta name="robots" content="noindex,nofollow" />
<title>重みの三角形と八状態の順序 — CHROMALUM LAB</title>
</head>
<body>
<main id="app"></main>
<script type="module" src="./main.ts"></script>
</body>
</html>
Loading
Loading