From f7f39993629dc97cf0bc273fc0698478959eed30 Mon Sep 17 00:00:00 2001 From: minoreality <244164377+minoreality@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:06:21 +0900 Subject: [PATCH 1/2] Add weight-chambers prototype of the A1 score triangle Co-Authored-By: Claude Opus 5.5 --- package.json | 3 + prototypes/weight-chambers/README.md | 23 + prototypes/weight-chambers/browser.check.ts | 168 +++++++ prototypes/weight-chambers/index.html | 14 + prototypes/weight-chambers/main.ts | 351 +++++++++++++++ prototypes/weight-chambers/model.test.ts | 136 ++++++ prototypes/weight-chambers/model.ts | 156 +++++++ .../weight-chambers/playwright.config.ts | 22 + prototypes/weight-chambers/style.css | 417 ++++++++++++++++++ prototypes/weight-chambers/tsconfig.json | 5 + 10 files changed, 1295 insertions(+) create mode 100644 prototypes/weight-chambers/README.md create mode 100644 prototypes/weight-chambers/browser.check.ts create mode 100644 prototypes/weight-chambers/index.html create mode 100644 prototypes/weight-chambers/main.ts create mode 100644 prototypes/weight-chambers/model.test.ts create mode 100644 prototypes/weight-chambers/model.ts create mode 100644 prototypes/weight-chambers/playwright.config.ts create mode 100644 prototypes/weight-chambers/style.css create mode 100644 prototypes/weight-chambers/tsconfig.json diff --git a/package.json b/package.json index aa523bd7..20a3b7ac 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,9 @@ "prototypes/hue-euler/main.tsx", "prototypes/hue-euler/playwright.config.ts", "prototypes/hue-euler/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" ] }, diff --git a/prototypes/weight-chambers/README.md b/prototypes/weight-chambers/README.md new file mode 100644 index 00000000..07d5a618 --- /dev/null +++ b/prototypes/weight-chambers/README.md @@ -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 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 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 { + 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 { + 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("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 { + 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(); + for (const w of states) { + await page.goto(`${PAGE}?w=${w}`); + await expect + .poll(() => + page.evaluate(() => + [...document.querySelectorAll(".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("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); + } +}); diff --git a/prototypes/weight-chambers/index.html b/prototypes/weight-chambers/index.html new file mode 100644 index 00000000..064dad79 --- /dev/null +++ b/prototypes/weight-chambers/index.html @@ -0,0 +1,14 @@ + + + + + + + + 重みの三角形と八状態の順序 — CHROMALUM LAB + + +
+ + + diff --git a/prototypes/weight-chambers/main.ts b/prototypes/weight-chambers/main.ts new file mode 100644 index 00000000..df6cf1ae --- /dev/null +++ b/prototypes/weight-chambers/main.ts @@ -0,0 +1,351 @@ +import "./style.css"; +import { + A1_WEIGHTS, + CHAMBERS, + COLORS, + FUNNEL, + HALVES, + N, + WALLS, + additiveRank, + bits, + complementReversed, + conditions, + isA1, + name, + onWall, + orderText, + parseWeights, + ranking, + reduced, + score, + snap, + upperHalf, + type Half, + type Weights, +} from "./model"; + +// Standalone prototype: no imports from the application or its state. +let weights: Weights = parseWeights(new URL(location.href).searchParams.get("w")) ?? A1_WEIGHTS; +type Point = [number, number]; + +// Label parts alternate plain text and a subscript: ["w", "G", " > w", "B"] reads w_G > w_B. +const math = (parts: readonly string[]) => parts.map((part, i) => (i % 2 ? `${part}` : part)).join(""); +const ink = (level: number) => (level >= 4 ? "#10111b" : "#ffffff"); +const TEXT_COLOR: Record = { G: "#5dff5d", R: "#ff6b6b", B: "#8c9bff" }; +const TINT: Record = { G: "#00ff00", R: "#ff0000", B: "#4f63ff", majority: "#9bacff" }; +const HALF_TEXT: Record = { + G: "上半分 = G を含む色 {G, C, Y, W}:立方体の面", + R: "上半分 = R を含む色 {R, M, Y, W}:立方体の面", + B: "上半分 = B を含む色 {B, M, C, W}:立方体の面", + majority: "上半分 = 原色を二つ以上含む色 {M, C, Y, W}:多数決", + none: "上半分は未定:中央で同点", +}; +const FUNNEL_TEXT = [ + "包含と矛盾しない全順序", + "補色でちょうど逆順になる順序(三角形の12部屋)", + "順位が原色の順位の和になる順序(上半分が立方体の面)", + "さらに G > M と R > B を満たす順序", +]; +const HASSE: Point[] = [ + [85, 132], + [35, 96], + [85, 96], + [35, 56], + [135, 96], + [85, 56], + [135, 56], + [85, 18], +]; + +document.querySelector("#app")!.innerHTML = ` +
+

CHROMALUM LAB PROTOTYPE

+

重みの三角形と八状態の順序

+

原色の重みを ${math(["w", "G", " + w", "R", " + w", "B", ` = ${N}`])} の整数格子上で動かし、加法的スコア σ(S) = Σc∈S wc が八状態に与える順序を見ます。

+
+
+
+
+
六本の壁が三角形を12部屋に分けます。色付きの部屋では一つの原色の重みが半分を超えます。太枠は三条件を満たす部屋です。
+
+
+
+

${math(["w", "G", " : w", "R", " : w", "B"])} =

+ +
+

点をドラッグするか、点にフォーカスして矢印キーで動かします。Shift で6目盛りずつ動きます。

+
    + ${conditions(weights) + .map(({ label }) => `
  • ${math(label)}
  • `) + .join("")} +
+

三条件がすべて成り立つのは、順序が K<B<R<M<G<C<Y<W のときだけです。

+

順序

+
+
+ + ${[ + [0, 1], + [0, 2], + [0, 4], + [1, 3], + [1, 5], + [2, 3], + [2, 6], + [4, 5], + [4, 6], + [3, 7], + [5, 7], + [6, 7], + ] + .map(([a, b]) => ``) + .join("")} + ${HASSE.map( + ([x, y], level) => ` + + ${name(level)} + `, + ).join("")} + +
+
${(Object.keys(HALF_TEXT) as (Half | "none")[]).map((half) => `

${HALF_TEXT[half]}

`).join("")}
+

補色は上半分と下半分を入れ替えます。そうなる上方閉集合はこの四つだけです。

+
+
+
    + ${FUNNEL.map( + (set, i) => + `
  1. ${set.length}${FUNNEL_TEXT[i]}
  2. `, + ).join("")} +
+
+
+
+

壁を越えると入れ替わる組

+ + + + ${WALLS.map( + ({ label, swaps }) => + ``, + ).join("")} + +
壁入れ替わる組
${math(label)}${swaps.map(([x, y]) => `${name(x)} ↔ ${name(y)}`).join("、")}
+

原色が半分を超えるかどうかの壁では原色とその補色が、二つの重みが等しくなる壁では互いに補色の関係にある二組が入れ替わります。

+
+
加法的スコアの順序を扱う試作です。知覚的・測光的な明るさの尺度ではありません。
+`; + +const triangleFrame = document.querySelector(".triangle-frame")!; +const stripFrame = document.querySelector(".strip-frame")!; + +// Draws the static triangle at the frame's width and returns its weight <-> screen maps. +function buildTriangle() { + const width = triangleFrame.getBoundingClientRect().width; + const side = Math.min(width - 48, 460); + const top = 34; + const cx = width / 2; + const vertices: Point[] = [ + [cx, top], + [cx - side / 2, top + (side * Math.sqrt(3)) / 2], + [cx + side / 2, top + (side * Math.sqrt(3)) / 2], + ]; + const height = vertices[1][1] + 36; + const along = (w: Weights, axis: 0 | 1) => w.reduce((sum, v, i) => sum + v * vertices[i][axis], 0) / N; + const toScreen = (w: Weights): Point => [along(w, 0), along(w, 1)]; + const [[xg, yg], [xr, yr], [xb, yb]] = vertices; + const det = (yr - yb) * (xg - xb) + (xb - xr) * (yg - yb); + const fromScreen = ([x, y]: Point) => { + const g = ((yr - yb) * (x - xb) + (xb - xr) * (y - yb)) / det; + const r = ((yb - yg) * (x - xb) + (xg - xb) * (y - yb)) / det; + return snap([g, r, 1 - g - r]); + }; + const path = (points: Weights[]) => `M${points.map((w) => toScreen(w).join(" ")).join(" L")} Z`; + // A wall runs between the two points where its form vanishes on the triangle's edges. + const wallEnds = (coefficients: readonly number[]) => { + const corners: Weights[] = [ + [N, 0, 0], + [0, N, 0], + [0, 0, N], + ]; + const f = (w: Weights) => coefficients.reduce((sum, c, i) => sum + c * w[i], 0); + return corners.flatMap((p, i) => { + const q = corners[(i + 1) % 3]; + const t = f(p) === f(q) ? -1 : f(p) / (f(p) - f(q)); + const [a, b] = [toScreen(p), toScreen(q)]; + return t >= 0 && t < 1 ? [[a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1])]] : []; + }); + }; + const labelOffset: Point[] = [ + [0, -14], + [-14, 22], + [14, 22], + ]; + triangleFrame.innerHTML = ` + ${CHAMBERS.map( + ({ vertices: corners, order, half }) => + ``, + ).join("")} + + ${WALLS.map(({ coefficients }, i) => { + const [[x1, y1], [x2, y2]] = wallEnds(coefficients); + return ``; + }).join("")} + ${CHAMBERS.filter(({ order }) => isA1(order)) + .map(({ vertices: corners }) => ``) + .join("")} + ${vertices + .map( + ([x, y], i) => + `${"GRB"[i]}`, + ) + .join("")} + + + + + `; + return { toScreen, fromScreen }; +} + +function stripSvg(width: number) { + const { order, tied, rank } = ranking(weights); + const pad = 16; + const x = (s: number) => pad + (s / N) * (width - 2 * pad); + const slot = (i: number) => pad + 13 + (i * (width - 2 * pad - 26)) / 7; + const divider = (slot(3) + slot(4)) / 2; + return ` + ${[0, N / 2, N].map((s) => `${s}`).join("")} + + + ${order + .map( + (level, i) => + ``, + ) + .join("")} + ${order + .map((level) => ``) + .join("")} + + ${order + .map( + (level, i) => ` + + ${name(level)} + ${bits(level)} + ${rank[i]} + `, + ) + .join("")} + ${tied.map((tie, i) => (tie ? `` : "")).join("")} + 下半分 + 上半分 + `; +} + +function update() { + const { order, tied, strict } = ranking(weights); + const half = tied[3] ? null : upperHalf(order); + const [px, py] = layout.toScreen(weights); + const handle = triangleFrame.querySelector(".handle")!; + handle.setAttribute("transform", `translate(${px} ${py})`); + handle.setAttribute("aria-valuenow", String(weights[0])); + handle.setAttribute("aria-valuetext", `w_G ${weights[0]}、w_R ${weights[1]}、w_B ${weights[2]}`); + for (const chamber of triangleFrame.querySelectorAll(".chamber")) { + chamber.toggleAttribute("data-current", strict && chamber.dataset.order === order.map(name).join("")); + } + for (const wall of triangleFrame.querySelectorAll(".wall")) { + wall.toggleAttribute("data-on", onWall(weights, WALLS[Number(wall.dataset.wall)].coefficients)); + } + document.querySelector(".weights-value")!.textContent = weights.map((v) => String(v).padStart(3, " ")).join(" : "); + const ratio = reduced(weights); + document.querySelector(".ratio")!.textContent = ratio[0] === weights[0] ? "" : `= ${ratio.join(" : ")}`; + conditions(weights).forEach(({ holds }, i) => { + const item = document.querySelectorAll(".conditions li")[i]; + item.dataset.holds = String(holds); + item.querySelector(".mark")!.textContent = holds ? "✓" : "✗"; + }); + document.querySelector(".order code")!.textContent = orderText(order, tied); + stripFrame.innerHTML = stripSvg(stripFrame.getBoundingClientRect().width); + for (const node of document.querySelectorAll(".hasse-node")) { + node.toggleAttribute("data-upper", half !== null && HALVES[half].includes(Number(node.dataset.level))); + } + for (const label of document.querySelectorAll(".half-labels [data-half]")) { + label.setAttribute("aria-hidden", String(label.dataset.half !== (half ?? "none"))); + } + const passes = [strict, strict && complementReversed(order), strict && additiveRank(order), strict && isA1(order)]; + document.querySelectorAll(".funnel li").forEach((item, i) => { + item.dataset.state = strict ? String(passes[i]) : "tied"; + item.querySelector(".mark")!.textContent = strict ? (passes[i] ? "✓" : "✗") : "—"; + }); + document + .querySelectorAll(".walls tbody tr") + .forEach((row, i) => row.toggleAttribute("data-on", onWall(weights, WALLS[i].coefficients))); + history.replaceState(null, "", `?w=${weights.join(",")}${location.hash}`); +} + +function setWeights(next: Weights) { + if (next.every((v, i) => v === weights[i])) return; + weights = next; + update(); +} + +let layout = buildTriangle(); +update(); + +const svgPoint = (event: PointerEvent): Point => { + const box = triangleFrame.querySelector("svg")!.getBoundingClientRect(); + return [event.clientX - box.left, event.clientY - box.top]; +}; +triangleFrame.addEventListener("pointerdown", (event) => { + if (!(event.target instanceof Element) || !event.target.closest("svg")) return; + event.preventDefault(); + triangleFrame.setPointerCapture(event.pointerId); + triangleFrame.querySelector(".handle")!.focus({ preventScroll: true }); + setWeights(layout.fromScreen(svgPoint(event))); +}); +triangleFrame.addEventListener("pointermove", (event) => { + if (triangleFrame.hasPointerCapture(event.pointerId)) setWeights(layout.fromScreen(svgPoint(event))); +}); + +// Arrow keys move along the lattice: left/right trade w_R against w_B, up/down trade w_G against both. +const KEYS: Record = { ArrowLeft: [0, 1, -1], ArrowRight: [0, -1, 1], ArrowUp: [2, -1, -1], ArrowDown: [-2, 1, 1] }; +triangleFrame.addEventListener("keydown", (event) => { + if (!(event.target instanceof Element) || !event.target.closest(".handle")) return; + if (event.key === "Home") { + event.preventDefault(); + setWeights(A1_WEIGHTS); + return; + } + const step = KEYS[event.key]; + if (!step) return; + event.preventDefault(); + const k = event.shiftKey ? 6 : 1; + const next = weights.map((v, i) => v + k * step[i]); + setWeights(next.every((v) => v >= 0) ? [next[0], next[1], next[2]] : snap(next.map((v) => v / N))); +}); +document.querySelector(".reset")!.addEventListener("click", () => setWeights(A1_WEIGHTS)); + +// Match each viewBox to its frame so text and marks keep their size at every width. Heights follow +// widths only, so a redraw at an unchanged width is skipped. The redraw waits for the next frame: +// redrawing the triangle resizes its observed frame, which inside this callback would report a +// ResizeObserver loop. +const widths = new Map(); +const resize = new ResizeObserver((entries) => { + const changed = entries.filter((entry) => widths.get(entry.target) !== entry.contentRect.width); + for (const entry of changed) widths.set(entry.target, entry.contentRect.width); + if (!changed.length) return; + const triangle = changed.some((entry) => entry.target === triangleFrame); + requestAnimationFrame(() => { + if (triangle) layout = buildTriangle(); + update(); + }); +}); +resize.observe(triangleFrame); +resize.observe(stripFrame); diff --git a/prototypes/weight-chambers/model.test.ts b/prototypes/weight-chambers/model.test.ts new file mode 100644 index 00000000..5725a7fc --- /dev/null +++ b/prototypes/weight-chambers/model.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "vitest"; +import { A1_WEIGHTS, CHAMBERS, FUNNEL, N, WALLS, conditions, parseWeights, ranking, snap, upperHalf, type Weights } from "./model"; + +// Expectations are rebuilt from subsets of {G,R,B}, independently of the model's bit arithmetic. +const SETS = [[], ["B"], ["R"], ["R", "B"], ["G"], ["G", "B"], ["G", "R"], ["G", "R", "B"]]; +const NAMES = "KBRMGCYW"; +const PRIMARY = ["G", "R", "B"]; +const sigma = (w: Weights, level: number) => SETS[level].reduce((sum, c) => sum + w[PRIMARY.indexOf(c)], 0); +const subset = (a: number, b: number) => SETS[a].every((c) => SETS[b].includes(c)); +const disjoint = (a: number, b: number) => !SETS[a].some((c) => SETS[b].includes(c)); +const union = (a: number, b: number) => + SETS.findIndex((s) => s.length === new Set([...SETS[a], ...SETS[b]]).size && [...SETS[a], ...SETS[b]].every((c) => s.includes(c))); +const complement = (a: number) => SETS.findIndex((s) => s.length === 3 - SETS[a].length && s.every((c) => !SETS[a].includes(c))); +const lattice: Weights[] = []; +for (let g = 0; g <= N; g++) for (let r = 0; r <= N - g; r++) lattice.push([g, r, N - g - r]); +const strictOrder = (w: Weights) => { + const scores = SETS.map((_, level) => sigma(w, level)); + if (new Set(scores).size < 8) return null; + const order = [...scores.keys()].sort((x, y) => scores[x] - scores[y]); + return order.map((x) => NAMES[x]).join(""); +}; + +function permutations(items: number[]): number[][] { + return items.length + ? items.flatMap((x, i) => permutations([...items.slice(0, i), ...items.slice(i + 1)]).map((rest) => [x, ...rest])) + : [[]]; +} +const extensions = permutations([0, 1, 2, 3, 4, 5, 6, 7]).filter((p) => + p.every((x, i) => p.slice(i + 1).every((y) => !subset(y, x) || y === x)), +); +const reversed = extensions.filter((p) => p.every((x, i) => p[7 - i] === complement(x))); +const text = (orders: number[][]) => orders.map((p) => p.map((x) => NAMES[x]).join("")).sort(); + +describe("weight chambers", () => { + it("narrows 48 inclusion orders to 12 reversed by complement, 6 with additive rank and 1 named order", () => { + const additive = reversed.filter((p) => + p.every((x) => p.indexOf(x) === SETS[x].reduce((sum, c) => sum + p.indexOf(SETS.findIndex((s) => s.join() === c)), 0)), + ); + expect([extensions, reversed, additive].map((set) => set.length)).toEqual([48, 12, 6]); + expect(FUNNEL.map((set) => set.length)).toEqual([48, 12, 6, 1]); + expect(text(FUNNEL[1])).toEqual(text(reversed)); + expect(text(FUNNEL[2])).toEqual(text(additive)); + expect(text(FUNNEL[3])).toEqual(["KBRMGCYW"]); + }); + + it("gives the twelve chambers the twelve complement-reversed orders, which are also the de Finetti orders", () => { + expect(CHAMBERS).toHaveLength(12); + expect(text(CHAMBERS.map(({ order }) => order))).toEqual(text(reversed)); + const definetti = extensions.filter((p) => + [...Array(8).keys()].every((a) => + [...Array(8).keys()].every((b) => + [...Array(8).keys()].every( + (c) => + a === b || + !disjoint(a, c) || + !disjoint(b, c) || + p.indexOf(a) < p.indexOf(b) === p.indexOf(union(a, c)) < p.indexOf(union(b, c)), + ), + ), + ), + ); + expect(text(definetti)).toEqual(text(reversed)); + }); + + it("orders every strict lattice point as its chamber does, and places the three conditions exactly in the A1 chamber", () => { + const inside = ([g, r]: Weights, triangle: Weights[]) => { + const side = (p: Weights, q: Weights) => (q[0] - p[0]) * (r - p[1]) - (q[1] - p[1]) * (g - p[0]); + const signs = triangle.map((p, i) => Math.sign(side(p, triangle[(i + 1) % 3]))); + return signs.every((s) => s > 0) || signs.every((s) => s < 0); + }; + let strict = 0; + for (const w of lattice) { + const expected = strictOrder(w); + const { order, strict: isStrict } = ranking(w); + expect(isStrict).toBe(expected !== null); + expect(conditions(w).every(({ holds }) => holds)).toBe(expected === "KBRMGCYW"); + if (!expected) continue; + strict++; + const homes = CHAMBERS.filter(({ vertices }) => inside(w, vertices)); + expect(homes).toHaveLength(1); + expect(homes[0].order.map((x) => NAMES[x]).join("")).toBe(expected); + expect(order.map((x) => NAMES[x]).join("")).toBe(expected); + } + expect(strict).toBeGreaterThan(10000); + }); + + it("takes the upper half from the dominant primary, or from majority when no weight exceeds half", () => { + for (const w of lattice) { + const expected = strictOrder(w); + if (!expected) continue; + const upper = [...expected.slice(4)].sort().join(""); + const dominant = PRIMARY.find((_, i) => 2 * w[i] > N); + const face = dominant ? NAMES.split("").filter((_, level) => SETS[level].includes(dominant)) : ["M", "C", "Y", "W"]; + expect(upper).toBe([...face].sort().join("")); + expect(upperHalf(ranking(w).order)).toBe(dominant ?? "majority"); + } + }); + + it("swaps on each wall exactly the pairs that tie there", () => { + const steps: Weights[] = [ + [1, -1, 0], + [1, 0, -1], + [0, 1, -1], + ]; + const move = (w: Weights, s: Weights, k: number): Weights => [w[0] + k * s[0], w[1] + k * s[1], w[2] + k * s[2]]; + for (const wall of WALLS) { + const form = (w: Weights, coefficients = wall.coefficients) => coefficients.reduce((sum, c, i) => sum + c * w[i], 0); + const others = WALLS.filter((other) => other !== wall); + // One lattice step changes any form by at most 2, so both neighbours stay off the other walls. + const point = lattice.find( + (w) => form(w) === 0 && w.every((v) => v >= 3) && others.every((other) => Math.abs(form(w, other.coefficients)) >= 3), + )!; + const ties = SETS.flatMap((_, x) => SETS.map((_, y) => [x, y]).filter(([, y]) => x < y && sigma(point, x) === sigma(point, y))); + const pairs = (list: number[][]) => list.map(([x, y]) => [NAMES[x], NAMES[y]].sort().join("")).sort(); + expect(pairs(wall.swaps)).toEqual(pairs(ties)); + const step = steps.find((s) => form(s) !== 0)!; + const before = strictOrder(move(point, step, -1))!.split(""); + const after = strictOrder(move(point, step, 1))!; + for (const [x, y] of wall.swaps) { + const i = before.indexOf(NAMES[x]); + const j = before.indexOf(NAMES[y]); + expect(Math.abs(i - j)).toBe(1); + [before[i], before[j]] = [before[j], before[i]]; + } + expect(before.join("")).toBe(after); + } + }); + + it("keeps weights on the lattice when snapping and parsing", () => { + for (const w of lattice) expect(snap(w.map((v) => v / N))).toEqual(w); + expect(snap([-0.2, 0.7, 0.9])[0]).toBe(0); + expect(snap([0.3, 0.3, 0.3]).reduce((a, b) => a + b)).toBe(N); + expect(parseWeights("96,48,24")).toEqual(A1_WEIGHTS); + for (const bad of [null, "", "96,48", "96,48,25", "96,-1,73", "96.5,47.5,24"]) expect(parseWeights(bad)).toBeNull(); + }); +}); diff --git a/prototypes/weight-chambers/model.ts b/prototypes/weight-chambers/model.ts new file mode 100644 index 00000000..8c39b46f --- /dev/null +++ b/prototypes/weight-chambers/model.ts @@ -0,0 +1,156 @@ +// Standalone prototype: no imports from the application or its state. +// The additive score sigma(S) = sum_{c in S} w_c on A = P({G,R,B}). Weights live on the integer +// lattice w_G + w_R + w_B = N, so every tie and every wall is decided exactly, without rounding. + +export const COLORS = [ + { name: "K", hex: "#000000" }, + { name: "B", hex: "#0000ff" }, + { name: "R", hex: "#ff0000" }, + { name: "M", hex: "#ff00ff" }, + { name: "G", hex: "#00ff00" }, + { name: "C", hex: "#00ffff" }, + { name: "Y", hex: "#ffff00" }, + { name: "W", hex: "#ffffff" }, +] as const; + +// 168 is divisible by 2, 3, 4 and 7: 4:2:1 (96:48:24), the centre (56:56:56) and the walls +// w_i = 84 are all lattice points. +export const N = 168; +export type Weights = readonly [g: number, r: number, b: number]; +export const A1_WEIGHTS: Weights = [96, 48, 24]; +const PRIMARIES = [ + { name: "G", bit: 4 }, + { name: "R", bit: 2 }, + { name: "B", bit: 1 }, +] as const; +type Primary = (typeof PRIMARIES)[number]["name"]; +const LEVELS = [0, 1, 2, 3, 4, 5, 6, 7]; + +export const name = (level: number) => COLORS[level].name; +export const bits = (level: number) => level.toString(2).padStart(3, "0"); +export const score = (w: Weights, level: number) => PRIMARIES.reduce((sum, { bit }, i) => (level & bit ? sum + w[i] : sum), 0); + +// States in ascending score, ties in ascending level. `rank` counts the states with a strictly +// lower score, so tied states share a rank; `tied[i]` compares order[i] with order[i + 1]. +export function ranking(w: Weights) { + const order = [...LEVELS].sort((x, y) => score(w, x) - score(w, y) || x - y); + const tied = order.slice(1).map((level, i) => score(w, level) === score(w, order[i])); + const rank = order.map((level) => LEVELS.filter((other) => score(w, other) < score(w, level)).length); + return { order, tied, rank, strict: !tied.includes(true) }; +} + +export const orderText = (order: readonly number[], tied: readonly boolean[]) => + order.map((level, i) => (i ? (tied[i - 1] ? "=" : "<") : "") + name(level)).join(""); + +export const conditions = (w: Weights) => [ + { label: ["w", "B", " > 0"], holds: w[2] > 0 }, + { label: ["w", "R", " > w", "B"], holds: w[1] > w[2] }, + { label: ["w", "G", " > w", "R", " + w", "B"], holds: w[0] > w[1] + w[2] }, +]; + +// Each interior wall is where a linear form in (w_G, w_R, w_B) vanishes. Crossing it swaps exactly +// the pairs whose [G,R,B] bits differ by the form's coefficients. +const bit = (level: number, i: number) => (level >> (2 - i)) & 1; +export const WALLS = [ + { label: ["w", "G", " = w", "R", " + w", "B"], coefficients: [1, -1, -1] }, + { label: ["w", "R", " = w", "G", " + w", "B"], coefficients: [-1, 1, -1] }, + { label: ["w", "B", " = w", "G", " + w", "R"], coefficients: [-1, -1, 1] }, + { label: ["w", "G", " = w", "R"], coefficients: [1, -1, 0] }, + { label: ["w", "G", " = w", "B"], coefficients: [1, 0, -1] }, + { label: ["w", "R", " = w", "B"], coefficients: [0, 1, -1] }, +].map((wall) => ({ + ...wall, + swaps: LEVELS.flatMap((x) => LEVELS.filter((y) => wall.coefficients.every((c, i) => bit(x, i) - bit(y, i) === c)).map((y) => [x, y])), +})); +export const onWall = (w: Weights, coefficients: readonly number[]) => coefficients.reduce((sum, c, i) => sum + c * w[i], 0) === 0; + +// The four upper halves a complement-reversed order can have: a cube face "contains p", or majority. +export type Half = Primary | "majority"; +export const HALVES: Record = { + G: LEVELS.filter((level) => level & 4), + R: LEVELS.filter((level) => level & 2), + B: LEVELS.filter((level) => level & 1), + majority: LEVELS.filter((level) => bits(level).split("1").length > 2), +}; +export function upperHalf(order: readonly number[]): Half | null { + const upper = [...order.slice(4)].sort(); + return (Object.keys(HALVES) as Half[]).find((half) => HALVES[half].join() === upper.join()) ?? null; +} + +// Orders of the eight states that extend inclusion: a state enters only after all its subsets. +function linearExtensions() { + const out: number[][] = []; + const walk = (prefix: number[]) => { + if (prefix.length === 8) return void out.push(prefix); + for (const level of LEVELS) { + const ready = LEVELS.every((below) => below === level || (below & level) !== below || prefix.includes(below)); + if (!prefix.includes(level) && ready) walk([...prefix, level]); + } + }; + walk([]); + return out; +} +export const complementReversed = (order: readonly number[]) => order.every((level, i) => order[7 - i] === (level ^ 7)); +export const additiveRank = (order: readonly number[]) => + LEVELS.every((level) => order.indexOf(level) === PRIMARIES.reduce((sum, { bit }) => (level & bit ? sum + order.indexOf(bit) : sum), 0)); +export const isA1 = (order: readonly number[]) => order.every((level, i) => level === i); + +const extensions = linearExtensions(); +export const FUNNEL = [ + extensions, + extensions.filter(complementReversed), + extensions.filter((order) => complementReversed(order) && additiveRank(order)), + extensions.filter(isA1), +]; + +// Twelve chambers: each corner triangle (one weight above N/2) is halved by its median, and the +// medial triangle is cut into six by the three medians. +const H = N / 2; +const Q = N / 4; +const T = N / 3; +const edgeMidpoints: Weights[] = [ + [H, H, 0], + [H, 0, H], + [0, H, H], +]; +const [GR, GB, RB] = edgeMidpoints; +const halfway: Record = { G: [H, Q, Q], R: [Q, H, Q], B: [Q, Q, H] }; +const ring = [GR, halfway.G, GB, halfway.B, RB, halfway.R]; +const chamberVertices: Weights[][] = [ + [[N, 0, 0], GR, halfway.G], + [[N, 0, 0], halfway.G, GB], + [[0, N, 0], GR, halfway.R], + [[0, N, 0], halfway.R, RB], + [[0, 0, N], GB, halfway.B], + [[0, 0, N], halfway.B, RB], + ...ring.map((point, i): Weights[] => [[T, T, T], point, ring[(i + 1) % ring.length]]), +]; +export const CHAMBERS = chamberVertices.map((vertices) => { + // Three times the centroid: the order is scale-invariant and the sum stays an integer. + const sum = (i: number) => vertices.reduce((total, v) => total + v[i], 0); + const order = ranking([sum(0), sum(1), sum(2)]).order; + return { vertices, order, half: upperHalf(order) }; +}); + +// Rounds barycentric fractions onto the lattice by largest remainder, clamping them into the triangle. +export function snap(fractions: readonly number[]): Weights { + const clamped = fractions.map((f) => Math.max(0, f)); + const total = clamped.reduce((a, b) => a + b, 0) || 1; + const scaled = clamped.map((f) => (f / total) * N); + const floors = scaled.map(Math.floor); + const byRemainder = [0, 1, 2].sort((i, j) => scaled[j] - floors[j] - (scaled[i] - floors[i])); + for (let rest = N - floors.reduce((a, b) => a + b, 0), k = 0; rest > 0; rest--, k++) floors[byRemainder[k]]++; + return [floors[0], floors[1], floors[2]]; +} + +export function parseWeights(text: string | null): Weights | null { + const parts = text?.split(",").map(Number) ?? []; + if (parts.length !== 3 || parts.some((v) => !Number.isInteger(v) || v < 0) || parts[0] + parts[1] + parts[2] !== N) return null; + return [parts[0], parts[1], parts[2]]; +} + +const gcd = (a: number, b: number): number => (b ? gcd(b, a % b) : a); +export const reduced = (w: Weights) => { + const d = w.reduce(gcd); + return w.map((v) => v / d); +}; diff --git a/prototypes/weight-chambers/playwright.config.ts b/prototypes/weight-chambers/playwright.config.ts new file mode 100644 index 00000000..b584d236 --- /dev/null +++ b/prototypes/weight-chambers/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, devices } from "@playwright/test"; +import { fileURLToPath } from "node:url"; + +export default defineConfig({ + testDir: ".", + testMatch: "browser.check.ts", + outputDir: "../../test-results/weight-chambers-lab", + timeout: 30_000, + reporter: "list", + use: { + baseURL: "http://127.0.0.1:4173/chromalum/", + reducedMotion: "reduce", + screenshot: "only-on-failure", + }, + webServer: { + command: "npm run dev -- --host 127.0.0.1 --port 4173", + cwd: fileURLToPath(new URL("../..", import.meta.url)), + url: "http://127.0.0.1:4173/chromalum/", + reuseExistingServer: true, + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], +}); diff --git a/prototypes/weight-chambers/style.css b/prototypes/weight-chambers/style.css new file mode 100644 index 00000000..f05430eb --- /dev/null +++ b/prototypes/weight-chambers/style.css @@ -0,0 +1,417 @@ +:root { + color-scheme: dark; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Yu Gothic UI", sans-serif; + background: #0b0c14; + color: #e5e7f3; + font-synthesis: none; + --muted: #a3a8c1; + --accent: #9bacff; + --line: #8c99b7; + --panel: #111625; + --mono: Consolas, "SFMono-Regular", monospace; +} +* { + box-sizing: border-box; +} +body { + margin: 0; +} +button { + font: inherit; + cursor: pointer; +} +#app { + max-width: 1080px; + margin: 0 auto; + padding: 28px 24px 24px; + container: weights / inline-size; +} +.eyebrow { + display: flex; + gap: 14px; + align-items: center; + margin: 0 0 18px; + font: 11px var(--mono); + letter-spacing: 0.09em; + color: var(--muted); +} +.eyebrow span { + font-size: 9px; + border: 1px solid #343a55; + border-radius: 4px; + padding: 4px 6px; + color: var(--accent); +} +h1 { + font-size: clamp(22px, 3.6vw, 30px); + font-weight: 600; + margin: 0 0 16px; +} +h2 { + font-size: 17px; + font-weight: 500; + margin: 0 0 12px; +} +.intro, +.hint, +.note, +.theorem, +figcaption { + color: var(--muted); + font-size: 13px; + line-height: 1.8; + margin: 0; + overflow-wrap: anywhere; +} +.intro { + font-size: 14px; + line-height: 1.9; +} +sub { + font-size: 0.72em; +} +.lab { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 24px; + margin-top: 26px; + border-top: 1px solid #343a50; + padding-top: 24px; +} +.triangle { + margin: 0; + min-width: 0; +} +.triangle-frame, +.strip-frame { + width: 100%; + min-width: 0; +} +.triangle-frame { + touch-action: none; + user-select: none; +} +.triangle-svg, +.strip-svg { + display: block; +} +.triangle-svg text, +.strip-svg text, +.hasse text { + font-family: var(--mono); + text-anchor: middle; +} +.chamber { + fill-opacity: 0.1; + stroke: none; +} +.chamber[data-half="majority"] { + fill-opacity: 0.05; +} +.chamber[data-current] { + fill-opacity: 0.34; +} +.chamber[data-half="majority"][data-current] { + fill-opacity: 0.2; +} +.outline { + fill: none; + stroke: #bec9e5; + stroke-width: 1.5; +} +.wall { + stroke: var(--line); + stroke-width: 1; + stroke-dasharray: 4 4; +} +.wall[data-on] { + stroke: #ffd35c; + stroke-width: 2.5; + stroke-dasharray: none; +} +.a1-outline { + fill: none; + stroke: #ffffff; + stroke-width: 2.5; + stroke-linejoin: round; + pointer-events: none; +} +.corner { + font-size: 15px; + font-weight: 700; +} +.handle { + cursor: grab; + outline: none; +} +.handle-ring { + fill: none; + stroke: #0b0c14; + stroke-width: 4; +} +.handle-dot { + fill: #ffffff; + stroke: #0b0c14; + stroke-width: 2; +} +.handle:focus-visible .handle-ring { + stroke: #dce1ff; + stroke-width: 2.5; +} +.readouts { + display: grid; + gap: 14px; + min-width: 0; + align-content: start; +} +.weights { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; + align-items: center; + justify-content: space-between; +} +.weights p { + margin: 0; + font-size: 15px; +} +.weights-value, +.ratio { + font-family: var(--mono); + white-space: pre; +} +.ratio { + display: inline-block; + min-width: 14ch; + color: var(--muted); +} +.reset { + min-height: 40px; + padding: 6px 12px; + border: 1px solid #596ba5; + border-radius: 6px; + background: #1b2239; + color: #edf0ff; + font-size: 13px; +} +.reset:focus-visible { + outline: 2px solid #dce1ff; + outline-offset: 2px; +} +.conditions { + display: flex; + flex-wrap: wrap; + gap: 6px 22px; + margin: 0; + padding: 10px 12px; + list-style: none; + border-radius: 8px; + background: var(--panel); + font-size: 15px; +} +.conditions li { + display: flex; + gap: 6px; + align-items: baseline; +} +.mark { + display: inline-block; + width: 1.3em; + text-align: center; + font-family: var(--mono); +} +[data-holds="true"] .mark, +[data-state="true"] .mark { + color: #7dff9a; +} +[data-holds="false"] .mark, +[data-state="false"] .mark { + color: #ff8a8a; +} +[data-state="tied"] .mark { + color: var(--muted); +} +.order { + margin: 0; + font-size: 13px; + color: var(--muted); +} +.order code { + margin-left: 6px; + font: 17px var(--mono); + color: #e5e7f3; + letter-spacing: 0.04em; +} +.tick, +.half { + fill: var(--muted); + font-size: 11px; +} +.axis, +.centre { + stroke: var(--line); + stroke-width: 1.2; +} +.centre { + stroke-dasharray: 3 3; +} +.link { + stroke-width: 1.4; + stroke-opacity: 0.75; +} +.marker, +.slot rect, +.hasse circle { + stroke: #ffffff66; + stroke-width: 1; +} +.slot-name { + font-size: 12px; + font-weight: 600; +} +.slot-bits { + fill: #c8d2e9; + font-size: 10px; +} +.slot-rank { + fill: var(--accent); + font-size: 12px; +} +.divider { + stroke: var(--line); + stroke-dasharray: 3 3; +} +.tie { + stroke: #ffd35c; + stroke-width: 2; +} +.halves { + display: flex; + flex-wrap: wrap; + gap: 8px 18px; + align-items: center; +} +.hasse { + flex: none; +} +.hasse-edge { + stroke: #4b5578; + stroke-width: 1.2; +} +.hasse-node { + opacity: 0.3; +} +.hasse-node[data-upper] { + opacity: 1; +} +.hasse-node[data-upper] circle { + stroke: #ffffff; + stroke-width: 2.5; +} +.hasse text { + font-size: 11px; + font-weight: 700; +} +.halves > div { + flex: 1 1 220px; + min-width: 0; + display: grid; + gap: 6px; +} +/* The five labels share one cell, so the row keeps the tallest height whatever is shown. */ +.half-labels { + display: grid; +} +.half-labels p { + grid-area: 1 / 1; + margin: 0; + font-size: 15px; + line-height: 1.6; + overflow-wrap: anywhere; +} +.half-labels [aria-hidden="true"] { + visibility: hidden; +} +.funnel { + display: grid; + gap: 4px; + margin: 0; + padding: 0; + list-style: none; +} +.funnel li { + display: grid; + grid-template-columns: 3ch minmax(0, 1fr) 1.3em; + gap: 10px; + align-items: baseline; + padding: 7px 10px; + border-radius: 6px; + background: var(--panel); + font-size: 13px; + line-height: 1.6; +} +.funnel li:nth-child(2) { + margin-left: 12px; +} +.funnel li:nth-child(3) { + margin-left: 24px; +} +.funnel li:nth-child(4) { + margin-left: 36px; +} +.count { + font: 600 15px var(--mono); + color: var(--accent); + text-align: right; +} +.walls-section { + margin-top: 30px; + border-top: 1px solid #343a50; + padding-top: 22px; +} +.walls { + width: 100%; + max-width: 560px; + border-collapse: collapse; + margin-bottom: 10px; + font-size: 14px; +} +.walls th, +.walls td { + padding: 7px 10px; + border-bottom: 1px solid #262c42; + text-align: left; + font-weight: 400; +} +.walls thead th { + color: var(--muted); + font-size: 12px; +} +.walls td { + font-family: var(--mono); +} +.walls tr[data-on] th, +.walls tr[data-on] td { + background: #3a3314; + color: #ffe9a6; +} +footer { + border-top: 1px solid #202539; + margin-top: 30px; + padding-top: 16px; + color: #858da8; + font-size: 11px; + text-align: center; + line-height: 1.8; +} +@container weights (min-width: 760px) { + .lab { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + align-items: start; + } +} +@container weights (max-width: 420px) { + .funnel li:nth-child(n) { + margin-left: 0; + } +} diff --git a/prototypes/weight-chambers/tsconfig.json b/prototypes/weight-chambers/tsconfig.json new file mode 100644 index 00000000..3989f5df --- /dev/null +++ b/prototypes/weight-chambers/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { "types": ["vite/client", "node"] }, + "include": ["./main.ts", "./model.ts", "./model.test.ts", "./browser.check.ts", "./playwright.config.ts"] +} From 5cfa60c9f618d6446999520c435c2674d5843ca8 Mon Sep 17 00:00:00 2001 From: minoreality <244164377+minoreality@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:41:59 +0900 Subject: [PATCH 2/2] Show the weight-chambers reduced ratio on the w_G = 0 edge On the w_G = 0 edge the readout never showed the reduced ratio: 0,84,84 showed nothing while 84,84,0 showed = 1 : 1 : 0, 121 of the 5,077 reducible lattice points in all. It hid the ratio when ratio[0] === weights[0], meant as "no common factor", which holds for any factor once w_G = 0. Hide it only when the reduction leaves every weight unchanged. Co-Authored-By: Claude Opus 5.5 --- prototypes/weight-chambers/README.md | 2 +- prototypes/weight-chambers/browser.check.ts | 13 +++++++++++++ prototypes/weight-chambers/main.ts | 3 ++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/prototypes/weight-chambers/README.md b/prototypes/weight-chambers/README.md index 07d5a618..82af13f9 100644 --- a/prototypes/weight-chambers/README.md +++ b/prototypes/weight-chambers/README.md @@ -20,4 +20,4 @@ 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 { 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}`); diff --git a/prototypes/weight-chambers/main.ts b/prototypes/weight-chambers/main.ts index df6cf1ae..3c97e26f 100644 --- a/prototypes/weight-chambers/main.ts +++ b/prototypes/weight-chambers/main.ts @@ -264,8 +264,9 @@ function update() { wall.toggleAttribute("data-on", onWall(weights, WALLS[Number(wall.dataset.wall)].coefficients)); } document.querySelector(".weights-value")!.textContent = weights.map((v) => String(v).padStart(3, " ")).join(" : "); + // A shorter ratio exists only when the weights share a factor. Compare every weight: 0 stays 0 under any factor. const ratio = reduced(weights); - document.querySelector(".ratio")!.textContent = ratio[0] === weights[0] ? "" : `= ${ratio.join(" : ")}`; + document.querySelector(".ratio")!.textContent = ratio.every((v, i) => v === weights[i]) ? "" : `= ${ratio.join(" : ")}`; conditions(weights).forEach(({ holds }, i) => { const item = document.querySelectorAll(".conditions li")[i]; item.dataset.holds = String(holds);