wordle-five: the engine, the reward, the solver and the probe that checks them
The Python is the source of truth; src/demos/wordle/engine.ts will be a port of it, and CI gates the two against a SHA-256 over all 21.2M (guess, answer) pattern pairs rather than a hand-picked vector file — a vector file only ever catches the cases somebody thought of. The reward is three weighted components, and the third one is the reason this demo is worth building. `solved` and `economy` pull toward winning. `consistency` pulls against them, because a player maximising information deliberately guesses words that cannot win — a word that splits the remaining candidates evenly teaches more than a word that might happen to be right. That is good play, and it costs consistency. The probe ladder proves the tension is real rather than asserted: inaction 0.0000 crude 0.0111 plausible 0.1224 candidate_only 0.8925 exhaustive 0.9031 oracle 0.9458 The two good policies are 0.05 apart and neither dominates — the entropy oracle takes 1.00 economy and 0.73 consistency, the candidate-only player takes 0.75 and 1.00. Which one wins is a decision about what you want, which is the whole argument the site exists to make. probe.py fails CI if either starts dominating. Two traps found by building it. `consistency` is scored over turns SPENT, not guesses accepted: counting only legal guesses hands a free 1.0 to a policy that plays one word and then jams the parser five times — one guess, no contradictions, perfect score. And `economy`'s denominator is the depth the SHIPPED solver reaches, not a depth-optimal search: entropy-greedy is not depth-optimal, so grading it against an exact optimum would make the oracle rung fail its own assertion on some seeds. The word lists are built from Wordnik (MIT) intersected with SCOWL, never from the original game's 2,315 answers. 4,603 answers makes this materially harder than the original, so the published SALET/3.4212 results are cited as belonging to that list and our own reference player's TARES/3.72 is measured here. verifiers is an optional extra. The engine, reward, solver and probe all run — and gate — without an RL stack resolvable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
PIG-Demo
|
||||
Copyright 2026 Karti Tripathi
|
||||
|
||||
This product includes software and data developed by third parties, listed
|
||||
below with their licences. Full licence texts are linked; where a licence
|
||||
requires a copy to be distributed, it is included in this repository.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
shadcn/ui — MIT
|
||||
https://github.com/shadcn-ui/ui
|
||||
The components under src/components/ui/ are derived from shadcn/ui's registry
|
||||
and adapted by hand. navigation-menu.tsx is a substantial rewrite: the upstream
|
||||
version targets Tailwind 4 and this project is on Tailwind 3.4.
|
||||
|
||||
Radix UI — MIT
|
||||
https://github.com/radix-ui/primitives
|
||||
The unstyled primitives those components are built on.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
Wordnik word list — MIT
|
||||
https://github.com/wordnik/wordlist
|
||||
Snapshot wordlist-20210729.txt. Source of envs/wordle_five/words/guesses.json.
|
||||
|
||||
SCOWL (Spell Checker Oriented Word Lists) — permissive, attribution required
|
||||
http://wordlist.aspell.net/
|
||||
Copyright 2000-2020 Kevin Atkinson. The `wamerican` list is used, in
|
||||
intersection with the above, to derive envs/wordle_five/words/answers.json.
|
||||
See envs/wordle_five/words/PROVENANCE.md for exactly how.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
Manrope — SIL Open Font License 1.1
|
||||
https://github.com/sharanda/manrope
|
||||
Copyright 2019 The Manrope Project Authors. Licence text: public/fonts/OFL.txt
|
||||
"Manrope" is a Reserved Font Name under that licence: a modified version of the
|
||||
font may not be distributed under that name.
|
||||
|
||||
Lucide — ISC
|
||||
https://github.com/lucide-icons/lucide
|
||||
Icons, themselves derived from Feather (MIT, copyright 2013-2017 Cole Bemis).
|
||||
|
||||
Recharts — MIT
|
||||
https://github.com/recharts/recharts
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
PIG (Prime Intellect Growth) — Apache-2.0
|
||||
https://github.com/karti-ai/PIG-Demo is a sibling of that project. The design
|
||||
token layer in src/index.css and the palette in tailwind.config.js are derived
|
||||
from it. Same author; the notice is here because this repository is public and
|
||||
Apache-2.0 section 4(d) asks for it either way.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
Not affiliated with, endorsed by, or derived from the code or assets of The New
|
||||
York Times Company or its games. "wordle-five" is an independent implementation
|
||||
of the well-known five-letter word-guessing format, with its own word lists,
|
||||
its own palette, and its own reward.
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The probe ladder: does this reward actually measure anything?
|
||||
|
||||
A reward function is a claim, and an unprobed one is an unchecked claim. The
|
||||
ladder plays a set of deliberately-crafted policies — from doing nothing at all
|
||||
to the best play we can write — and asserts that the reward orders them the way
|
||||
a person would, per weighted component rather than as one blended float.
|
||||
|
||||
A blended float hides the failure this is built to catch: a component that is
|
||||
flat across every policy is measuring nothing, and it will still move the total
|
||||
because the other components move.
|
||||
|
||||
Run: uv run python envs/probe.py
|
||||
Exits non-zero on any violated assertion.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent / "wordle_five"))
|
||||
|
||||
from wordle_five import solver as S # noqa: E402
|
||||
from wordle_five.engine import ( # noqa: E402
|
||||
MAX_GUESSES,
|
||||
Game,
|
||||
answer_for_seed,
|
||||
answers,
|
||||
score_guess,
|
||||
)
|
||||
from wordle_five.protocol import parse_guess # noqa: E402
|
||||
from wordle_five.reward import ROLES, WEIGHTS, Episode, metrics, score, total # noqa: E402
|
||||
|
||||
SEEDS = list(range(16))
|
||||
COMPONENTS = ("solved", "economy", "consistency")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- policies --
|
||||
# Each policy is a function (game) -> reply text. They return TEXT, not moves,
|
||||
# so the parser and the rejection path are exercised exactly as a model would
|
||||
# exercise them. A policy that returns unparseable prose is a valid policy.
|
||||
|
||||
|
||||
def policy_inaction(game: Game) -> str:
|
||||
"""Reasons at length and never commits. The floor: must measure 0.000."""
|
||||
return "Let me think carefully about which word would be most informative here."
|
||||
|
||||
|
||||
def policy_spammer(game: Game) -> str:
|
||||
"""Multiple bracketed tokens. Only the first is read; the rest are noise."""
|
||||
return "Maybe [crane] or [slate] or [adieu]?"
|
||||
|
||||
|
||||
def policy_crude(game: Game) -> str:
|
||||
"""One valid word, forever. Legal, useless, and it repeats itself."""
|
||||
return "[tares]"
|
||||
|
||||
|
||||
def policy_plausible(game: Game) -> str:
|
||||
"""Keeps greens, drops known-absent letters. What a person does unaided."""
|
||||
pool = answers()
|
||||
if not game.history:
|
||||
return "[tares]"
|
||||
greens: dict[int, str] = {}
|
||||
absent: set[str] = set()
|
||||
for guess, pattern in game.history:
|
||||
for i, tile in enumerate(pattern):
|
||||
if tile == "G":
|
||||
greens[i] = guess[i]
|
||||
elif tile == "X":
|
||||
absent.add(guess[i])
|
||||
played = {g for g, _ in game.history}
|
||||
for word in pool:
|
||||
if word in played:
|
||||
continue
|
||||
if any(word[i] != c for i, c in greens.items()):
|
||||
continue
|
||||
if any(c in absent for c in word):
|
||||
continue
|
||||
return f"[{word}]"
|
||||
return "[tares]"
|
||||
|
||||
|
||||
def policy_candidate_only(game: Game) -> str:
|
||||
"""Only ever guesses words that could still be the answer.
|
||||
|
||||
This is the policy the counterweight rewards. It never wastes a guess, and
|
||||
it is measurably slower than the oracle because it cannot buy information
|
||||
with a word that has no chance of winning.
|
||||
"""
|
||||
alive = S.consistent_candidates(game.history)
|
||||
played = {g for g, _ in game.history}
|
||||
for word in alive:
|
||||
if word not in played:
|
||||
return f"[{word}]"
|
||||
return "[tares]"
|
||||
|
||||
|
||||
def policy_oracle(game: Game) -> str:
|
||||
"""Greedy maximum entropy — the reference player, and the economy denominator."""
|
||||
import numpy as np
|
||||
|
||||
pool = answers()
|
||||
if not game.history:
|
||||
return f"[{pool[S._best_opener()]}]"
|
||||
alive = S.consistent_candidates(game.history)
|
||||
if not alive:
|
||||
return "[tares]"
|
||||
index = np.array([pool.index(w) for w in alive])
|
||||
return f"[{pool[S.best_guess(index)]}]"
|
||||
|
||||
|
||||
def policy_exhaustive(game: Game) -> str:
|
||||
"""Knows the answer but burns three turns on known-wrong probes first.
|
||||
|
||||
Proves the turn budget actually binds: this must score strictly below the
|
||||
oracle, or `economy` is not doing its job.
|
||||
"""
|
||||
if len(game.history) < 3:
|
||||
probes = ["fuzzy", "jumbo", "whelp"]
|
||||
pick = probes[len(game.history)]
|
||||
return f"[{pick if pick != game.answer else 'tares'}]"
|
||||
return f"[{game.answer}]"
|
||||
|
||||
|
||||
POLICIES = {
|
||||
"inaction": policy_inaction,
|
||||
"spammer": policy_spammer,
|
||||
"crude": policy_crude,
|
||||
"plausible": policy_plausible,
|
||||
"candidate_only": policy_candidate_only,
|
||||
"exhaustive": policy_exhaustive,
|
||||
"oracle": policy_oracle,
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ runner --
|
||||
|
||||
|
||||
def play(policy, seed: int) -> Episode:
|
||||
game = Game(seed=seed)
|
||||
for _ in range(MAX_GUESSES * 3): # room for rejected replies
|
||||
if game.over:
|
||||
break
|
||||
guess = parse_guess(policy(game))
|
||||
if guess is None:
|
||||
game.rejected += 1
|
||||
# An unparseable reply still costs a turn of patience, or a policy
|
||||
# that never guesses would loop until the budget above runs out and
|
||||
# look identical to one that guessed badly.
|
||||
if game.rejected >= MAX_GUESSES:
|
||||
break
|
||||
continue
|
||||
game.play(guess)
|
||||
return Episode(
|
||||
answer=game.answer,
|
||||
guesses=[g for g, _ in game.history],
|
||||
patterns=[p for _, p in game.history],
|
||||
rejected=game.rejected,
|
||||
reference_depth=S.reference_depth(game.answer),
|
||||
)
|
||||
|
||||
|
||||
def run() -> int:
|
||||
print(f"Probing wordle-five over {len(SEEDS)} seeds\n")
|
||||
results: dict[str, dict[str, float]] = {}
|
||||
extra: dict[str, dict[str, float]] = {}
|
||||
|
||||
for name, policy in POLICIES.items():
|
||||
episodes = [play(policy, s) for s in SEEDS]
|
||||
scores = [score(e) for e in episodes]
|
||||
results[name] = {c: sum(s[c] for s in scores) / len(scores) for c in COMPONENTS}
|
||||
results[name]["total"] = sum(total(e) for e in episodes) / len(episodes)
|
||||
mets = [metrics(e) for e in episodes]
|
||||
extra[name] = {k: sum(m[k] for m in mets) / len(mets) for k in mets[0]}
|
||||
|
||||
width = max(len(n) for n in POLICIES)
|
||||
header = f"{'policy':<{width}} " + " ".join(f"{c:>12}" for c in COMPONENTS) + f" {'TOTAL':>8}"
|
||||
print(header)
|
||||
print("-" * len(header))
|
||||
for name in POLICIES:
|
||||
row = results[name]
|
||||
cells = " ".join(f"{row[c]:>12.4f}" for c in COMPONENTS)
|
||||
print(f"{name:<{width}} {cells} {row['total']:>8.4f}")
|
||||
|
||||
print(f"\nweights: {WEIGHTS}")
|
||||
print(f"roles: {ROLES}")
|
||||
print("\nguesses used (mean):", {n: round(extra[n]['guesses_used'], 2) for n in POLICIES})
|
||||
|
||||
failures: list[str] = []
|
||||
|
||||
# 1. Doing nothing must measure exactly nothing. If a policy that never
|
||||
# plays can collect reward, every number above it is inflated.
|
||||
if abs(results["inaction"]["total"]) > 1e-9:
|
||||
failures.append(f"inaction scored {results['inaction']['total']:.6f}, must be 0.000")
|
||||
|
||||
# 2. The reference player must reach the top of both objective components,
|
||||
# or `economy`'s denominator disagrees with the policy that defines it.
|
||||
if abs(results["oracle"]["solved"] - 1.0) > 1e-9:
|
||||
failures.append(f"oracle solved {results['oracle']['solved']:.4f}, must be 1.0")
|
||||
if abs(results["oracle"]["economy"] - 1.0) > 1e-9:
|
||||
failures.append(f"oracle economy {results['oracle']['economy']:.4f}, must be 1.0")
|
||||
|
||||
# 3. The counterweight must actually bite the policy it is aimed at. The
|
||||
# oracle buys information with guesses that cannot win; if that were
|
||||
# free, `consistency` would be a gate wearing a counterweight's name.
|
||||
if results["oracle"]["consistency"] >= 1.0 - 1e-9:
|
||||
failures.append("oracle scored a perfect consistency — the counterweight is not in tension")
|
||||
|
||||
# 4. And the policy that pays the counterweight's price must collect it.
|
||||
if abs(results["candidate_only"]["consistency"] - 1.0) > 1e-9:
|
||||
failures.append(
|
||||
f"candidate_only consistency {results['candidate_only']['consistency']:.4f}, must be 1.0"
|
||||
)
|
||||
|
||||
# 5. Neither of the two good policies may dominate the other on every
|
||||
# component. If one did, the reward would encode a single right answer
|
||||
# and the reward editor would be theatre.
|
||||
oracle, cand = results["oracle"], results["candidate_only"]
|
||||
if all(oracle[c] >= cand[c] - 1e-9 for c in COMPONENTS):
|
||||
failures.append("oracle dominates candidate_only on every component — no real trade-off")
|
||||
if all(cand[c] >= oracle[c] - 1e-9 for c in COMPONENTS):
|
||||
failures.append("candidate_only dominates oracle on every component — no real trade-off")
|
||||
|
||||
# 6. Burning turns on known-wrong probes must cost.
|
||||
if results["exhaustive"]["total"] >= results["oracle"]["total"] - 1e-9:
|
||||
failures.append("exhaustive >= oracle — the turn budget does not bind")
|
||||
|
||||
# 7. No weighted component may be flat. A component with the same value for
|
||||
# every policy is measuring nothing and still moves the total.
|
||||
for component in COMPONENTS:
|
||||
values = [results[n][component] for n in POLICIES]
|
||||
if max(values) - min(values) < 1e-9:
|
||||
failures.append(f"component '{component}' is flat across the ladder — it measures nothing")
|
||||
|
||||
print()
|
||||
if failures:
|
||||
for f in failures:
|
||||
print(f"FAIL: {f}")
|
||||
return 1
|
||||
print(f"All {7} ladder assertions hold.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(run())
|
||||
@@ -0,0 +1 @@
|
||||
69f4e8dfbdc492176d7b7f04b080d8d3cccb5f18aca2d592e622938a5aeec8e2 sha256 over 4603^2 (guess,answer) pattern pairs, answers.json order
|
||||
@@ -0,0 +1,22 @@
|
||||
[project]
|
||||
name = "wordle-five"
|
||||
version = "0.1.0"
|
||||
description = "A five-letter word-guessing environment with a three-component reward"
|
||||
requires-python = ">=3.11,<3.14"
|
||||
dependencies = ["numpy>=2.0"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
# verifiers is optional so the engine, the solver, the reward and the probe all
|
||||
# run — and CI gates on them — without pulling a heavyweight RL stack. Only
|
||||
# taskset.py needs it.
|
||||
verifiers = ["verifiers>=0.3.2.dev12"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["wordle_five"]
|
||||
|
||||
[tool.hatch.build]
|
||||
include = ["wordle_five/**", "words/**", "pyproject.toml", "README.md"]
|
||||
@@ -0,0 +1,142 @@
|
||||
"""The duplicate-letter cases first, because they are the ones that get shipped wrong."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from wordle_five.engine import (
|
||||
Game,
|
||||
answer_for_seed,
|
||||
answers,
|
||||
conformance_digest,
|
||||
guesses,
|
||||
hard_mode_violation,
|
||||
is_consistent,
|
||||
score_guess,
|
||||
)
|
||||
|
||||
# Every one of these was verified against an independent implementation and
|
||||
# against TextArena's own scorer before being written down. They are the
|
||||
# regression surface for the two-pass rule.
|
||||
VECTORS = [
|
||||
("alloy", "llama", "YGYXX"), # greens claim first, yellows take the rest
|
||||
("speed", "erase", "YXYYX"), # both E's yellow: exactly two are available
|
||||
("array", "radar", "YYYGX"), # double-double with one green
|
||||
("sassy", "basis", "YGGXX"), # 3 S guessed, 2 in answer: one Y, one X
|
||||
("eerie", "rebel", "YGYXX"), # the same shape on E
|
||||
("level", "eagle", "YYXYX"), # two repeated letters, no greens at all
|
||||
("geese", "these", "XXGGG"), # greens exhaust the pool, leading E greys
|
||||
("abbey", "abbot", "GGGXX"),
|
||||
("crane", "plane", "XXGGG"),
|
||||
("alloy", "balmy", "YXGXG"),
|
||||
("tares", "tares", "GGGGG"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("guess,answer,expected", VECTORS)
|
||||
def test_scoring_vectors(guess: str, answer: str, expected: str) -> None:
|
||||
assert score_guess(guess, answer) == expected
|
||||
|
||||
|
||||
def test_score_is_case_insensitive() -> None:
|
||||
assert score_guess("ALLOY", "LLAMA") == score_guess("alloy", "llama")
|
||||
|
||||
|
||||
def test_a_letter_is_never_marked_more_often_than_it_occurs() -> None:
|
||||
"""The invariant the two passes exist to preserve, checked over the list."""
|
||||
pool = answers()[:200]
|
||||
for answer in pool:
|
||||
for guess in pool[:50]:
|
||||
pattern = score_guess(guess, answer)
|
||||
for letter in set(guess):
|
||||
marked = sum(
|
||||
1 for i, c in enumerate(guess) if c == letter and pattern[i] != "X"
|
||||
)
|
||||
assert marked <= answer.count(letter)
|
||||
|
||||
|
||||
def test_consistency_is_symmetric_with_scoring() -> None:
|
||||
"""`is_consistent` must agree with `score_guess`, since the reward uses it."""
|
||||
for answer in answers()[:100]:
|
||||
guess = "tares"
|
||||
pattern = score_guess(guess, answer)
|
||||
assert is_consistent(answer, guess, pattern)
|
||||
|
||||
|
||||
def test_every_answer_is_guessable() -> None:
|
||||
assert set(answers()) <= guesses()
|
||||
|
||||
|
||||
def test_seeded_answer_is_deterministic() -> None:
|
||||
assert answer_for_seed(42) == answer_for_seed(42)
|
||||
assert answer_for_seed(1) != answer_for_seed(2)
|
||||
|
||||
|
||||
def test_rejections_do_not_consume_a_row() -> None:
|
||||
game = Game(seed=3, answer="tares")
|
||||
assert game.play("zzzzz")[1] is not None # not a word
|
||||
assert game.play("four")[1] is not None # wrong length
|
||||
assert len(game.history) == 0
|
||||
assert game.rejected == 2
|
||||
assert game.play("crane")[0] is not None
|
||||
assert len(game.history) == 1
|
||||
|
||||
|
||||
def test_a_repeat_is_rejected() -> None:
|
||||
game = Game(seed=3, answer="tares")
|
||||
game.play("crane")
|
||||
assert "already been guessed" in (game.play("crane")[1] or "")
|
||||
|
||||
|
||||
def test_game_never_raises_on_junk() -> None:
|
||||
game = Game(seed=3, answer="tares")
|
||||
for junk in ["", " ", "!!!!!", "a", "abcdefghij", "12345"]:
|
||||
pattern, reason = game.play(junk)
|
||||
assert pattern is None and reason is not None
|
||||
|
||||
|
||||
def test_solving_ends_the_game() -> None:
|
||||
game = Game(seed=3, answer="tares")
|
||||
game.play("tares")
|
||||
assert game.solved and game.over
|
||||
|
||||
|
||||
# ------------------------------------------------------------- hard mode --
|
||||
|
||||
|
||||
def test_hard_mode_locks_greens() -> None:
|
||||
assert hard_mode_violation("crown", "crane", "GGXXX") is None
|
||||
assert hard_mode_violation("blown", "crane", "GGXXX") is not None
|
||||
|
||||
|
||||
def test_hard_mode_counts_yellows_rather_than_testing_membership() -> None:
|
||||
"""Two revealed E's require two E's, not merely one."""
|
||||
pattern = score_guess("speed", "erase") # YXYYX — two E's revealed
|
||||
assert hard_mode_violation("ester", "speed", pattern) is None # two E's
|
||||
assert hard_mode_violation("crest", "speed", pattern) is not None # only one E
|
||||
|
||||
|
||||
def test_hard_mode_does_not_ban_grey_letters() -> None:
|
||||
"""The rule most implementations add and the real game does not have."""
|
||||
pattern = score_guess("crane", "tares") # C grey
|
||||
assert pattern[0] == "X", "C should be grey against TARES"
|
||||
# A guess reusing that grey C is legal, so long as it still satisfies every
|
||||
# green and yellow that was revealed.
|
||||
assert hard_mode_violation("stare", "crane", pattern) is None
|
||||
|
||||
|
||||
# ------------------------------------------------------ cross-language gate --
|
||||
|
||||
|
||||
def test_conformance_digest_matches_the_committed_value() -> None:
|
||||
"""The gate that keeps the TypeScript port honest.
|
||||
|
||||
This hashes every (guess, answer) pair in the answer list. If it changes,
|
||||
either the scorer changed or the word list did, and `src/demos/wordle/
|
||||
engine.ts` must be re-verified against it — `pnpm conformance` does the
|
||||
other half.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
committed = (Path(__file__).parent.parent / "CONFORMANCE.txt").read_text().split()[0]
|
||||
assert conformance_digest() == committed
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Turning model text into a move, and a move into text.
|
||||
|
||||
The one rule: **never raise**. A malformed reply is a thing the model did, not
|
||||
an error in the harness, and it must be scored rather than crash the rollout.
|
||||
A parse failure becomes a rejected guess, which costs the model a turn of its
|
||||
budget and shows up in `rejected_replies`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from .engine import MAX_GUESSES, WORD_LENGTH
|
||||
|
||||
SYSTEM_PROMPT = """You are playing a word-guessing game.
|
||||
|
||||
A secret {length}-letter word has been chosen. You have {turns} attempts.
|
||||
After each guess you are told, for every letter:
|
||||
G — correct letter, correct position
|
||||
Y — the letter is in the word, but somewhere else
|
||||
X — the letter is not in the word
|
||||
|
||||
Think it through, then give your guess as the only square-bracketed token in
|
||||
your reply, like [crane]. The game reads the first bracketed token and ignores
|
||||
everything else, so do not put any other words in brackets."""
|
||||
|
||||
_BRACKETED = re.compile(r"\[([A-Za-z]+)\]")
|
||||
|
||||
|
||||
def system_prompt() -> str:
|
||||
return SYSTEM_PROMPT.format(length=WORD_LENGTH, turns=MAX_GUESSES)
|
||||
|
||||
|
||||
def parse_guess(reply: str | None) -> str | None:
|
||||
"""The first bracketed alphabetic token, lowercased. None if there is none.
|
||||
|
||||
Deliberately does NOT check length or membership — that is the game's job,
|
||||
so that 'the model guessed a six-letter word' and 'the model produced no
|
||||
guess at all' stay distinguishable in the metrics.
|
||||
"""
|
||||
if not reply:
|
||||
return None
|
||||
match = _BRACKETED.search(reply)
|
||||
return match.group(1).lower() if match else None
|
||||
|
||||
|
||||
def render_feedback(guess: str, pattern: str, turns_left: int) -> str:
|
||||
"""The feedback block, in the format the system prompt promised.
|
||||
|
||||
Letters and tiles are space-separated and positionally aligned. Most
|
||||
tokenizers give a space-separated character its own token, which is the
|
||||
difference between the model reading position 3 and guessing at it.
|
||||
"""
|
||||
letters = " ".join(guess.upper())
|
||||
tiles = " ".join(pattern)
|
||||
plural = "" if turns_left == 1 else "es"
|
||||
return f"{letters}\n{tiles}\n\nYou have {turns_left} guess{plural} left."
|
||||
|
||||
|
||||
def render_rejection(reason: str, turns_left: int) -> str:
|
||||
plural = "" if turns_left == 1 else "es"
|
||||
return (
|
||||
f"That guess was not accepted: {reason}.\n"
|
||||
f"It did not use up a turn. You have {turns_left} guess{plural} left."
|
||||
)
|
||||
@@ -0,0 +1,132 @@
|
||||
"""The reward. This file is quoted verbatim on the demo page, so it is written
|
||||
to be read by someone who does not write Python.
|
||||
|
||||
Three components, weights summing to 1.0. Two of them pull toward winning. The
|
||||
third pulls against them on purpose.
|
||||
|
||||
# region: pig-demo/reward
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .engine import GREEN, WORD_LENGTH, score_guess
|
||||
|
||||
SOLVED_WEIGHT = 0.50
|
||||
ECONOMY_WEIGHT = 0.30
|
||||
CONSISTENCY_WEIGHT = 0.20
|
||||
|
||||
|
||||
@dataclass
|
||||
class Episode:
|
||||
"""Everything the reward needs from one played game."""
|
||||
|
||||
answer: str
|
||||
guesses: list[str]
|
||||
patterns: list[str]
|
||||
rejected: int
|
||||
reference_depth: int
|
||||
max_guesses: int = 6
|
||||
|
||||
|
||||
def solved(ep: Episode) -> float:
|
||||
"""Did it win? The objective, and half the reward."""
|
||||
return 1.0 if ep.patterns and ep.patterns[-1] == GREEN * WORD_LENGTH else 0.0
|
||||
|
||||
|
||||
def economy(ep: Episode) -> float:
|
||||
"""How few turns it took, against how few the reference player needed.
|
||||
|
||||
Always a ratio against a reference on the SAME hidden word, never an
|
||||
absolute count — otherwise the reward would mostly measure whether the word
|
||||
happened to be easy, and a model would be punished for a hard draw.
|
||||
"""
|
||||
if not solved(ep):
|
||||
return 0.0
|
||||
return min(1.0, ep.reference_depth / max(1, len(ep.guesses)))
|
||||
|
||||
|
||||
def consistency(ep: Episode) -> float:
|
||||
"""The share of guesses that were still possible answers when they were made.
|
||||
|
||||
This is the counterweight, and it is genuinely in tension with the other
|
||||
two. A player maximising information will deliberately guess words that
|
||||
CANNOT win, because a word that splits the remaining candidates evenly
|
||||
teaches it more than a word that might happen to be right. That is good
|
||||
play, and it costs consistency. The reference solver scores below 1.0 here.
|
||||
|
||||
So the three components describe a real trade: win, win fast, and do not
|
||||
spend turns on moves that could not have won. You cannot max all three, and
|
||||
which one you weight is a decision about what you actually want — which is
|
||||
the entire argument this demo exists to make.
|
||||
|
||||
Scored over every turn SPENT, not every guess accepted — see below. Without
|
||||
that, a policy which never produces a parseable word scores a vacuous 1.0
|
||||
here (no guesses, therefore no contradictions) and collects 0.2 for doing
|
||||
nothing at all.
|
||||
"""
|
||||
spent = len(ep.guesses) + ep.rejected
|
||||
if spent == 0:
|
||||
return 0.0
|
||||
|
||||
viable = 0
|
||||
for i, guess in enumerate(ep.guesses):
|
||||
# A guess is viable iff it is consistent with every earlier piece of
|
||||
# feedback — that is, it was still a possible answer at the moment it
|
||||
# was played.
|
||||
if all(score_guess(ep.guesses[k], guess) == ep.patterns[k] for k in range(i)):
|
||||
viable += 1
|
||||
|
||||
# The denominator is every turn SPENT, including replies the game refused.
|
||||
# Counting only legal guesses would hand a free 1.0 to a policy that plays
|
||||
# one word and then jams the parser five times: one guess, no
|
||||
# contradictions, perfect score. Measuring against turns spent asks the
|
||||
# question that actually matters — of the attempts you made, how many were
|
||||
# a move that could have won?
|
||||
return viable / spent
|
||||
|
||||
|
||||
def score(ep: Episode) -> dict[str, float]:
|
||||
"""The weighted components, unweighted. The caller applies the weights."""
|
||||
return {
|
||||
"solved": solved(ep),
|
||||
"economy": economy(ep),
|
||||
"consistency": consistency(ep),
|
||||
}
|
||||
|
||||
|
||||
def total(ep: Episode) -> float:
|
||||
parts = score(ep)
|
||||
return (
|
||||
parts["solved"] * SOLVED_WEIGHT
|
||||
+ parts["economy"] * ECONOMY_WEIGHT
|
||||
+ parts["consistency"] * CONSISTENCY_WEIGHT
|
||||
)
|
||||
|
||||
|
||||
# endregion: pig-demo/reward
|
||||
|
||||
|
||||
def metrics(ep: Episode) -> dict[str, float]:
|
||||
"""Diagnostics. Weight zero — reported, never summed into the reward."""
|
||||
return {
|
||||
"guesses_used": float(len(ep.guesses)),
|
||||
"rejected_replies": float(ep.rejected),
|
||||
"reference_depth": float(ep.reference_depth),
|
||||
"turns_granted": float(ep.max_guesses),
|
||||
"inconsistent_guesses": float(round((1.0 - consistency(ep)) * len(ep.guesses))),
|
||||
}
|
||||
|
||||
|
||||
WEIGHTS = {
|
||||
"solved": SOLVED_WEIGHT,
|
||||
"economy": ECONOMY_WEIGHT,
|
||||
"consistency": CONSISTENCY_WEIGHT,
|
||||
}
|
||||
|
||||
ROLES = {
|
||||
"solved": "objective",
|
||||
"economy": "objective",
|
||||
"consistency": "counterweight",
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
"""The reference player: greedy maximum-entropy with full constraint filtering.
|
||||
|
||||
Two jobs, and it is important they are the same code:
|
||||
|
||||
1. It is the `oracle` rung of the probe ladder.
|
||||
2. Its solve depth on a seed is the denominator of the `economy` reward.
|
||||
|
||||
Deriving the reward's reference from the shipped policy rather than from a
|
||||
separately-computed optimum is deliberate. If `economy` were measured against a
|
||||
depth-optimal search the oracle could not reach 1.0 — entropy-greedy is not
|
||||
depth-optimal — and the probe's own assertion would fail on some seeds. The
|
||||
optimum is still published, as a diagnostic, in `optimal_depth_report()`.
|
||||
|
||||
Note what this solver does NOT get: it scores 1.0 on solving and on economy,
|
||||
and strictly below 1.0 on `consistency`, because maximising information means
|
||||
deliberately guessing words that cannot win. That tension is the point — see
|
||||
reward.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .engine import GREEN, WORD_LENGTH, answers, score_guess
|
||||
|
||||
_CACHE = Path(__file__).parent.parent / ".pattern-cache" / "answers.npy"
|
||||
|
||||
# Base-3 code for a 5-tile pattern: 0..242, so it fits in a uint8. The all-green
|
||||
# pattern is the largest code, which is the only value the solver compares
|
||||
# against by name.
|
||||
_TILE_VALUE = {"X": 0, "Y": 1, "G": 2}
|
||||
ALL_GREEN_CODE = sum(2 * 3**i for i in range(WORD_LENGTH))
|
||||
|
||||
|
||||
def pattern_code(pattern: str) -> int:
|
||||
return sum(_TILE_VALUE[t] * 3**i for i, t in enumerate(pattern))
|
||||
|
||||
|
||||
def _compute_matrix(words: tuple[str, ...]) -> np.ndarray:
|
||||
"""M[g, a] = the pattern code for guessing words[g] against answer words[a].
|
||||
|
||||
Vectorised one letter at a time and chunked over guesses. The naive
|
||||
(n_guess, n_answer, 5) boolean intermediate is ~106 MB per letter at this
|
||||
list size, so chunking is what keeps peak memory in the tens of megabytes
|
||||
instead of gigabytes.
|
||||
"""
|
||||
n = len(words)
|
||||
grid = np.frombuffer("".join(words).encode(), dtype=np.uint8).reshape(n, WORD_LENGTH)
|
||||
grid = grid - ord("a")
|
||||
|
||||
out = np.zeros((n, n), dtype=np.uint8)
|
||||
place = np.array([3**i for i in range(WORD_LENGTH)], dtype=np.uint16)
|
||||
|
||||
chunk = 256
|
||||
for start in range(0, n, chunk):
|
||||
stop = min(start + chunk, n)
|
||||
g = grid[start:stop] # (c, 5)
|
||||
# Greens first, exactly as engine.py does: every green in the word is
|
||||
# resolved before any yellow is considered.
|
||||
green = g[:, None, :] == grid[None, :, :] # (c, n, 5)
|
||||
tiles = green.astype(np.uint16) * 2
|
||||
|
||||
for letter in range(26):
|
||||
g_is = g == letter # (c, 5)
|
||||
a_is = grid == letter # (n, 5)
|
||||
if not g_is.any() or not a_is.any():
|
||||
continue
|
||||
# How many of this letter the answer has left after greens took theirs.
|
||||
green_here = green & g_is[:, None, :] # (c, n, 5)
|
||||
available = a_is.sum(1)[None, :] - green_here.sum(2) # (c, n)
|
||||
|
||||
# Candidate yellow positions, ranked left to right. A position wins a
|
||||
# yellow only if its rank is inside the remaining allocation, which
|
||||
# is what makes SASSY/BASIS come out YGGXX rather than YGGYX.
|
||||
candidate = g_is[:, None, :] & ~green_here # (c, n, 5)
|
||||
rank = np.cumsum(candidate, axis=2) - 1
|
||||
yellow = candidate & (rank < available[:, :, None])
|
||||
tiles += yellow.astype(np.uint16)
|
||||
|
||||
out[start:stop] = (tiles * place[None, None, :]).sum(2).astype(np.uint8)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def pattern_matrix() -> np.ndarray:
|
||||
"""The answer-vs-answer pattern matrix, memoised on disk.
|
||||
|
||||
Roughly 21 MB at the shipped list size. Gitignored and regenerated on
|
||||
demand — committing it would put a build artefact in a repo whose whole
|
||||
argument is that you can rebuild everything it claims.
|
||||
"""
|
||||
words = answers()
|
||||
if _CACHE.exists():
|
||||
cached = np.load(_CACHE)
|
||||
if cached.shape == (len(words), len(words)):
|
||||
return cached
|
||||
matrix = _compute_matrix(words)
|
||||
_CACHE.parent.mkdir(parents=True, exist_ok=True)
|
||||
np.save(_CACHE, matrix)
|
||||
return matrix
|
||||
|
||||
|
||||
def _verify_matrix_row(index: int) -> None:
|
||||
"""Cross-check one matrix row against engine.score_guess. Used by tests."""
|
||||
words = answers()
|
||||
row = pattern_matrix()[index]
|
||||
for j, answer in enumerate(words):
|
||||
expected = pattern_code(score_guess(words[index], answer))
|
||||
if row[j] != expected:
|
||||
raise AssertionError(f"matrix disagrees with engine at ({index}, {j})")
|
||||
|
||||
|
||||
def best_guess(candidates: np.ndarray, first: bool = False) -> int:
|
||||
"""The index of the guess with the highest expected information gain.
|
||||
|
||||
Ties break on being a candidate — among equally informative guesses, prefer
|
||||
one that could actually win, which is free expected value and also costs
|
||||
nothing in `consistency`.
|
||||
"""
|
||||
matrix = pattern_matrix()
|
||||
if first:
|
||||
# The opening guess never depends on the seed, so it is computed once
|
||||
# per process rather than once per game.
|
||||
return _best_opener()
|
||||
if len(candidates) == 1:
|
||||
return int(candidates[0])
|
||||
|
||||
sub = matrix[:, candidates] # (n_guess, n_candidates)
|
||||
best_index, best_score = -1, (-1.0, -1)
|
||||
total = len(candidates)
|
||||
candidate_set = set(candidates.tolist())
|
||||
for g in range(matrix.shape[0]):
|
||||
counts = np.bincount(sub[g], minlength=243)
|
||||
counts = counts[counts > 0]
|
||||
probability = counts / total
|
||||
entropy = float(-(probability * np.log2(probability)).sum())
|
||||
score = (entropy, 1 if g in candidate_set else 0)
|
||||
if score > best_score:
|
||||
best_index, best_score = g, score
|
||||
return best_index
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _best_opener() -> int:
|
||||
matrix = pattern_matrix()
|
||||
total = matrix.shape[1]
|
||||
best_index, best_entropy = -1, -1.0
|
||||
for g in range(matrix.shape[0]):
|
||||
counts = np.bincount(matrix[g], minlength=243)
|
||||
counts = counts[counts > 0]
|
||||
probability = counts / total
|
||||
entropy = float(-(probability * np.log2(probability)).sum())
|
||||
if entropy > best_entropy:
|
||||
best_index, best_entropy = g, entropy
|
||||
return best_index
|
||||
|
||||
|
||||
def solve(answer: str, max_guesses: int = 6) -> list[str]:
|
||||
"""Play a full game against a known answer. Returns the guesses made."""
|
||||
words = answers()
|
||||
matrix = pattern_matrix()
|
||||
target = words.index(answer)
|
||||
candidates = np.arange(len(words))
|
||||
played: list[str] = []
|
||||
|
||||
for turn in range(max_guesses):
|
||||
pick = best_guess(candidates, first=(turn == 0))
|
||||
played.append(words[pick])
|
||||
if pick == target:
|
||||
return played
|
||||
observed = matrix[pick, target]
|
||||
candidates = candidates[matrix[pick, candidates] == observed]
|
||||
return played
|
||||
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def reference_depth(answer: str) -> int:
|
||||
"""How many guesses the shipped solver needs for this answer.
|
||||
|
||||
This is the `economy` denominator. Cached because the probe and the reward
|
||||
both ask for it on the same seeds.
|
||||
"""
|
||||
return len(solve(answer))
|
||||
|
||||
|
||||
def entropy_of(guess: str, candidates: list[str]) -> float:
|
||||
"""Expected bits gained by playing `guess` against a candidate set."""
|
||||
words = answers()
|
||||
matrix = pattern_matrix()
|
||||
g = words.index(guess)
|
||||
idx = np.array([words.index(c) for c in candidates])
|
||||
counts = np.bincount(matrix[g, idx], minlength=243)
|
||||
counts = counts[counts > 0]
|
||||
probability = counts / len(candidates)
|
||||
return float(-(probability * np.log2(probability)).sum())
|
||||
|
||||
|
||||
def consistent_candidates(history: list[tuple[str, str]]) -> list[str]:
|
||||
"""Every answer still viable given the feedback so far."""
|
||||
words = answers()
|
||||
matrix = pattern_matrix()
|
||||
alive = np.arange(len(words))
|
||||
for guess, pattern in history:
|
||||
if guess not in words:
|
||||
# A guess outside the answer list still constrains, but is not a row
|
||||
# of this matrix; fall back to the scalar engine for it.
|
||||
alive = np.array([i for i in alive if score_guess(guess, words[i]) == pattern])
|
||||
continue
|
||||
g = words.index(guess)
|
||||
alive = alive[matrix[g, alive] == pattern_code(pattern)]
|
||||
return [words[i] for i in alive]
|
||||
|
||||
|
||||
def optimal_depth_report(seeds: list[int]) -> dict[str, float]:
|
||||
"""Published diagnostics for the shipped solver over a set of seeds."""
|
||||
from .engine import answer_for_seed
|
||||
|
||||
depths = [reference_depth(answer_for_seed(s)) for s in seeds]
|
||||
return {
|
||||
"mean_guesses": sum(depths) / len(depths),
|
||||
"worst_case": max(depths),
|
||||
"solved_within_six": sum(1 for d in depths if d <= 6) / len(depths),
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
"""The verifiers v1 taskset.
|
||||
|
||||
Optional: everything else in this package runs, and is gated in CI, without
|
||||
verifiers installed. Only this file needs it, so the correctness of the game,
|
||||
the reward and the probe never depends on an RL stack being resolvable.
|
||||
|
||||
Install with: uv sync --all-packages --extra verifiers
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .engine import MAX_GUESSES, Game, answer_for_seed
|
||||
from .protocol import parse_guess, render_feedback, render_rejection, system_prompt
|
||||
from .reward import ROLES, WEIGHTS, Episode, metrics, score
|
||||
from .solver import reference_depth
|
||||
|
||||
TASKSET_ID = "wordle-five"
|
||||
|
||||
|
||||
def play_episode(seed: int, respond, max_guesses: int = MAX_GUESSES) -> dict[str, Any]:
|
||||
"""Drive one full episode against a callable that returns reply text.
|
||||
|
||||
Split out from the verifiers plumbing on purpose: this function is the
|
||||
whole game loop, it has no RL dependency, and it is what the probe, the
|
||||
tests and the fixture generator all use. The verifiers Env below is a thin
|
||||
adapter over it, so the thing being evaluated and the thing being tested
|
||||
cannot drift apart.
|
||||
"""
|
||||
game = Game(seed=seed, max_guesses=max_guesses)
|
||||
transcript: list[dict[str, Any]] = []
|
||||
prompt = system_prompt()
|
||||
|
||||
while not game.over:
|
||||
turns_left = max_guesses - len(game.history)
|
||||
reply = respond(prompt, transcript)
|
||||
guess = parse_guess(reply)
|
||||
|
||||
if guess is None:
|
||||
game.rejected += 1
|
||||
observation = render_rejection("no bracketed guess found", turns_left)
|
||||
else:
|
||||
pattern, rejection = game.play(guess)
|
||||
if rejection is not None:
|
||||
observation = render_rejection(rejection, turns_left)
|
||||
else:
|
||||
assert pattern is not None
|
||||
observation = render_feedback(guess, pattern, max_guesses - len(game.history))
|
||||
|
||||
transcript.append({"reply": reply, "guess": guess, "observation": observation})
|
||||
|
||||
# A model that only ever emits refusals would otherwise loop until the
|
||||
# process is killed; the game itself has no notion of a wasted turn.
|
||||
if game.rejected >= max_guesses:
|
||||
break
|
||||
|
||||
episode = Episode(
|
||||
answer=game.answer,
|
||||
guesses=[g for g, _ in game.history],
|
||||
patterns=[p for _, p in game.history],
|
||||
rejected=game.rejected,
|
||||
reference_depth=reference_depth(game.answer),
|
||||
max_guesses=max_guesses,
|
||||
)
|
||||
return {
|
||||
"seed": seed,
|
||||
"answer": game.answer,
|
||||
"solved": game.solved,
|
||||
"outcome": "solved" if game.solved else "failed",
|
||||
"transcript": transcript,
|
||||
"rewards": score(episode),
|
||||
"weights": WEIGHTS,
|
||||
"roles": ROLES,
|
||||
"metrics": metrics(episode),
|
||||
}
|
||||
|
||||
|
||||
def build_taskset(): # pragma: no cover - requires the optional extra
|
||||
"""The verifiers v1 Taskset. Imported lazily so the module stays optional."""
|
||||
import verifiers as vf
|
||||
|
||||
class WordleFiveEnv(vf.Env):
|
||||
"""One agent seat, `player`, playing against the engine host-side.
|
||||
|
||||
The engine plays the user role rather than the model conversing with a
|
||||
second model — the feedback is computed, not generated, which is the
|
||||
whole reason this reward is verifiable.
|
||||
"""
|
||||
|
||||
async def run(self, task, agent, trace):
|
||||
game = Game(seed=task.data.info["seed"])
|
||||
turns = 0
|
||||
async with agent.interaction(system=system_prompt()) as session:
|
||||
message = "Enter your guess to begin."
|
||||
while not game.over and turns < MAX_GUESSES * 2:
|
||||
reply = await session.turn(message)
|
||||
guess = parse_guess(reply)
|
||||
left = MAX_GUESSES - len(game.history)
|
||||
if guess is None:
|
||||
game.rejected += 1
|
||||
message = render_rejection("no bracketed guess found", left)
|
||||
else:
|
||||
pattern, rejection = game.play(guess)
|
||||
message = (
|
||||
render_rejection(rejection, left)
|
||||
if rejection is not None
|
||||
else render_feedback(guess, pattern or "", MAX_GUESSES - len(game.history))
|
||||
)
|
||||
turns += 1
|
||||
if game.rejected >= MAX_GUESSES:
|
||||
break
|
||||
|
||||
episode = Episode(
|
||||
answer=game.answer,
|
||||
guesses=[g for g, _ in game.history],
|
||||
patterns=[p for _, p in game.history],
|
||||
rejected=game.rejected,
|
||||
reference_depth=reference_depth(game.answer),
|
||||
)
|
||||
# Graded INSIDE the interaction, so the trace carries the
|
||||
# rewards rather than having them stapled on afterwards.
|
||||
for key, value in score(episode).items():
|
||||
trace.record_reward(key, value, WEIGHTS[key])
|
||||
for key, value in metrics(episode).items():
|
||||
trace.record_reward(key, value, 0.0)
|
||||
trace.info["answer"] = game.answer
|
||||
trace.info["outcome"] = "solved" if game.solved else "failed"
|
||||
|
||||
return WordleFiveEnv
|
||||
@@ -0,0 +1,56 @@
|
||||
# Where these words came from
|
||||
|
||||
Both lists are built by `build_words.py` from the two source files committed
|
||||
beside it. Nothing here is copied from the original game.
|
||||
|
||||
## Guesses — `guesses.json`, 11,846 words
|
||||
|
||||
From [`wordnik/wordlist`](https://github.com/wordnik/wordlist), snapshot
|
||||
`wordlist-20210729.txt`, **MIT licensed**, filtered to five-letter lowercase
|
||||
ASCII. Wordnik publishes this list for word-game developers, which is exactly
|
||||
what it is being used for.
|
||||
|
||||
## Answers — `answers.json`, 4,603 words
|
||||
|
||||
The **intersection** of the guess list with SCOWL's common-American tier
|
||||
(`wamerican` 2020.12.07, as shipped in `/usr/share/dict/american-english`),
|
||||
minus a short hand-curated block list.
|
||||
|
||||
The intersection is the point. A word is a possible answer if it is a real
|
||||
headword (Wordnik) *and* common enough that a non-specialist has plausibly met
|
||||
it (SCOWL). That rule is stated, reproducible, and ours — as opposed to copying
|
||||
somebody else's editorial selection of which words are fair.
|
||||
|
||||
SCOWL is distributed under a permissive licence requiring attribution, which
|
||||
`NOTICE` carries.
|
||||
|
||||
## What we deliberately did not use
|
||||
|
||||
**The original game's 2,315-word answer list.** It is the product of a
|
||||
deliberate human curation pass, which is the strongest selection-originality
|
||||
argument of any list in this space and has never been litigated. We do not need
|
||||
it, so we do not ship it.
|
||||
|
||||
We verified separately that the Wordnik list *contains* all 2,315 of those
|
||||
words — that is a statement about Wordnik's coverage, not a reason to
|
||||
redistribute the selection.
|
||||
|
||||
Consequences worth stating plainly, since they affect every number on the site:
|
||||
|
||||
- Our answer pool is **4,603**, roughly twice the original's. This game is
|
||||
**harder** than the original, and our measured solver averages are not
|
||||
comparable to published figures for it.
|
||||
- The famous results — SALET as the optimal opener, a 3.4212-guess mean, a
|
||||
proven worst case of five — are proven **for the original list**. They are
|
||||
cited on the site as exactly that, never as our own ceiling. Our own
|
||||
reference player opens **TARES** and averages **3.72**, and that number is
|
||||
computed by `solver.optimal_depth_report()` rather than quoted.
|
||||
|
||||
## Rebuilding
|
||||
|
||||
```bash
|
||||
uv run python envs/wordle_five/words/build_words.py
|
||||
```
|
||||
|
||||
Deterministic and offline. The output must be byte-identical; if it is not,
|
||||
`CONFORMANCE.txt` changes too and the TypeScript port has to be re-verified.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2019 The Manrope Project Authors (https://github.com/sharanda/manrope)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,15 @@
|
||||
[project]
|
||||
name = "pig-demo-envs"
|
||||
version = "0.1.0"
|
||||
description = "RL environments backing the demos at demo.primeintellectgrowth.com"
|
||||
requires-python = ">=3.11,<3.14"
|
||||
dependencies = []
|
||||
|
||||
[tool.uv.workspace]
|
||||
members = ["envs/*"]
|
||||
|
||||
[tool.uv.sources]
|
||||
wordle-five = { workspace = true }
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["pytest>=8.0", "numpy>=2.0"]
|
||||
@@ -0,0 +1,718 @@
|
||||
/**
|
||||
* Shared plumbing for the contract scripts.
|
||||
*
|
||||
* Every `scripts/check-*.mjs` is a gate, not a linter: it exits non-zero with a
|
||||
* message naming the file and the numbered rule that was broken, so a failure
|
||||
* in CI reads as an instruction rather than a puzzle.
|
||||
*
|
||||
* The demo contract lives in TypeScript and these scripts are plain Node, so
|
||||
* most of what follows is a small, deliberately dumb TypeScript reader: find a
|
||||
* named declaration, brace-match its object/array literal, strip the two TS-only
|
||||
* forms a literal can legally carry (`as const`, `satisfies X`), and evaluate it
|
||||
* in a bare `vm` context.
|
||||
*
|
||||
* That reader is honest about its limits. A literal that references an imported
|
||||
* constant does not evaluate, and the scripts FAIL rather than skip: the whole
|
||||
* point of `meta` and `reward.components` is that a human can read the numbers
|
||||
* off the page beside the code, so a weight hidden behind an indirection is a
|
||||
* contract problem, not a tooling problem.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import http from 'node:http';
|
||||
import zlib from 'node:zlib';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import vm from 'node:vm';
|
||||
|
||||
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
export const abs = (...parts) => path.join(ROOT, ...parts);
|
||||
export const rel = (p) => path.relative(ROOT, p) || '.';
|
||||
export const exists = (p) => fs.existsSync(p);
|
||||
export const read = (p) => fs.readFileSync(p, 'utf8');
|
||||
export const readJson = (p) => JSON.parse(read(p));
|
||||
|
||||
/* ------------------------------------------------------------------ output */
|
||||
|
||||
const ESC = '\u001b[';
|
||||
const COLOUR = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
|
||||
const paint = (code, s) => (COLOUR ? `${ESC}${code}m${s}${ESC}0m` : String(s));
|
||||
export const bold = (s) => paint('1', s);
|
||||
export const dim = (s) => paint('2', s);
|
||||
export const red = (s) => paint('31', s);
|
||||
export const green = (s) => paint('32', s);
|
||||
export const yellow = (s) => paint('33', s);
|
||||
export const cyan = (s) => paint('36', s);
|
||||
|
||||
/**
|
||||
* Collects failures instead of throwing on the first one.
|
||||
*
|
||||
* A contract check that dies on failure #1 makes the author fix and re-run
|
||||
* seven times. Every rule that can still be evaluated is evaluated.
|
||||
*/
|
||||
export class Report {
|
||||
/** @param {string} title */
|
||||
constructor(title) {
|
||||
this.title = title;
|
||||
/** @type {{file: string, rule: string, message: string}[]} */
|
||||
this.failures = [];
|
||||
/** @type {string[]} */
|
||||
this.warnings = [];
|
||||
/** @type {string[]} */
|
||||
this.staticNotes = [];
|
||||
this.passed = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} file repo-relative path the reader should open
|
||||
* @param {string} rule the numbered rule, e.g. 'rule 10 (reward weights)'
|
||||
* @param {string} message what is actually wrong
|
||||
*/
|
||||
fail(file, rule, message) {
|
||||
this.failures.push({ file, rule, message });
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Assert, recording either a pass or a precise failure. */
|
||||
check(condition, file, rule, message) {
|
||||
if (condition) {
|
||||
this.passed += 1;
|
||||
return true;
|
||||
}
|
||||
return this.fail(file, rule, message);
|
||||
}
|
||||
|
||||
warn(message) {
|
||||
this.warnings.push(message);
|
||||
}
|
||||
|
||||
/** Record that a rule was verified by reading source, not by running it. */
|
||||
staticOnly(message) {
|
||||
this.staticNotes.push(message);
|
||||
}
|
||||
|
||||
/** Prints the report and exits the process. Never returns. */
|
||||
finish() {
|
||||
const out = [];
|
||||
if (this.staticNotes.length) {
|
||||
out.push('');
|
||||
out.push(dim('Checked by reading source, not by executing it:'));
|
||||
for (const n of this.staticNotes) out.push(dim(` - ${n}`));
|
||||
}
|
||||
if (this.warnings.length) {
|
||||
out.push('');
|
||||
for (const w of this.warnings) out.push(`${yellow('warn')} ${w}`);
|
||||
}
|
||||
if (this.failures.length) {
|
||||
out.push('');
|
||||
for (const f of this.failures) {
|
||||
out.push(`${red('FAIL')} ${bold(f.file)}`);
|
||||
out.push(` ${cyan(f.rule)}: ${f.message}`);
|
||||
}
|
||||
out.push('');
|
||||
out.push(red(`${this.title}: ${this.failures.length} failure(s), ${this.passed} check(s) passed.`));
|
||||
console.error(out.join('\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
out.push('');
|
||||
out.push(green(`${this.title}: ${this.passed} check(s) passed.`));
|
||||
console.log(out.join('\n'));
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fatal error that is the script's own problem, not the repo's. */
|
||||
export function die(message) {
|
||||
console.error(`${red('FAIL')} ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- filesystem walking */
|
||||
|
||||
/**
|
||||
* @param {string} dir
|
||||
* @param {(file: string) => boolean} [filter]
|
||||
* @returns {string[]} absolute paths, depth-first, stable order
|
||||
*/
|
||||
export function walk(dir, filter = () => true) {
|
||||
if (!exists(dir)) return [];
|
||||
/** @type {string[]} */
|
||||
const out = [];
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1));
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === 'node_modules' || entry.name === '__pycache__') continue;
|
||||
out.push(...walk(full, filter));
|
||||
} else if (entry.isFile() && filter(full)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const DEMOS_DIR = abs('src', 'demos');
|
||||
|
||||
/** Every directory under `src/demos`, including `_template`. */
|
||||
export function allDemoDirs() {
|
||||
if (!exists(DEMOS_DIR)) return [];
|
||||
return fs
|
||||
.readdirSync(DEMOS_DIR, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
/** Shippable demos: a leading underscore marks scaffolding, not a demo. */
|
||||
export function demoSlugs() {
|
||||
return allDemoDirs().filter((name) => !name.startsWith('_'));
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------- the small TS reader */
|
||||
|
||||
/**
|
||||
* Splits source into code and non-code (string literal / comment) spans.
|
||||
*
|
||||
* Everything below works on this rather than on raw regex, because the metas
|
||||
* are full of prose: a `description` containing the words "as an executive"
|
||||
* is otherwise indistinguishable from a TypeScript `as` assertion.
|
||||
*
|
||||
* @param {string} src
|
||||
* @returns {{code: boolean, text: string}[]}
|
||||
*/
|
||||
export function segment(src) {
|
||||
/** @type {{code: boolean, text: string}[]} */
|
||||
const out = [];
|
||||
let codeStart = 0;
|
||||
let i = 0;
|
||||
const flushCode = (end) => {
|
||||
if (end > codeStart) out.push({ code: true, text: src.slice(codeStart, end) });
|
||||
};
|
||||
while (i < src.length) {
|
||||
const c = src[i];
|
||||
const next = src[i + 1];
|
||||
if (c === '/' && next === '/') {
|
||||
flushCode(i);
|
||||
const nl = src.indexOf('\n', i);
|
||||
const end = nl === -1 ? src.length : nl;
|
||||
out.push({ code: false, text: src.slice(i, end) });
|
||||
i = codeStart = end;
|
||||
continue;
|
||||
}
|
||||
if (c === '/' && next === '*') {
|
||||
flushCode(i);
|
||||
const close = src.indexOf('*/', i + 2);
|
||||
const end = close === -1 ? src.length : close + 2;
|
||||
out.push({ code: false, text: src.slice(i, end) });
|
||||
i = codeStart = end;
|
||||
continue;
|
||||
}
|
||||
if (c === "'" || c === '"' || c === '`') {
|
||||
flushCode(i);
|
||||
const end = endOfString(src, i) + 1;
|
||||
out.push({ code: false, text: src.slice(i, end) });
|
||||
i = codeStart = end;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
flushCode(src.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Index of the closing quote of the string literal starting at `start`. */
|
||||
function endOfString(src, start) {
|
||||
const quote = src[start];
|
||||
for (let j = start + 1; j < src.length; j += 1) {
|
||||
const c = src[j];
|
||||
if (c === '\\') {
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
if (quote === '`' && c === '$' && src[j + 1] === '{') {
|
||||
j = scanBalanced(src, j + 1) - 1;
|
||||
continue;
|
||||
}
|
||||
if (c === quote) return j;
|
||||
}
|
||||
throw new Error(`unterminated string literal starting at offset ${start}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* End offset (exclusive) of the bracket pair opening at `start`.
|
||||
* Skips strings and comments, and recurses through `${...}` in templates.
|
||||
*/
|
||||
export function scanBalanced(src, start) {
|
||||
const open = src[start];
|
||||
const close = open === '{' ? '}' : open === '[' ? ']' : open === '(' ? ')' : null;
|
||||
if (!close) throw new Error(`offset ${start} is ${JSON.stringify(open)}, not an opening bracket`);
|
||||
let depth = 0;
|
||||
for (let i = start; i < src.length; i += 1) {
|
||||
const c = src[i];
|
||||
const next = src[i + 1];
|
||||
if (c === '/' && next === '/') {
|
||||
const nl = src.indexOf('\n', i);
|
||||
if (nl === -1) break;
|
||||
i = nl;
|
||||
continue;
|
||||
}
|
||||
if (c === '/' && next === '*') {
|
||||
const end = src.indexOf('*/', i + 2);
|
||||
i = end === -1 ? src.length : end + 1;
|
||||
continue;
|
||||
}
|
||||
if (c === "'" || c === '"' || c === '`') {
|
||||
i = endOfString(src, i);
|
||||
continue;
|
||||
}
|
||||
if (c === open) depth += 1;
|
||||
else if (c === close) {
|
||||
depth -= 1;
|
||||
if (depth === 0) return i + 1;
|
||||
}
|
||||
}
|
||||
throw new Error(`unbalanced ${open} starting at offset ${start}`);
|
||||
}
|
||||
|
||||
/** Offsets of every code (non-string, non-comment) span in `src`. */
|
||||
function codeSpans(src) {
|
||||
const spans = [];
|
||||
let at = 0;
|
||||
for (const s of segment(src)) {
|
||||
spans.push({ code: s.code, start: at, end: at + s.text.length });
|
||||
at += s.text.length;
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the literal that follows `anchor` and returns its source text.
|
||||
*
|
||||
* @param {string} src
|
||||
* @param {RegExp} anchor must match immediately before the opening bracket
|
||||
* @param {'{' | '['} open
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function literalAfter(src, anchor, open = '{') {
|
||||
const flags = anchor.flags.includes('g') ? anchor.flags : `${anchor.flags}g`;
|
||||
const re = new RegExp(anchor.source, flags);
|
||||
const spans = codeSpans(src);
|
||||
const isCode = (offset) => {
|
||||
const hit = spans.find((s) => offset >= s.start && offset < s.end);
|
||||
return hit ? hit.code : true;
|
||||
};
|
||||
let match;
|
||||
while ((match = re.exec(src)) !== null) {
|
||||
// The anchor may legally match inside a comment or a string — a docblock
|
||||
// that quotes `const meta = {`. Only a match in real code counts.
|
||||
if (!isCode(match.index)) continue;
|
||||
const from = src.indexOf(open, match.index + Math.max(match[0].length - 1, 0));
|
||||
if (from === -1) continue;
|
||||
const between = src.slice(match.index + match[0].length, from);
|
||||
if (/[;=}]/.test(between)) continue;
|
||||
return src.slice(from, scanBalanced(src, from));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates a TypeScript object/array literal as plain data.
|
||||
*
|
||||
* `as const` and `satisfies T` are stripped from code spans only. Anything else
|
||||
* a literal might carry — an identifier, a call, a spread of an import — throws,
|
||||
* and callers turn that into a contract failure with the file named.
|
||||
*
|
||||
* @param {string} text
|
||||
* @param {string} label
|
||||
*/
|
||||
export function evalLiteral(text, label) {
|
||||
const js = segment(text)
|
||||
.map((s) =>
|
||||
s.code
|
||||
? s.text
|
||||
.replace(/\bas\s+const\b/g, '')
|
||||
.replace(/\bsatisfies\s+[A-Za-z_$][\w$.]*(?:<[^>]*>)?(?:\[\])*/g, '')
|
||||
.replace(/\bas\s+[A-Za-z_$][\w$.]*(?:<[^>]*>)?(?:\[\])*/g, '')
|
||||
: s.text,
|
||||
)
|
||||
.join('');
|
||||
try {
|
||||
const value = vm.runInNewContext(`(${js})`, Object.create(null), { timeout: 2000 });
|
||||
return { ok: true, value, error: null };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
value: null,
|
||||
error:
|
||||
`the ${label} literal did not evaluate as plain data (${error.message}). ` +
|
||||
'These literals are read by tooling and by humans reading the page beside the code, ' +
|
||||
'so they must be written out, not assembled from imported constants.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Members of a string-union type alias, e.g. `export type Vertical = 'a' | 'b'`. */
|
||||
export function parseStringUnion(src, typeName) {
|
||||
const match = src.match(new RegExp(`\\btype\\s+${typeName}\\s*=([^;]+);`));
|
||||
if (!match || !match[1]) return null;
|
||||
const members = [...match[1].matchAll(/'([^']+)'/g)].map((m) => m[1]);
|
||||
return members.length ? members : null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ demo metadata */
|
||||
|
||||
const META_ANCHORS = [
|
||||
/(?:export\s+)?const\s+meta\s*(?::\s*[^=]+)?=\s*/,
|
||||
/(?:export\s+)?const\s+[A-Za-z_$][\w$]*Meta\s*(?::\s*[^=]+)?=\s*/,
|
||||
/\bmeta\s*:\s*/,
|
||||
];
|
||||
|
||||
const CANDIDATE_META_FILES = ['meta.ts', 'meta.tsx', 'demo.tsx', 'demo.ts', 'index.ts', 'index.tsx'];
|
||||
|
||||
/**
|
||||
* Reads one demo's `DemoMeta` out of its source.
|
||||
* @param {string} slug
|
||||
* @returns {{file: string, meta: Record<string, any>} | {file: string, error: string}}
|
||||
*/
|
||||
export function loadMeta(slug) {
|
||||
const dir = path.join(DEMOS_DIR, slug);
|
||||
const tried = [];
|
||||
for (const name of CANDIDATE_META_FILES) {
|
||||
const file = path.join(dir, name);
|
||||
if (!exists(file)) continue;
|
||||
tried.push(rel(file));
|
||||
const src = read(file);
|
||||
for (const anchor of META_ANCHORS) {
|
||||
let text;
|
||||
try {
|
||||
text = literalAfter(src, anchor, '{');
|
||||
} catch (error) {
|
||||
return { file: rel(file), error: `could not brace-match the meta literal: ${error.message}` };
|
||||
}
|
||||
if (!text) continue;
|
||||
const result = evalLiteral(text, 'DemoMeta');
|
||||
if (!result.ok) return { file: rel(file), error: result.error };
|
||||
if (result.value && typeof result.value === 'object' && 'slug' in result.value) {
|
||||
return { file: rel(file), meta: result.value };
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
file: tried[0] ?? rel(path.join(dir, 'meta.ts')),
|
||||
error:
|
||||
tried.length === 0
|
||||
? `no meta source found; expected one of ${CANDIDATE_META_FILES.join(', ')} in ${rel(dir)}`
|
||||
: `no DemoMeta object literal with a \`slug\` key found in ${tried.join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Every demo whose meta could be read, keyed by directory name. */
|
||||
export function loadAllMetas() {
|
||||
/** @type {Map<string, Record<string, any>>} */
|
||||
const metas = new Map();
|
||||
/** @type {{slug: string, file: string, error: string}[]} */
|
||||
const errors = [];
|
||||
for (const slug of demoSlugs()) {
|
||||
const result = loadMeta(slug);
|
||||
if ('meta' in result) metas.set(slug, result.meta);
|
||||
else errors.push({ slug, file: result.file, error: result.error });
|
||||
}
|
||||
return { metas, errors };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ verticals */
|
||||
|
||||
export const VERTICALS_FILE = abs('src', 'content', 'verticals.ts');
|
||||
|
||||
const VERTICAL_ANCHORS = [
|
||||
/(?:export\s+)?const\s+VERTICALS\s*(?::\s*[^=]+)?=\s*/,
|
||||
/(?:export\s+)?const\s+verticals\s*(?::\s*[^=]+)?=\s*/,
|
||||
];
|
||||
|
||||
const asVertical = (id, v) => ({
|
||||
id,
|
||||
title: v?.title ?? v?.label ?? v?.name ?? null,
|
||||
description: v?.description ?? v?.blurb ?? v?.tagline ?? v?.summary ?? null,
|
||||
});
|
||||
|
||||
/**
|
||||
* The vertical records the site groups demos by.
|
||||
*
|
||||
* Tolerant about field names on purpose: this file belongs to another lane, and
|
||||
* a prerender that hard-codes `label` then silently bakes an empty <title> when
|
||||
* the author wrote `name` is worse than one that looks for both.
|
||||
*/
|
||||
export function loadVerticals() {
|
||||
if (!exists(VERTICALS_FILE)) return { list: [], error: `${rel(VERTICALS_FILE)} does not exist` };
|
||||
const src = read(VERTICALS_FILE);
|
||||
for (const anchor of VERTICAL_ANCHORS) {
|
||||
for (const open of ['[', '{']) {
|
||||
let text = null;
|
||||
try {
|
||||
text = literalAfter(src, anchor, open);
|
||||
} catch {
|
||||
text = null;
|
||||
}
|
||||
if (!text) continue;
|
||||
const result = evalLiteral(text, 'verticals');
|
||||
if (!result.ok) return { list: [], error: result.error };
|
||||
const value = result.value;
|
||||
const list = Array.isArray(value)
|
||||
? value.map((v) => asVertical(v?.id ?? v?.slug ?? v?.key, v))
|
||||
: value && typeof value === 'object'
|
||||
? Object.entries(value).map(([id, v]) => asVertical(id, v))
|
||||
: [];
|
||||
const clean = list.filter((v) => typeof v.id === 'string' && v.id.length > 0);
|
||||
if (clean.length) return { list: clean, error: null };
|
||||
}
|
||||
}
|
||||
return { list: [], error: `no \`verticals\` or \`VERTICALS\` literal found in ${rel(VERTICALS_FILE)}` };
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------- traces */
|
||||
|
||||
export const MANIFEST_FILE = abs('public', 'traces', 'manifest.json');
|
||||
|
||||
/**
|
||||
* Normalises `public/traces/manifest.json` into `slug -> {runs, extras}`.
|
||||
*
|
||||
* Four shapes are accepted because the manifest is authored by hand and all
|
||||
* four are things a reasonable person writes. Anything else fails loudly rather
|
||||
* than quietly reporting zero runs, which would turn rule 7 into a no-op.
|
||||
*/
|
||||
export function loadManifest() {
|
||||
if (!exists(MANIFEST_FILE)) return { byDemo: new Map(), error: `${rel(MANIFEST_FILE)} does not exist` };
|
||||
let raw;
|
||||
try {
|
||||
raw = readJson(MANIFEST_FILE);
|
||||
} catch (error) {
|
||||
return { byDemo: new Map(), error: `${rel(MANIFEST_FILE)} is not valid JSON: ${error.message}` };
|
||||
}
|
||||
/** @type {Map<string, {runs: any[], extras: Record<string, any>}>} */
|
||||
const byDemo = new Map();
|
||||
const container =
|
||||
raw && typeof raw === 'object' && !Array.isArray(raw) && raw.demos && typeof raw.demos === 'object' ? raw.demos : raw;
|
||||
|
||||
if (Array.isArray(container)) {
|
||||
// A flat array of runs, each carrying its own `demo`/`slug`.
|
||||
for (const run of container) {
|
||||
const slug = run?.demo ?? run?.slug;
|
||||
if (typeof slug !== 'string') continue;
|
||||
if (!byDemo.has(slug)) byDemo.set(slug, { runs: [], extras: {} });
|
||||
byDemo.get(slug).runs.push(run);
|
||||
}
|
||||
} else if (container && typeof container === 'object') {
|
||||
for (const [slug, value] of Object.entries(container)) {
|
||||
if (Array.isArray(value)) byDemo.set(slug, { runs: value, extras: {} });
|
||||
else if (value && typeof value === 'object' && Array.isArray(value.runs)) {
|
||||
const { runs, ...extras } = value;
|
||||
byDemo.set(slug, { runs, extras });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (byDemo.size === 0) {
|
||||
return {
|
||||
byDemo,
|
||||
error:
|
||||
`${rel(MANIFEST_FILE)} did not parse into any demo runs. Expected one of: ` +
|
||||
'{"<slug>": RunRef[]}, {"<slug>": {"runs": RunRef[], ...}}, {"demos": {...}}, ' +
|
||||
'or a flat RunRef[] where each run carries a "demo" field.',
|
||||
};
|
||||
}
|
||||
return { byDemo, error: null };
|
||||
}
|
||||
|
||||
/** Resolve a site-absolute trace path ('/traces/x.json') to a disk path. */
|
||||
export function traceFile(sitePath) {
|
||||
const clean = String(sitePath).split('?')[0].split('#')[0];
|
||||
return abs('public', clean.replace(/^\/+/, ''));
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- static server */
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.mjs': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp',
|
||||
'.avif': 'image/avif',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff2': 'font/woff2',
|
||||
'.woff': 'font/woff',
|
||||
'.ttf': 'font/ttf',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.map': 'application/json; charset=utf-8',
|
||||
'.xml': 'application/xml; charset=utf-8',
|
||||
};
|
||||
|
||||
/**
|
||||
* Serves a directory the way the deploy host will: a real file if one exists,
|
||||
* `index.html` otherwise.
|
||||
*
|
||||
* Prerender MUST hit the SPA fallback, so this deliberately does not 404 on an
|
||||
* unknown route — but it DOES 404 on a missing file under a path with an
|
||||
* extension, because serving HTML where a script was requested is how you
|
||||
* prerender a blank page and never find out why.
|
||||
*
|
||||
* @param {string} dir
|
||||
* @returns {Promise<{origin: string, close: () => Promise<void>}>}
|
||||
*/
|
||||
export function serveStatic(dir) {
|
||||
const root = path.resolve(dir);
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url ?? '/', 'http://localhost');
|
||||
const decoded = decodeURIComponent(url.pathname);
|
||||
const safe = path.normalize(decoded).replace(/^(\.\.[/\\])+/, '');
|
||||
let file = path.join(root, safe);
|
||||
if (!file.startsWith(root)) {
|
||||
res.writeHead(403).end('forbidden');
|
||||
return;
|
||||
}
|
||||
if (exists(file) && fs.statSync(file).isDirectory()) file = path.join(file, 'index.html');
|
||||
if (!exists(file)) {
|
||||
if (/\.[a-z0-9]+$/i.test(safe)) {
|
||||
res.writeHead(404, { 'content-type': 'text/plain' }).end(`not found: ${safe}`);
|
||||
return;
|
||||
}
|
||||
file = path.join(root, 'index.html');
|
||||
}
|
||||
const body = fs.readFileSync(file);
|
||||
res.writeHead(200, {
|
||||
'content-type': MIME[path.extname(file).toLowerCase()] ?? 'application/octet-stream',
|
||||
'content-length': body.length,
|
||||
'cache-control': 'no-store',
|
||||
});
|
||||
res.end(body);
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
server.on('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
resolve({
|
||||
origin: `http://127.0.0.1:${address.port}`,
|
||||
close: () => new Promise((done) => server.close(() => done())),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- routing */
|
||||
|
||||
/** The public origin the baked tags point at. */
|
||||
export const SITE_ORIGIN = (process.env.PIG_DEMO_ORIGIN ?? 'https://demo.primeintellectgrowth.com').replace(/\/+$/, '');
|
||||
|
||||
/**
|
||||
* Every route pattern the app declares, read out of the router source.
|
||||
*
|
||||
* Hard-coding `['/', '/d/:slug']` here is the exact failure this function
|
||||
* exists to avoid: the router moves, prerender keeps emitting the old paths,
|
||||
* and every shared link previews as the homepage again — which is the bug
|
||||
* prerendering was added to fix in the first place.
|
||||
*/
|
||||
export function discoverRoutePatterns() {
|
||||
const files = walk(abs('src'), (f) => /\.tsx?$/.test(f));
|
||||
/** @type {Set<string>} */
|
||||
const patterns = new Set();
|
||||
for (const file of files) {
|
||||
const src = read(file);
|
||||
for (const m of src.matchAll(/<Route\b[^>]*?\bpath\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*['"]([^'"]*)['"]\s*\})/g)) {
|
||||
const value = m[1] ?? m[2] ?? m[3];
|
||||
if (typeof value === 'string') patterns.add(value);
|
||||
}
|
||||
// `path:` is a common key name, so only trust it in a file that is
|
||||
// demonstrably a router config.
|
||||
if (/createBrowserRouter|createHashRouter|createMemoryRouter|RouteObject/.test(src)) {
|
||||
for (const m of src.matchAll(/\bpath\s*:\s*(?:"([^"]*)"|'([^']*)')/g)) {
|
||||
const value = m[1] ?? m[2];
|
||||
if (typeof value === 'string') patterns.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...patterns];
|
||||
}
|
||||
|
||||
const DEMO_PARAMS = /^(slug|demo|demoSlug)$/;
|
||||
const VERTICAL_PARAMS = /^(vertical|verticalId|sector|category)$/;
|
||||
|
||||
/**
|
||||
* Expands the router's patterns against the real data into concrete paths.
|
||||
*
|
||||
* @param {{metas: Map<string, any>, verticals: {id: string}[]}} data
|
||||
* @returns {{routes: {path: string, kind: string, slug?: string, id?: string}[], errors: string[]}}
|
||||
*/
|
||||
export function expandRoutes(data) {
|
||||
const patterns = discoverRoutePatterns();
|
||||
/** @type {string[]} */
|
||||
const errors = [];
|
||||
/** @type {Map<string, any>} */
|
||||
const routes = new Map();
|
||||
|
||||
const add = (p, record) => {
|
||||
const normalised = p === '/' || p === '' ? '/' : `/${p.replace(/^\/+|\/+$/g, '')}`;
|
||||
if (!routes.has(normalised)) routes.set(normalised, { path: normalised, ...record });
|
||||
};
|
||||
|
||||
if (patterns.length === 0) {
|
||||
errors.push(
|
||||
'no route patterns found under src/. Looked for `<Route path=...>` and, in files that mention ' +
|
||||
'createBrowserRouter/RouteObject, `path: ...`. Refusing to guess a route list.',
|
||||
);
|
||||
return { routes: [], errors };
|
||||
}
|
||||
|
||||
const demoSlugList = [...data.metas.keys()];
|
||||
const verticalIds = data.verticals.map((v) => v.id);
|
||||
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.includes('*')) continue; // the catch-all becomes 404.html, not a route
|
||||
const params = [...pattern.matchAll(/:([A-Za-z0-9_]+)\??/g)].map((m) => m[1]);
|
||||
if (params.length === 0) {
|
||||
add(pattern, pattern === '/' || pattern === '' ? { kind: 'home' } : { kind: 'static' });
|
||||
continue;
|
||||
}
|
||||
if (params.length > 1) {
|
||||
errors.push(`route pattern "${pattern}" has more than one parameter; prerender cannot expand it.`);
|
||||
continue;
|
||||
}
|
||||
const param = params[0];
|
||||
const fill = (value, record) => add(pattern.replace(/:[A-Za-z0-9_]+\??/, value), record);
|
||||
if (VERTICAL_PARAMS.test(param)) {
|
||||
if (!verticalIds.length) errors.push(`route "${pattern}" needs verticals, but none were readable from src/content/verticals.ts.`);
|
||||
for (const id of verticalIds) fill(id, { kind: 'vertical', id });
|
||||
} else if (DEMO_PARAMS.test(param) || param === 'id') {
|
||||
if (!demoSlugList.length) errors.push(`route "${pattern}" needs demos, but no demo meta was readable under src/demos/.`);
|
||||
for (const slug of demoSlugList) fill(slug, { kind: 'demo', slug });
|
||||
} else {
|
||||
errors.push(
|
||||
`route pattern "${pattern}" uses parameter ":${param}", which prerender cannot fill. ` +
|
||||
'Name it :slug (a demo) or :vertical (a vertical), or teach scripts/_lib.mjs about it.',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!routes.has('/')) add('/', { kind: 'home' });
|
||||
return { routes: [...routes.values()].sort((a, b) => (a.path < b.path ? -1 : 1)), errors };
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- sizes */
|
||||
|
||||
export const gzipSize = (buf) => zlib.gzipSync(buf, { level: 9 }).length;
|
||||
|
||||
export const kb = (bytes) => `${(bytes / 1024).toFixed(1)} kB`;
|
||||
|
||||
/** Padded plain-text table. Beats taking a dependency to print eight rows. */
|
||||
export function table(headers, rows) {
|
||||
const all = [headers, ...rows];
|
||||
const widths = headers.map((_, i) => Math.max(...all.map((r) => String(r[i] ?? '').length)));
|
||||
const line = (cells, pad = ' ') =>
|
||||
cells
|
||||
.map((c, i) => (i === 0 ? String(c ?? '').padEnd(widths[i], pad) : String(c ?? '').padStart(widths[i], pad)))
|
||||
.join(' ');
|
||||
return [line(headers), line(widths.map((w) => '-'.repeat(w)), '-'), ...rows.map((r) => line(r))].join('\n');
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { StoryBeat } from '@/lib/demo-kit/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface BeatSectionProps {
|
||||
beat: StoryBeat;
|
||||
/** 1-based. The narrative is numbered so a reader can be told "see beat 3". */
|
||||
number: number;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One beat of the exec narrative: a number, a title, a claim, and the surface
|
||||
* that makes the claim true.
|
||||
*
|
||||
* The claim is typeset as an assertion — large, high contrast, above the
|
||||
* evidence — because the failure mode of a demo site is a visitor watching a
|
||||
* pretty animation and never learning what it was supposed to prove.
|
||||
*/
|
||||
export function BeatSection({ beat, number, children, className }: BeatSectionProps) {
|
||||
const headingId = `beat-${beat.id}-title`;
|
||||
return (
|
||||
<section
|
||||
id={beat.id}
|
||||
aria-labelledby={headingId}
|
||||
data-surface={beat.surface}
|
||||
className={cn('scroll-mt-[var(--app-header-h)] py-10 lg:py-14', className)}
|
||||
>
|
||||
<header className="mb-6 lg:mb-8">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="nums select-none text-sm font-semibold tabular-nums text-accent-fg"
|
||||
>
|
||||
{String(number).padStart(2, '0')}
|
||||
</span>
|
||||
<h2 id={headingId} className="text-xl font-semibold tracking-tight lg:text-2xl">
|
||||
{beat.title}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="mt-3 max-w-2xl text-pretty text-lg leading-snug text-fg lg:text-xl">
|
||||
{beat.claim}
|
||||
</p>
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Component } from 'react';
|
||||
import type { ErrorInfo, ReactNode } from 'react';
|
||||
import { AlertTriangle, ExternalLink, RotateCcw } from 'lucide-react';
|
||||
|
||||
const REPO_URL = 'https://github.com/karti-ai/PIG-Demo';
|
||||
|
||||
export interface DemoErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
/** Named in the fallback copy, so the visitor knows what broke. */
|
||||
demoTitle?: string;
|
||||
/** Link to the exact source, if the caller knows it. Falls back to the repo. */
|
||||
sourceHref?: string;
|
||||
/** Called when the visitor asks to try again; use it to reset shell state. */
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
interface DemoErrorBoundaryState {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One broken demo must never take the site down.
|
||||
*
|
||||
* This is a class because there is still no hook for `componentDidCatch`; that
|
||||
* is the entire reason for the exception to the function-component rule here.
|
||||
*
|
||||
* The fallback is deliberately calm and specific. A site whose pitch is
|
||||
* "here are the receipts" cannot answer a crash with a shrug: it names the
|
||||
* demo, links the source, and lets the visitor retry without a full reload.
|
||||
*/
|
||||
export class DemoErrorBoundary extends Component<DemoErrorBoundaryProps, DemoErrorBoundaryState> {
|
||||
override state: DemoErrorBoundaryState = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): DemoErrorBoundaryState {
|
||||
return { error };
|
||||
}
|
||||
|
||||
override componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
// No telemetry endpoint on a static site, and none is wanted. The console
|
||||
// is the only place a maintainer can see this, so keep the component stack.
|
||||
console.error('[pig-demo] a demo surface threw', error, info.componentStack);
|
||||
}
|
||||
|
||||
private handleReset = () => {
|
||||
this.setState({ error: null });
|
||||
this.props.onReset?.();
|
||||
};
|
||||
|
||||
override render() {
|
||||
const { error } = this.state;
|
||||
if (!error) return this.props.children;
|
||||
|
||||
const { demoTitle, sourceHref } = this.props;
|
||||
return (
|
||||
<div role="alert" className="card mx-auto my-10 max-w-xl p-6">
|
||||
<div className="flex items-center gap-2 text-warning">
|
||||
<AlertTriangle className="h-5 w-5" aria-hidden="true" />
|
||||
<h2 className="text-base font-semibold">
|
||||
{demoTitle ? `${demoTitle} failed to render` : 'This demo failed to render'}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-relaxed text-muted">
|
||||
Something in this demo threw while drawing. The rest of the site is unaffected — every
|
||||
other demo is a separate module. The environment and the recorded runs behind this page
|
||||
are in the repository either way, and you can run them yourself.
|
||||
</p>
|
||||
<p className="mt-3 break-words rounded-lg bg-surface-2 px-3 py-2 font-mono text-xs text-muted">
|
||||
{error.message || 'Unknown error'}
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={this.handleReset}
|
||||
className="tap inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors duration-2 ease-enter hover:bg-primary/90"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" aria-hidden="true" />
|
||||
Try again
|
||||
</button>
|
||||
<a
|
||||
href={sourceHref ?? REPO_URL}
|
||||
className="tap inline-flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm font-medium transition-colors duration-2 ease-enter hover:bg-surface-2"
|
||||
>
|
||||
Read the source
|
||||
<ExternalLink className="h-4 w-4" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { ArrowRight, ListChecks, Scale, Target, TrendingUp } from 'lucide-react';
|
||||
import type { DemoModule } from '@/lib/demo-kit/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type Anatomy = DemoModule['anatomy'];
|
||||
|
||||
export interface EnvAnatomyProps {
|
||||
anatomy: Anatomy;
|
||||
/** `DemoMeta.rewardLine` — six words on what the reward pays for. */
|
||||
rewardLine?: string;
|
||||
/** Set on the gallery/overview page, where the four boxes are context rather
|
||||
* than the lesson, to drop the closing line and tighten the type. */
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface Box {
|
||||
key: keyof Anatomy;
|
||||
kicker: string;
|
||||
question: string;
|
||||
Icon: LucideIcon;
|
||||
}
|
||||
|
||||
/**
|
||||
* The four boxes, in the order an executive builds the mental model: what is
|
||||
* being asked, what the agent is allowed to do, who decides whether it was
|
||||
* good, and what number that decision moves.
|
||||
*
|
||||
* The kickers are deliberately generic. This object is the site's one piece of
|
||||
* transferable explanation: someone who learns the machine on a word game
|
||||
* should read the fraud demo as "same machine, different grader", and that only
|
||||
* works if the four labels never change between demos.
|
||||
*/
|
||||
const BOXES: Box[] = [
|
||||
{ key: 'task', kicker: 'The task', question: 'What is the agent asked to do?', Icon: Target },
|
||||
{
|
||||
key: 'actions',
|
||||
kicker: 'Legal actions',
|
||||
question: 'What is it allowed to do?',
|
||||
Icon: ListChecks,
|
||||
},
|
||||
{
|
||||
key: 'grader',
|
||||
kicker: 'The grader',
|
||||
question: 'Who decides whether it was good?',
|
||||
Icon: Scale,
|
||||
},
|
||||
{
|
||||
key: 'score',
|
||||
kicker: 'The score that moves',
|
||||
question: 'What number does that produce?',
|
||||
Icon: TrendingUp,
|
||||
},
|
||||
];
|
||||
|
||||
export function EnvAnatomy({ anatomy, rewardLine, compact = false, className }: EnvAnatomyProps) {
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
<ol className="flex flex-col lg:flex-row lg:items-stretch">
|
||||
{BOXES.map((box, index) => (
|
||||
<li
|
||||
key={box.key}
|
||||
className="flex flex-col items-stretch lg:flex-1 lg:flex-row lg:items-center"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'card flex flex-1 flex-col gap-2 p-4',
|
||||
// The grader is the box every later demo differs on. It is the
|
||||
// one the eye should land on second, after the task.
|
||||
box.key === 'grader' && 'border-brand/40 bg-accent-subtle/40',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-accent-subtle text-accent-fg">
|
||||
<box.Icon className="h-4 w-4" strokeWidth={2} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold leading-tight">
|
||||
<span className="nums mr-1.5 text-muted">{index + 1}</span>
|
||||
{box.kicker}
|
||||
</p>
|
||||
{!compact ? (
|
||||
<p className="text-xs leading-tight text-muted">{box.question}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<p className={cn('text-pretty text-fg', compact ? 'text-xs' : 'text-sm')}>
|
||||
{anatomy[box.key]}
|
||||
</p>
|
||||
</div>
|
||||
{index < BOXES.length - 1 ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="flex items-center justify-center py-2 text-muted lg:px-2 lg:py-0"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4 rotate-90 lg:rotate-0" />
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{rewardLine ? (
|
||||
<p className="text-sm text-muted">
|
||||
<span className="font-medium text-fg">This reward: </span>
|
||||
{rewardLine}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!compact ? (
|
||||
<p className="max-w-3xl text-pretty text-sm leading-relaxed text-muted">
|
||||
Every demo on this site is that same machine. The task changes, the legal actions
|
||||
change, and the grader changes — but the grader is always code you can read, and the
|
||||
score is always a number you can watch move. That is what makes an environment
|
||||
different from an eval:{' '}
|
||||
<span className="font-medium text-fg">
|
||||
an environment is an eval you can take the gradient of.
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* The sanctioned extension seam.
|
||||
*
|
||||
* A demo that needs something the shell does not have has two options: fork the
|
||||
* shell, or drop it into a slot. This is the second one. Slots are named and
|
||||
* finite, so `check-demos` can see what a demo added and reviewers can see it
|
||||
* in a diff — which is the whole reason this exists rather than letting demos
|
||||
* pass arbitrary children into arbitrary components.
|
||||
*
|
||||
* A slot with nothing in it renders NOTHING, not an empty box: the layout must
|
||||
* not shift depending on whether a demo opted in.
|
||||
*/
|
||||
export type SlotId =
|
||||
| 'hero-aside'
|
||||
| 'below-board'
|
||||
| 'beside-reward'
|
||||
| 'below-timeline'
|
||||
| 'before-limits'
|
||||
| 'after-receipts';
|
||||
|
||||
export interface SlotRegionProps {
|
||||
id: SlotId;
|
||||
/**
|
||||
* Announced to assistive tech when the slot has content. Omit for purely
|
||||
* decorative additions; a region with no label is not exposed as a landmark.
|
||||
*/
|
||||
label?: string;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SlotRegion({ id, label, children, className }: SlotRegionProps) {
|
||||
// `children` can be `false`/`null` from a demo's own conditional. Treat those
|
||||
// as "no slot content" rather than rendering a labelled empty region.
|
||||
if (children === null || children === undefined || children === false) return null;
|
||||
|
||||
if (!label) {
|
||||
return (
|
||||
<div data-slot={id} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section data-slot={id} aria-label={label} className={cn('contents', className)}>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type StatTone = 'default' | 'positive' | 'warning' | 'danger' | 'info' | 'brand';
|
||||
|
||||
export interface Stat {
|
||||
/** Short. Two or three words; it sits above the number. */
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
/** One clarifying line, shown under the number at a smaller size. */
|
||||
hint?: string;
|
||||
tone?: StatTone;
|
||||
/** Set when this number was derived under an edited reward, not recorded. */
|
||||
edited?: boolean;
|
||||
}
|
||||
|
||||
export interface StatStripProps {
|
||||
stats: Stat[];
|
||||
className?: string;
|
||||
/** Announce changes as they happen. Off by default — the shell owns the
|
||||
* page's single live region and two competing ones talk over each other. */
|
||||
live?: boolean;
|
||||
}
|
||||
|
||||
const TONE: Record<StatTone, string> = {
|
||||
default: 'text-fg',
|
||||
positive: 'text-positive',
|
||||
warning: 'text-warning',
|
||||
danger: 'text-danger',
|
||||
info: 'text-info',
|
||||
brand: 'text-accent-fg',
|
||||
};
|
||||
|
||||
/**
|
||||
* A row of headline numbers. Scrolls horizontally on a phone rather than
|
||||
* wrapping into a ragged grid: four stats reflowing to 2x2 at 390px puts the
|
||||
* least important number in the most prominent corner.
|
||||
*/
|
||||
export function StatStrip({ stats, className, live = false }: StatStripProps) {
|
||||
if (stats.length === 0) return null;
|
||||
return (
|
||||
<dl
|
||||
className={cn(
|
||||
'flex snap-x snap-mandatory gap-3 overflow-x-auto pb-1',
|
||||
'sm:grid sm:snap-none sm:overflow-visible sm:pb-0',
|
||||
stats.length <= 2 ? 'sm:grid-cols-2' : 'sm:grid-cols-3 lg:grid-cols-4',
|
||||
className,
|
||||
)}
|
||||
{...(live ? { 'aria-live': 'polite' as const } : {})}
|
||||
>
|
||||
{stats.map((stat) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
className="card min-w-[9.5rem] flex-1 shrink-0 snap-start px-4 py-3"
|
||||
>
|
||||
<dt className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-muted">
|
||||
<span className="truncate">{stat.label}</span>
|
||||
{stat.edited ? <EditedChip /> : null}
|
||||
</dt>
|
||||
<dd
|
||||
className={cn(
|
||||
'nums mt-1 text-2xl font-semibold leading-tight',
|
||||
TONE[stat.tone ?? 'default'],
|
||||
)}
|
||||
>
|
||||
{stat.value}
|
||||
</dd>
|
||||
{stat.hint ? <dd className="mt-0.5 text-xs text-muted">{stat.hint}</dd> : null}
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a number the visitor caused rather than one we recorded. It appears on
|
||||
* every derived value in the reward editor; without it, an edited ranking
|
||||
* screenshots identically to a measured one.
|
||||
*/
|
||||
export function EditedChip({ className }: { className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-md bg-accent-subtle px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-accent-fg',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
edited
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Circle, Pause, Play, RotateCcw } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatDate, usePrefersReducedMotion } from './format';
|
||||
|
||||
/** `instant` is not "very fast": it is "do not animate, show me the end". */
|
||||
export type PlaybackSpeed = 1 | 2 | 4 | 'instant';
|
||||
|
||||
export const PLAYBACK_SPEEDS: readonly PlaybackSpeed[] = [1, 2, 4, 'instant'];
|
||||
|
||||
/** Wall-clock dwell on a step at 1x. Not the model's real latency — see below. */
|
||||
const BASE_STEP_MS = 1800;
|
||||
|
||||
export interface UseTracePlaybackOptions {
|
||||
stepCount: number;
|
||||
step: number;
|
||||
onStepChange: (next: number) => void;
|
||||
/**
|
||||
* Dwell time for one step at 1x, in ms. Defaults to a fixed cadence rather
|
||||
* than the recorded `durationMs`, and that is deliberate: real calls run from
|
||||
* 300 ms to half a minute, so replaying at true latency produces a player
|
||||
* that appears frozen. The recorded latency is still shown, verbatim, in the
|
||||
* model-call panel — it is reported, just not used as a timeline.
|
||||
*/
|
||||
stepDurationMs?: (index: number) => number;
|
||||
initialSpeed?: PlaybackSpeed;
|
||||
}
|
||||
|
||||
export interface TracePlayback {
|
||||
playing: boolean;
|
||||
speed: PlaybackSpeed;
|
||||
setPlaying: (playing: boolean) => void;
|
||||
setSpeed: (speed: PlaybackSpeed) => void;
|
||||
toggle: () => void;
|
||||
restart: () => void;
|
||||
atEnd: boolean;
|
||||
}
|
||||
|
||||
export function useTracePlayback({
|
||||
stepCount,
|
||||
step,
|
||||
onStepChange,
|
||||
stepDurationMs,
|
||||
initialSpeed = 1,
|
||||
}: UseTracePlaybackOptions): TracePlayback {
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [speed, setSpeedState] = useState<PlaybackSpeed>(initialSpeed);
|
||||
const atEnd = step >= stepCount - 1;
|
||||
|
||||
// The callback identity changes on every render of the shell; holding it in a
|
||||
// ref keeps it out of the timer effect's deps, or the timer restarts on every
|
||||
// render and the step never lands.
|
||||
const onStepChangeRef = useRef(onStepChange);
|
||||
onStepChangeRef.current = onStepChange;
|
||||
|
||||
const setSpeed = useCallback(
|
||||
(next: PlaybackSpeed) => {
|
||||
setSpeedState(next);
|
||||
if (next === 'instant') {
|
||||
setPlaying(false);
|
||||
onStepChangeRef.current(Math.max(stepCount - 1, 0));
|
||||
}
|
||||
},
|
||||
[stepCount],
|
||||
);
|
||||
|
||||
const restart = useCallback(() => {
|
||||
onStepChangeRef.current(0);
|
||||
setPlaying(stepCount > 1);
|
||||
}, [stepCount]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (stepCount <= 1) return;
|
||||
setPlaying((was) => {
|
||||
if (was) return false;
|
||||
// Pressing play at the end replays from the top rather than doing
|
||||
// nothing, which is what every visitor expects and nobody says out loud.
|
||||
if (step >= stepCount - 1) onStepChangeRef.current(0);
|
||||
return true;
|
||||
});
|
||||
}, [step, stepCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing || speed === 'instant' || stepCount <= 1) return;
|
||||
if (step >= stepCount - 1) {
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
const base = stepDurationMs?.(step) ?? BASE_STEP_MS;
|
||||
const timer = window.setTimeout(() => {
|
||||
onStepChangeRef.current(step + 1);
|
||||
}, Math.max(base / speed, 120));
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [playing, speed, step, stepCount, stepDurationMs]);
|
||||
|
||||
return { playing, speed, setPlaying, setSpeed, toggle, restart, atEnd };
|
||||
}
|
||||
|
||||
export interface TracePlayerProps {
|
||||
playing: boolean;
|
||||
onPlayingChange: (playing: boolean) => void;
|
||||
speed: PlaybackSpeed;
|
||||
onSpeedChange: (speed: PlaybackSpeed) => void;
|
||||
onRestart: () => void;
|
||||
step: number;
|
||||
stepCount: number;
|
||||
onStepChange: (next: number) => void;
|
||||
/** Straight off the run: never a marketing name for the model. */
|
||||
model: string;
|
||||
capturedAt: string;
|
||||
/** Present only on an `intervened` run; the contract requires it there. */
|
||||
intervention?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport controls for a recorded rollout.
|
||||
*
|
||||
* There is no spinner anywhere in this component and there never should be. A
|
||||
* spinner implies a request is in flight; nothing here is live, and an exec who
|
||||
* believes they are watching a model think in real time has been misled by the
|
||||
* UI rather than the copy. Hence the permanent badge — it is not a disclosure
|
||||
* we tuck into a footnote, it sits in the transport bar for the whole session.
|
||||
*/
|
||||
export function TracePlayer({
|
||||
playing,
|
||||
onPlayingChange,
|
||||
speed,
|
||||
onSpeedChange,
|
||||
onRestart,
|
||||
step,
|
||||
stepCount,
|
||||
onStepChange,
|
||||
model,
|
||||
capturedAt,
|
||||
intervention,
|
||||
className,
|
||||
}: TracePlayerProps) {
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
const canPlay = stepCount > 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'card flex flex-wrap items-center gap-x-3 gap-y-2 p-2 sm:gap-x-4',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="tap grid place-items-center rounded-lg text-muted transition-colors duration-2 ease-enter hover:bg-surface-2 hover:text-fg disabled:opacity-40"
|
||||
onClick={() => onStepChange(Math.max(step - 1, 0))}
|
||||
disabled={step <= 0}
|
||||
aria-label="Previous step"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tap grid place-items-center rounded-lg bg-primary px-4 text-primary-foreground transition-colors duration-2 ease-enter hover:bg-primary/90 disabled:opacity-40"
|
||||
onClick={() => onPlayingChange(!playing)}
|
||||
disabled={!canPlay}
|
||||
aria-label={playing ? 'Pause the recorded run' : 'Play the recorded run'}
|
||||
aria-keyshortcuts="Space"
|
||||
>
|
||||
{playing ? (
|
||||
<Pause className="h-5 w-5" aria-hidden="true" />
|
||||
) : (
|
||||
<Play className="h-5 w-5" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tap grid place-items-center rounded-lg text-muted transition-colors duration-2 ease-enter hover:bg-surface-2 hover:text-fg disabled:opacity-40"
|
||||
onClick={() => onStepChange(Math.min(step + 1, Math.max(stepCount - 1, 0)))}
|
||||
disabled={step >= stepCount - 1}
|
||||
aria-label="Next step"
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tap grid place-items-center rounded-lg text-muted transition-colors duration-2 ease-enter hover:bg-surface-2 hover:text-fg"
|
||||
onClick={onRestart}
|
||||
aria-label="Restart from the first step"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="nums text-sm text-muted">
|
||||
Step <span className="font-semibold text-fg">{Math.min(step + 1, stepCount)}</span> of{' '}
|
||||
{stepCount}
|
||||
</p>
|
||||
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Playback speed"
|
||||
className="flex items-center gap-0.5 rounded-lg bg-surface-2 p-0.5"
|
||||
>
|
||||
{PLAYBACK_SPEEDS.map((option) => {
|
||||
const selected = option === speed;
|
||||
return (
|
||||
<button
|
||||
key={String(option)}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
onClick={() => onSpeedChange(option)}
|
||||
className={cn(
|
||||
'min-h-9 rounded-md px-2.5 text-xs font-medium transition-colors duration-2 ease-enter',
|
||||
selected
|
||||
? 'bg-surface text-fg shadow-sm'
|
||||
: 'text-muted hover:text-fg',
|
||||
)}
|
||||
>
|
||||
{option === 'instant' ? 'Instant' : `${option}x`}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<RecordedBadge model={model} capturedAt={capturedAt} intervention={intervention} />
|
||||
|
||||
{reducedMotion ? (
|
||||
// Not an apology — a statement that the page is behaving as asked. The
|
||||
// steps still advance; only the tile flips and slides are gone.
|
||||
<p className="sr-only">
|
||||
Reduced motion is on. Steps still advance and every change is announced.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface RecordedBadgeProps {
|
||||
model: string;
|
||||
capturedAt: string;
|
||||
intervention?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RecordedBadge({
|
||||
model,
|
||||
capturedAt,
|
||||
intervention,
|
||||
className,
|
||||
}: RecordedBadgeProps) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
'ml-auto flex flex-wrap items-center gap-x-1.5 gap-y-1 rounded-lg bg-surface-2 px-2.5 py-1.5 text-xs text-muted',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Circle className="h-2 w-2 shrink-0 fill-muted text-muted" aria-hidden="true" />
|
||||
<span className="font-medium text-fg">Recorded run</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span className="nums font-mono">{model}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span className="nums">{formatDate(capturedAt)}</span>
|
||||
{intervention ? (
|
||||
<span className="rounded-md bg-accent-subtle px-1.5 py-0.5 font-medium text-accent-fg">
|
||||
{intervention}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Formatting and environment hooks shared by every shell component.
|
||||
*
|
||||
* Numbers on this site are evidence, so formatting is centralised: an exec who
|
||||
* sees `0.81` in one panel and `0.812` in the next assumes one of them is
|
||||
* rounded in someone's favour. Everything that renders a recorded number goes
|
||||
* through here, and everything that renders one wears `.nums`.
|
||||
*/
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
|
||||
/**
|
||||
* A missing recorded number is an em dash, never a zero. `0` is a measurement;
|
||||
* `—` is the absence of one, and the difference is the whole point of the site.
|
||||
*/
|
||||
export const DASH = '—';
|
||||
|
||||
export function formatNumber(value: number, digits = 3): string {
|
||||
if (!Number.isFinite(value)) return DASH;
|
||||
return value.toFixed(digits);
|
||||
}
|
||||
|
||||
export function formatOrDash(value: number | null | undefined, digits = 3): string {
|
||||
return value === null || value === undefined ? DASH : formatNumber(value, digits);
|
||||
}
|
||||
|
||||
export function formatInt(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined || !Number.isFinite(value)) return DASH;
|
||||
// en-US grouping is pinned rather than taken from the visitor: the page is
|
||||
// prerendered, and a locale-dependent separator makes the built HTML and the
|
||||
// hydrated DOM disagree.
|
||||
return value.toLocaleString('en-US');
|
||||
}
|
||||
|
||||
export function formatMs(ms: number | null | undefined): string {
|
||||
if (ms === null || ms === undefined || !Number.isFinite(ms)) return DASH;
|
||||
if (ms < 1000) return `${Math.round(ms)} ms`;
|
||||
return `${(ms / 1000).toFixed(ms < 10_000 ? 2 : 1)} s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The verification delta. Seven decimals is not decoration: it is the number
|
||||
* that tells a sceptical engineer we compared floats rather than strings.
|
||||
*/
|
||||
export function formatDelta(delta: number): string {
|
||||
if (!Number.isFinite(delta)) return DASH;
|
||||
// -0 prints as "-0.0000000" and reads like a failure. Normalise it.
|
||||
const normalised = Object.is(delta, -0) ? 0 : delta;
|
||||
return normalised.toFixed(7);
|
||||
}
|
||||
|
||||
export function formatSigned(value: number, digits = 2): string {
|
||||
if (!Number.isFinite(value)) return DASH;
|
||||
const sign = value > 0 ? '+' : '';
|
||||
return `${sign}${value.toFixed(digits)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `2026-08-12` and `2026-08-12T09:31:00Z` both render as `12 Aug 2026`.
|
||||
* Formatted in UTC on purpose: a bare ISO date parses as midnight UTC, and a
|
||||
* visitor west of Greenwich would otherwise see the day before the capture.
|
||||
*/
|
||||
export function formatDate(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return iso;
|
||||
return date.toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
|
||||
/** `finish_reason` values are snake_case off the wire; humans read spaces. */
|
||||
export function humaniseToken(token: string): string {
|
||||
return token.replace(/[_-]+/g, ' ');
|
||||
}
|
||||
|
||||
function subscribeToQuery(query: string) {
|
||||
return (onChange: () => void) => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
|
||||
const list = window.matchMedia(query);
|
||||
list.addEventListener('change', onChange);
|
||||
return () => list.removeEventListener('change', onChange);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Media queries as React state. `useSyncExternalStore` rather than an effect,
|
||||
* because the server snapshot is explicit: the prerendered HTML is built at the
|
||||
* desktop, motion-allowed default and corrects itself on the client.
|
||||
*/
|
||||
export function useMediaQuery(query: string, serverValue = false): boolean {
|
||||
const subscribe = useCallback(subscribeToQuery(query), [query]);
|
||||
const getSnapshot = useCallback(() => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return serverValue;
|
||||
return window.matchMedia(query).matches;
|
||||
}, [query, serverValue]);
|
||||
return useSyncExternalStore(subscribe, getSnapshot, () => serverValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduced motion is not only a CSS concern here. The CSS clamps transitions,
|
||||
* but the trace player and the reasoning stream are JS timers: they have to
|
||||
* resolve to their final state immediately, or a visitor who asked for no
|
||||
* motion gets the animation anyway, just without the easing.
|
||||
*/
|
||||
export function usePrefersReducedMotion(): boolean {
|
||||
return useMediaQuery('(prefers-reduced-motion: reduce)');
|
||||
}
|
||||
|
||||
/** The one breakpoint the shell branches on: the drawer/panel split. */
|
||||
export function useIsDesktop(): boolean {
|
||||
return useMediaQuery('(min-width: 1024px)', true);
|
||||
}
|
||||
|
||||
/** Clamp that also copes with a NaN out of `Number(searchParam)`. */
|
||||
export function clampIndex(value: number, length: number): number {
|
||||
if (!Number.isFinite(value) || length <= 0) return 0;
|
||||
return Math.min(Math.max(Math.trunc(value), 0), length - 1);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Resolving `DemoMeta.icon` — a lucide export NAME — to a component.
|
||||
*
|
||||
* The obvious implementations are both wrong, and both were tried:
|
||||
*
|
||||
* `import * as lucide from 'lucide-react'` — kills tree-shaking. Every icon
|
||||
* in the library (~1,500) lands in a chunk to render twelve of them.
|
||||
*
|
||||
* `import('lucide-react/dynamicIconImports')` — correct at runtime, but the
|
||||
* map holds a dynamic import per icon, so Rollup emits ~1,500 chunk files
|
||||
* into `dist/` for a static site that serves twelve.
|
||||
*
|
||||
* So the shell keeps an explicit registry. Adding a demo means adding its icon
|
||||
* here; that is one line, and in exchange the entry chunk stays honest. An
|
||||
* unknown name renders the neutral fallback rather than throwing, because a
|
||||
* typo in a demo's metadata must not take the gallery down.
|
||||
*/
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
Blocks,
|
||||
Boxes,
|
||||
Braces,
|
||||
Building2,
|
||||
ClipboardCheck,
|
||||
Code,
|
||||
Cpu,
|
||||
Database,
|
||||
FileSearch,
|
||||
Gauge,
|
||||
Grid3x3,
|
||||
HeartPulse,
|
||||
Landmark,
|
||||
LifeBuoy,
|
||||
MessagesSquare,
|
||||
Package,
|
||||
PhoneCall,
|
||||
Plug,
|
||||
Radio,
|
||||
Receipt,
|
||||
Scale,
|
||||
ShieldCheck,
|
||||
ShoppingCart,
|
||||
Stethoscope,
|
||||
Truck,
|
||||
Wallet,
|
||||
Workflow,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
const REGISTRY: Record<string, LucideIcon> = {
|
||||
Blocks,
|
||||
Boxes,
|
||||
Braces,
|
||||
Building2,
|
||||
ClipboardCheck,
|
||||
Code,
|
||||
Cpu,
|
||||
Database,
|
||||
FileSearch,
|
||||
Gauge,
|
||||
Grid3x3,
|
||||
HeartPulse,
|
||||
Landmark,
|
||||
LifeBuoy,
|
||||
MessagesSquare,
|
||||
Package,
|
||||
PhoneCall,
|
||||
Plug,
|
||||
Radio,
|
||||
Receipt,
|
||||
Scale,
|
||||
ShieldCheck,
|
||||
ShoppingCart,
|
||||
Stethoscope,
|
||||
Truck,
|
||||
Wallet,
|
||||
Workflow,
|
||||
Zap,
|
||||
};
|
||||
|
||||
export const FallbackDemoIcon: LucideIcon = Boxes;
|
||||
|
||||
/** Every icon name the shell can render, for `check-demos` to assert against. */
|
||||
export const KNOWN_ICON_NAMES: readonly string[] = Object.keys(REGISTRY);
|
||||
|
||||
export function resolveDemoIcon(name: string | undefined): LucideIcon {
|
||||
if (!name) return FallbackDemoIcon;
|
||||
return REGISTRY[name] ?? FallbackDemoIcon;
|
||||
}
|
||||
|
||||
export interface DemoIconProps {
|
||||
/** A lucide export name from `DemoMeta.icon`, e.g. `Grid3x3`. */
|
||||
name: string | undefined;
|
||||
className?: string;
|
||||
/** Icons here are always decorative — the label beside them carries the name. */
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
export function DemoIcon({ name, className, strokeWidth = 1.75 }: DemoIconProps) {
|
||||
const Icon = resolveDemoIcon(name);
|
||||
return <Icon className={className} strokeWidth={strokeWidth} aria-hidden="true" />;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import * as React from 'react';
|
||||
import { Contrast } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const STORAGE_KEY = 'pig-demo:contrast';
|
||||
|
||||
/**
|
||||
* `localStorage` is not always readable. In a cross-origin iframe with third-
|
||||
* party storage blocked, and in Safari private mode, the getter itself THROWS
|
||||
* rather than returning null — so every access has to be wrapped, not just
|
||||
* null-checked. Unreadable storage means "off", never a crash.
|
||||
*/
|
||||
function readStored(): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(STORAGE_KEY) === 'high';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStored(high: boolean): void {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, high ? 'high' : 'normal');
|
||||
} catch {
|
||||
/* Preference is session-only here. The toggle still works. */
|
||||
}
|
||||
}
|
||||
|
||||
export function ContrastToggle({ className }: { className?: string }) {
|
||||
const [high, setHigh] = React.useState(false);
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const stored = readStored();
|
||||
setHigh(stored);
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!mounted) return;
|
||||
const root = document.documentElement;
|
||||
// Removing the attribute rather than setting it to "normal": the CSS keys
|
||||
// off `:root[data-contrast='high']`, and leaving a stale attribute behind
|
||||
// makes the DOM lie about the palette that is actually applied.
|
||||
if (high) root.setAttribute('data-contrast', 'high');
|
||||
else root.removeAttribute('data-contrast');
|
||||
}, [high, mounted]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-touch"
|
||||
className={cn('lg:size-9', high && 'bg-accent-subtle text-accent-fg', className)}
|
||||
aria-pressed={high}
|
||||
aria-label={
|
||||
high ? 'High contrast tiles on. Turn off.' : 'High contrast tiles off. Turn on.'
|
||||
}
|
||||
title="High contrast tiles"
|
||||
onClick={() => {
|
||||
const next = !high;
|
||||
setHigh(next);
|
||||
writeStored(next);
|
||||
}}
|
||||
>
|
||||
<Contrast aria-hidden="true" />
|
||||
<span aria-live="polite" className="sr-only">
|
||||
{mounted ? (high ? 'High contrast on' : 'High contrast off') : ''}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
Boxes,
|
||||
BookOpen,
|
||||
Code2,
|
||||
Database,
|
||||
FlaskConical,
|
||||
Gauge,
|
||||
Grid3x3,
|
||||
Headset,
|
||||
Landmark,
|
||||
LifeBuoy,
|
||||
Network,
|
||||
Package,
|
||||
PhoneCall,
|
||||
Plane,
|
||||
RadioTower,
|
||||
Receipt,
|
||||
Scale,
|
||||
ShieldCheck,
|
||||
ShoppingCart,
|
||||
Stethoscope,
|
||||
Target,
|
||||
Truck,
|
||||
Wallet,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* `DemoMeta.icon` is a lucide NAME, not a component — that is deliberate, and
|
||||
* types.ts says why: importing the component in the meta would drag lucide into
|
||||
* the entry chunk for every demo at once.
|
||||
*
|
||||
* Resolving the name therefore has to happen against a static map. A dynamic
|
||||
* `import * as lucide` here would work and would also pull all 1,500 icons into
|
||||
* this chunk, which is the exact cost the contract was avoiding. So: named
|
||||
* imports, tree-shaken to what is listed, and an unknown name falls back to a
|
||||
* neutral glyph rather than rendering nothing. If you add a demo whose icon
|
||||
* lands on the fallback, add the name here — that is the one line the header
|
||||
* ever needs.
|
||||
*/
|
||||
const ICONS: Record<string, LucideIcon> = {
|
||||
BookOpen,
|
||||
Boxes,
|
||||
Code2,
|
||||
Database,
|
||||
FlaskConical,
|
||||
Gauge,
|
||||
Grid3x3,
|
||||
Headset,
|
||||
Landmark,
|
||||
LifeBuoy,
|
||||
Network,
|
||||
Package,
|
||||
PhoneCall,
|
||||
Plane,
|
||||
RadioTower,
|
||||
Receipt,
|
||||
Scale,
|
||||
ShieldCheck,
|
||||
ShoppingCart,
|
||||
Stethoscope,
|
||||
Target,
|
||||
Truck,
|
||||
Wallet,
|
||||
Zap,
|
||||
};
|
||||
|
||||
/** Used when a vertical has no icon of its own to offer. */
|
||||
export const VERTICAL_ICONS: Record<string, string> = {
|
||||
reference: 'Grid3x3',
|
||||
support: 'Headset',
|
||||
healthcare: 'Stethoscope',
|
||||
insurance: 'ShieldCheck',
|
||||
'financial-crime': 'Landmark',
|
||||
energy: 'Zap',
|
||||
logistics: 'Truck',
|
||||
code: 'Code2',
|
||||
retail: 'ShoppingCart',
|
||||
telecom: 'RadioTower',
|
||||
data: 'Database',
|
||||
legal: 'Scale',
|
||||
};
|
||||
|
||||
export function DemoIcon({ name, className }: { name: string; className?: string }) {
|
||||
const Icon = ICONS[name] ?? Boxes;
|
||||
return <Icon aria-hidden="true" className={cn('size-4 shrink-0', className)} />;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Github } from 'lucide-react';
|
||||
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { PIG_URL, REPO_URL } from '@/components/site/links';
|
||||
import { Wordmark } from '@/components/site/Wordmark';
|
||||
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer className="mt-16 border-t border-border bg-surface">
|
||||
<div className="mx-auto max-w-canvas px-4 pb-[max(2rem,var(--safe-bottom))] pl-[max(1rem,var(--safe-left))] pr-[max(1rem,var(--safe-right))] pt-8">
|
||||
<div className="flex flex-col gap-6 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Wordmark className="text-base" />
|
||||
<p className="max-w-sm text-sm text-muted">
|
||||
An environment is an eval you can take the gradient of.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav aria-label="Footer" className="flex flex-col gap-1 text-sm">
|
||||
<a
|
||||
className="tap inline-flex items-center gap-2 py-1 text-fg underline-offset-4 hover:text-accent-fg hover:underline"
|
||||
href={REPO_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
<Github aria-hidden="true" className="size-4" />
|
||||
Source on GitHub
|
||||
</a>
|
||||
<Link
|
||||
className="tap inline-flex items-center py-1 text-fg underline-offset-4 hover:text-accent-fg hover:underline"
|
||||
to="/honesty"
|
||||
>
|
||||
What we are not claiming
|
||||
</Link>
|
||||
<a
|
||||
className="tap inline-flex items-center py-1 text-fg underline-offset-4 hover:text-accent-fg hover:underline"
|
||||
href={PIG_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
primeintellectgrowth.com
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<Separator className="my-6" />
|
||||
|
||||
<div className="flex flex-col gap-2 text-xs leading-relaxed text-muted">
|
||||
<p>
|
||||
Every number on this site is reproducible from the repository: the environments, the
|
||||
recorded rollouts and the command that produced them all ship with the source.
|
||||
</p>
|
||||
<p>
|
||||
Licensed{' '}
|
||||
<a
|
||||
className="underline underline-offset-2 hover:text-accent-fg"
|
||||
href={`${REPO_URL}/blob/main/LICENSE`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
Apache-2.0
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
{/*
|
||||
Stated plainly and kept in the footer of every page. The word game
|
||||
demo is an independent implementation of a public game mechanic; the
|
||||
trademark belongs to someone else and this site must never read as
|
||||
if it were theirs.
|
||||
*/}
|
||||
<p>
|
||||
Not affiliated with, endorsed by, or connected to The New York Times Company. Wordle is
|
||||
their trademark.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* First focusable thing on the page. `sr-only` until focused, then a real,
|
||||
* visible control — it sits above the sticky header's z-50, because a skip link
|
||||
* that focuses behind the header is worse than none at all.
|
||||
*/
|
||||
export function SkipLink() {
|
||||
return (
|
||||
<a
|
||||
href="#main"
|
||||
className="sr-only focus-visible:not-sr-only focus-visible:fixed focus-visible:left-3 focus-visible:top-[max(0.75rem,var(--safe-top))] focus-visible:z-[60] focus-visible:inline-flex focus-visible:min-h-11 focus-visible:items-center focus-visible:rounded-md focus-visible:border focus-visible:border-border focus-visible:bg-surface focus-visible:px-4 focus-visible:text-sm focus-visible:font-medium focus-visible:text-fg focus-visible:shadow-lg"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as React from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Laptop, Moon, Sun } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Mode = 'light' | 'dark' | 'system';
|
||||
|
||||
const ORDER: readonly Mode[] = ['light', 'dark', 'system'] as const;
|
||||
|
||||
const LABEL: Record<Mode, string> = {
|
||||
light: 'Light',
|
||||
dark: 'Dark',
|
||||
system: 'Match system',
|
||||
};
|
||||
|
||||
/**
|
||||
* Cycles light -> dark -> system.
|
||||
*
|
||||
* The mounted guard is not ceremony: `theme` is `undefined` on the server and
|
||||
* on the first client render, so painting an icon before that resolves renders
|
||||
* the wrong one and then swaps it — the flash this component exists to avoid.
|
||||
* The placeholder is the same size as the button so the header does not reflow
|
||||
* when it resolves.
|
||||
*/
|
||||
export function ThemeToggle({ className }: { className?: string }) {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
React.useEffect(() => setMounted(true), []);
|
||||
|
||||
if (!mounted) {
|
||||
return <div className={cn('size-11 lg:size-9', className)} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
const current: Mode = theme === 'dark' || theme === 'light' ? theme : 'system';
|
||||
const next = ORDER[(ORDER.indexOf(current) + 1) % ORDER.length] ?? 'system';
|
||||
const Icon = current === 'light' ? Sun : current === 'dark' ? Moon : Laptop;
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-touch"
|
||||
className={cn('lg:size-9', className)}
|
||||
onClick={() => setTheme(next)}
|
||||
// The label states where you ARE and where the press takes you, because
|
||||
// an icon-only toggle announced as just "Theme" tells a screen-reader
|
||||
// user nothing about what pressing it will do.
|
||||
aria-label={`Theme: ${LABEL[current]}. Switch to ${LABEL[next].toLowerCase()}.`}
|
||||
title={`Theme: ${LABEL[current]}`}
|
||||
>
|
||||
<Icon aria-hidden="true" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Two words, one tone shift: the brand carries the accent, the qualifier does
|
||||
* not. Rendered as a single string for a screen reader so it is announced
|
||||
* "PIG demo" rather than as two unrelated fragments.
|
||||
*/
|
||||
export function Wordmark({ className }: { className?: string }) {
|
||||
return (
|
||||
<span className={cn('inline-flex items-baseline gap-1 font-semibold tracking-tight', className)}>
|
||||
<span aria-hidden="true" className="text-accent-fg">
|
||||
PIG
|
||||
</span>
|
||||
<span aria-hidden="true" className="font-normal lowercase text-muted">
|
||||
demo
|
||||
</span>
|
||||
<span className="sr-only">PIG demo</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* The three off-site destinations the chrome links to, in one place so the
|
||||
* header and the footer can never disagree about them.
|
||||
*/
|
||||
export const REPO_URL = 'https://github.com/karti-ai/PIG-Demo';
|
||||
export const PIG_URL = 'https://primeintellectgrowth.com';
|
||||
export const VERIFIERS_WORDLE_URL =
|
||||
'https://github.com/PrimeIntellect-ai/verifiers/tree/main/environments/wordle';
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as React from 'react';
|
||||
import * as AccordionPrimitive from '@radix-ui/react-accordion';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Accordion = AccordionPrimitive.Root;
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return <AccordionPrimitive.Item className={cn('border-b border-border', className)} {...props} />;
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
className={cn(
|
||||
'tap flex flex-1 items-center justify-between gap-3 py-3 text-left text-sm font-medium text-fg transition-colors duration-1 ease-enter hover:text-accent-fg [&[data-state=open]>svg]:rotate-180',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className="size-4 shrink-0 text-muted transition-transform duration-2 ease-enter"
|
||||
/>
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* There is no height animation here on purpose.
|
||||
*
|
||||
* shadcn's accordion animates height with `accordion-down` / `accordion-up`
|
||||
* keyframes that its CLI writes into tailwind.config.js. That file is
|
||||
* hand-maintained in this repo and is not ours to edit, so those keyframes do
|
||||
* not exist. A `transition-[height]` on `--radix-accordion-content-height`
|
||||
* looks like a substitute and is not one: Radix's Presence waits for an
|
||||
* `animationend`, so with no animation-name the node unmounts the instant you
|
||||
* collapse it and the closing transition never plays. Fade + slide is a real
|
||||
* animation, so Presence holds the node, and it degrades correctly under
|
||||
* reduced motion.
|
||||
*/
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
className="overflow-hidden duration-2 ease-enter data-[state=closed]:animate-out data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-1"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn('pb-3 pt-0', className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-medium leading-5 transition-colors duration-1 ease-enter',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-accent-subtle text-accent-fg',
|
||||
outline: 'border-border text-muted',
|
||||
solid: 'border-transparent bg-brand text-accent-on',
|
||||
positive: 'border-transparent bg-positive/10 text-positive',
|
||||
warning: 'border-transparent bg-warning/10 text-warning',
|
||||
danger: 'border-transparent bg-danger/10 text-danger',
|
||||
info: 'border-transparent bg-info/10 text-info',
|
||||
muted: 'border-border bg-surface-2 text-muted',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default' },
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLSpanElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* shadcn "new-york" button, retuned to PIG's tokens.
|
||||
*
|
||||
* Note `accent` in tailwind.config.js is the SUBTLE hover surface, not the
|
||||
* brand — that mapping is deliberate and documented there. So the solid CTA
|
||||
* uses `bg-brand text-accent-on`, never `bg-accent`.
|
||||
*/
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors duration-1 ease-enter disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-brand text-accent-on shadow-sm hover:bg-brand/90',
|
||||
secondary: 'bg-surface-2 text-fg hover:bg-surface-2/70',
|
||||
outline: 'border border-border bg-surface text-fg hover:bg-surface-2',
|
||||
ghost: 'text-fg hover:bg-surface-2',
|
||||
subtle: 'bg-accent-subtle text-accent-fg hover:bg-accent-subtle/70',
|
||||
destructive: 'bg-danger text-white shadow-sm hover:bg-danger/90',
|
||||
link: 'text-accent-fg underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
// `h-9` is 36px, which is fine for a mouse and too small for a thumb.
|
||||
// Anything a phone visitor taps gets `size="touch"` or the `.tap`
|
||||
// helper on top — see SiteHeader.
|
||||
sm: 'h-8 rounded-md px-3 text-xs [&_svg]:size-3.5',
|
||||
default: 'h-9 px-4 py-2 [&_svg]:size-4',
|
||||
lg: 'h-11 rounded-lg px-6 [&_svg]:size-4',
|
||||
touch: 'min-h-11 px-4 py-2 [&_svg]:size-4',
|
||||
icon: 'size-9 [&_svg]:size-4',
|
||||
'icon-touch': 'size-11 [&_svg]:size-[1.125rem]',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default', size: 'default' },
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return <Comp className={cn(buttonVariants({ variant, size }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** `.card` is defined in src/index.css so the shell and the demos agree on
|
||||
* one surface treatment; this component is the React face of it. */
|
||||
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div className={cn('card text-fg', className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div className={cn('flex flex-col gap-1.5 p-5', className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<'h3'>) {
|
||||
return <h3 className={cn('font-semibold leading-tight tracking-tight', className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
return <p className={cn('text-sm text-muted', className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div className={cn('p-5 pt-0', className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div className={cn('flex items-center p-5 pt-0', className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };
|
||||
@@ -0,0 +1,98 @@
|
||||
import * as React from 'react';
|
||||
import { Drawer as DrawerPrimitive } from 'vaul';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* vaul, not Radix Dialog: this is the sheet you drag, used where a phone
|
||||
* visitor expects to flick a panel away (the reward editor, step detail).
|
||||
* `shouldScaleBackground` is off — it transforms `body`, which breaks
|
||||
* `position: fixed` on the sticky header underneath it.
|
||||
*/
|
||||
function Drawer({
|
||||
shouldScaleBackground = false,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />;
|
||||
}
|
||||
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger;
|
||||
const DrawerPortal = DrawerPrimitive.Portal;
|
||||
const DrawerClose = DrawerPrimitive.Close;
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||
return <DrawerPrimitive.Overlay className={cn('fixed inset-0 z-50 bg-fg/40', className)} {...props} />;
|
||||
}
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||
return (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
className={cn(
|
||||
'fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto max-h-[88svh] flex-col rounded-t-xl border border-border bg-surface pb-[var(--safe-bottom)]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* The grab handle is decorative; the drawer is also closable with
|
||||
Escape and by the close control the caller renders. */}
|
||||
<div className="mx-auto mt-3 h-1.5 w-12 shrink-0 rounded-full bg-border" aria-hidden="true" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div className={cn('grid gap-1 p-4 text-left', className)} {...props} />;
|
||||
}
|
||||
|
||||
function DrawerBody({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div className={cn('mt-auto flex flex-col gap-2 p-4', className)} {...props} />;
|
||||
}
|
||||
|
||||
function DrawerTitle({ className, ...props }: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title className={cn('text-base font-semibold text-fg', className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description className={cn('text-sm text-muted', className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerTrigger,
|
||||
DrawerPortal,
|
||||
DrawerClose,
|
||||
DrawerOverlay,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerBody,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
import * as React from 'react';
|
||||
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Tailwind 3.4 port of shadcn's navigation-menu.
|
||||
*
|
||||
* The registry version on ui.shadcn.com now targets Tailwind 4 and leans on
|
||||
* v4-only pieces (`size-*` everywhere, `@theme` tokens, the CSS-first config).
|
||||
* Everything below is expressible in 3.4 with tailwindcss-animate, which is
|
||||
* already a plugin here. Three things break if you change them carelessly:
|
||||
*
|
||||
* 1. VIEWPORT POSITIONING. The viewport is not inside the trigger — Radix
|
||||
* hoists every open panel into one shared box. It only lands under the menu
|
||||
* because it sits in an `absolute left-0 top-full` wrapper that is a child
|
||||
* of the *Root*, and because the Root is `relative`. Move the wrapper out of
|
||||
* the Root, or drop `relative`, and the panel positions against the page.
|
||||
* 2. WIDTH. `--radix-navigation-menu-viewport-width` is the measured width of
|
||||
* the open panel. Without that binding the viewport shrink-wraps to nothing
|
||||
* on the first frame and the panel visibly snaps to size.
|
||||
* 3. Z-INDEX. The header that hosts this is `position: sticky` with a
|
||||
* `backdrop-filter`, which makes it a stacking context, so the viewport's
|
||||
* z-index competes only inside the header — but it must still clear the
|
||||
* header's own translucent background, hence z-50 rather than the z-10 the
|
||||
* upstream recipe uses.
|
||||
*/
|
||||
function NavigationMenu({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Root
|
||||
className={cn('relative z-50 flex max-w-max flex-1 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<NavigationMenuViewport />
|
||||
</NavigationMenuPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.List
|
||||
className={cn('group flex flex-1 list-none items-center justify-center gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const NavigationMenuItem = NavigationMenuPrimitive.Item;
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
'group inline-flex h-9 w-max items-center justify-center gap-1 rounded-md px-3 py-2 text-sm font-medium text-fg transition-colors duration-1 ease-enter hover:bg-surface-2 disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-surface-2',
|
||||
);
|
||||
|
||||
function NavigationMenuTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
className={cn(navigationMenuTriggerStyle(), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className="relative top-px size-3.5 text-muted transition-transform duration-3 ease-enter group-data-[state=open]:rotate-180"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `data-motion` is set by Radix when you move sideways from one open panel to
|
||||
* the next; the slide utilities below are what make that read as one surface
|
||||
* sliding rather than two panels blinking. The `md:absolute` flip is the
|
||||
* upstream trick that lets the content measure itself at full width before the
|
||||
* viewport adopts that width.
|
||||
*/
|
||||
function NavigationMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Content
|
||||
className={cn(
|
||||
'left-0 top-0 w-full duration-3 ease-enter data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const NavigationMenuLink = NavigationMenuPrimitive.Link;
|
||||
|
||||
function NavigationMenuViewport({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
||||
return (
|
||||
<div className="absolute left-0 top-full z-50 flex justify-center">
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
className={cn(
|
||||
'relative mt-2 h-[var(--radix-navigation-menu-viewport-height)] w-full origin-top overflow-hidden rounded-xl border border-border bg-surface text-fg shadow-xl',
|
||||
'transition-[width,height] duration-3 ease-enter',
|
||||
'data-[state=closed]:animate-out data-[state=open]:animate-in data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'md:w-[var(--radix-navigation-menu-viewport-width)]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuIndicator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
className={cn(
|
||||
'top-full z-50 flex h-2 items-end justify-center overflow-hidden duration-3 ease-enter data-[state=hidden]:animate-out data-[state=visible]:animate-in data-[state=hidden]:fade-out data-[state=visible]:fade-in',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* Rotated square, half-clipped by the parent's overflow — a caret that
|
||||
inherits the panel's border and surface without a second SVG. */}
|
||||
<div className="relative top-[60%] size-2 rotate-45 rounded-tl-sm border-l border-t border-border bg-surface" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
navigationMenuTriggerStyle,
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as React from 'react';
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = 'vertical',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none select-none transition-colors duration-1 ease-enter',
|
||||
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root className={cn('relative overflow-hidden', className)} {...props}>
|
||||
{/*
|
||||
`h-full w-full` on the viewport is load-bearing: Radix renders a
|
||||
`display:table` element inside it, which will happily grow past a
|
||||
max-height and leave you with a scroll area that never scrolls.
|
||||
*/}
|
||||
<ScrollAreaPrimitive.Viewport className="size-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as React from 'react';
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = 'horizontal',
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
@@ -0,0 +1,141 @@
|
||||
import * as React from 'react';
|
||||
import * as SheetPrimitive from '@radix-ui/react-dialog';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Sheet = SheetPrimitive.Root;
|
||||
const SheetTrigger = SheetPrimitive.Trigger;
|
||||
const SheetClose = SheetPrimitive.Close;
|
||||
const SheetPortal = SheetPrimitive.Portal;
|
||||
|
||||
function SheetOverlay({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-fg/40 backdrop-blur-[2px] duration-3 ease-enter data-[state=closed]:animate-out data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const sheetVariants = cva(
|
||||
'fixed z-50 flex flex-col gap-0 bg-surface shadow-xl duration-3 ease-enter data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: 'inset-x-0 top-0 border-b border-border pt-[var(--safe-top)] data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
||||
bottom:
|
||||
'inset-x-0 bottom-0 border-t border-border pb-[var(--safe-bottom)] data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
||||
left: 'inset-y-0 left-0 h-full w-[min(88vw,22rem)] border-r border-border pl-[var(--safe-left)] data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left',
|
||||
right:
|
||||
'inset-y-0 right-0 h-full w-[min(88vw,22rem)] border-l border-border pr-[var(--safe-right)] data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right',
|
||||
},
|
||||
},
|
||||
defaultVariants: { side: 'right' },
|
||||
},
|
||||
);
|
||||
|
||||
export interface SheetContentProps
|
||||
extends React.ComponentProps<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {
|
||||
/** Set false when the sheet supplies its own close affordance. */
|
||||
showClose?: boolean;
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
side = 'right',
|
||||
className,
|
||||
children,
|
||||
showClose = true,
|
||||
...props
|
||||
}: SheetContentProps) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content className={cn(sheetVariants({ side }), className)} {...props}>
|
||||
{children}
|
||||
{showClose ? (
|
||||
<SheetPrimitive.Close
|
||||
className="tap absolute right-3 top-3 inline-flex items-center justify-center rounded-md p-2 text-muted transition-colors duration-1 ease-enter hover:bg-surface-2 hover:text-fg"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X aria-hidden="true" className="size-5" />
|
||||
</SheetPrimitive.Close>
|
||||
) : null}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 flex-col gap-1 border-b border-border px-4 pb-3 pt-[max(1rem,var(--safe-top))]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The scrolling half of the sheet. Keeping the scroll on an inner element
|
||||
* rather than on the content root is what stops a long menu from clipping on a
|
||||
* short phone; `overscroll-contain` stops the flick from chaining through to
|
||||
* the page behind the overlay.
|
||||
*/
|
||||
function SheetBody({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 flex-col gap-2 border-t border-border px-4 pb-[max(1rem,var(--safe-bottom))] pt-3',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
className={cn('text-base font-semibold text-fg', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return <SheetPrimitive.Description className={cn('text-sm text-muted', className)} {...props} />;
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetBody,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* `bg-muted` would be wrong here: in this palette `muted` is the muted TEXT
|
||||
* colour, a mid grey that reads as a filled block rather than a placeholder.
|
||||
* The placeholder surface is `surface-2`.
|
||||
*/
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn('animate-pulse rounded-md bg-surface-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as React from 'react';
|
||||
import * as SliderPrimitive from '@radix-ui/react-slider';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* One thumb per value, not one hard-coded thumb: the reward editor drives this
|
||||
* with a single weight today and a range tomorrow, and a single-thumb slider
|
||||
* fed a two-value array silently drops the second value.
|
||||
*/
|
||||
function Slider({
|
||||
className,
|
||||
value,
|
||||
defaultValue,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||
const thumbCount = (value ?? defaultValue ?? [0]).length;
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
className={cn('relative flex w-full touch-none select-none items-center py-2', className)}
|
||||
{...(value === undefined ? {} : { value })}
|
||||
{...(defaultValue === undefined ? {} : { defaultValue })}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-surface-2">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-brand" />
|
||||
</SliderPrimitive.Track>
|
||||
{Array.from({ length: thumbCount }, (_, i) => (
|
||||
<SliderPrimitive.Thumb
|
||||
key={i}
|
||||
className="block size-5 rounded-full border-2 border-brand bg-surface shadow-sm transition-colors duration-1 ease-enter disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Slider };
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from 'react';
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
className={cn(
|
||||
'inline-flex items-center justify-start gap-1 rounded-lg border border-border bg-surface-2 p-1 text-muted',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
className={cn(
|
||||
'tap inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-colors duration-1 ease-enter disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-surface data-[state=active]:text-fg data-[state=active]:shadow-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return <TabsPrimitive.Content className={cn('mt-4', className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as React from 'react';
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 6,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
// z-50 and not z-10: the tooltip has to clear the sticky header,
|
||||
// which is itself z-50 and creates a stacking context of its own.
|
||||
'z-50 overflow-hidden rounded-md border border-border bg-surface px-2.5 py-1.5 text-xs text-fg shadow-md',
|
||||
'duration-1 ease-enter animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
||||
'data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Every external claim this site makes, with the link that backs it, plus the
|
||||
* handful of numbers about our own word list.
|
||||
*
|
||||
* Home and Honesty both quote these. They are here, once, so the two pages
|
||||
* cannot drift into citing the same fact two different ways — which is the
|
||||
* usual way an honest site becomes a dishonest one.
|
||||
*
|
||||
* The word-list counts are literals rather than imports on purpose: the answer
|
||||
* list is 4,603 strings sitting outside the Vite root, and pulling it in to
|
||||
* render one number would put the whole dictionary in the entry chunk. They are
|
||||
* reproducible with the command in `wordListRebuild` and are checked by CI.
|
||||
*/
|
||||
|
||||
export interface Citation {
|
||||
/** What the source proves, in one line. */
|
||||
claim: string;
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The credential. Wordle is not our choice of demo — it is Prime Intellect's
|
||||
* own hello-world, in three separate places in their stack.
|
||||
*/
|
||||
export const helloWorldCitations: readonly Citation[] = [
|
||||
{
|
||||
claim: 'One of the five basic end-to-end examples in prime-rl, their RL trainer.',
|
||||
label: 'prime-rl / examples / basic / wordle',
|
||||
href: 'https://github.com/PrimeIntellect-ai/prime-rl/tree/main/examples/basic/wordle',
|
||||
},
|
||||
{
|
||||
claim: 'A shipped environment in verifiers, the library the whole ecosystem builds on.',
|
||||
label: 'verifiers / environments / wordle',
|
||||
href: 'https://github.com/PrimeIntellect-ai/verifiers/tree/main/environments/wordle',
|
||||
},
|
||||
{
|
||||
claim: 'The environment used in the official lab-cookbook prompt-optimisation tutorial.',
|
||||
label: 'lab-cookbook / guides / prompt optimization',
|
||||
href: 'https://github.com/PrimeIntellect-ai/lab-cookbook/tree/main/guides/04-prompt-optimization',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The one measured, citable training result on this site. It is theirs, not
|
||||
* ours, and it is a WIN RATE.
|
||||
*
|
||||
* Their README also publishes average-reward figures for the same runs. We do
|
||||
* not quote those anywhere and neither should you: the reward function has
|
||||
* changed across versions of the environment, the two numbers were produced
|
||||
* under different versions, and nobody re-measured them. A win rate survives a
|
||||
* reward change. An average reward does not.
|
||||
*/
|
||||
export const trainingResult = {
|
||||
model: 'Qwen3-1.7B',
|
||||
before: '0%',
|
||||
after: '~60%',
|
||||
metric: 'win rate',
|
||||
method: 'SFT warm-up, then multi-turn RL with group-relative advantages (GRPO)',
|
||||
/** From the same README: 20 held-out words, 3 rollouts each. */
|
||||
evalDescription: '20 held-out words the model never trained on, played 3 times each',
|
||||
source: {
|
||||
label: 'prime-rl / examples / basic / wordle',
|
||||
href: 'https://github.com/PrimeIntellect-ai/prime-rl/tree/main/examples/basic/wordle',
|
||||
},
|
||||
checkpoints: [
|
||||
{
|
||||
label: 'PrimeIntellect/Qwen3-1.7B-Wordle-SFT',
|
||||
href: 'https://huggingface.co/PrimeIntellect/Qwen3-1.7B-Wordle-SFT',
|
||||
},
|
||||
{
|
||||
label: 'PrimeIntellect/Qwen3-1.7B-Wordle-RL',
|
||||
href: 'https://huggingface.co/PrimeIntellect/Qwen3-1.7B-Wordle-RL',
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Our word list, by the numbers. Answers are the intersection of Wordnik's
|
||||
* headwords and SCOWL's common-American tier, minus a short hand-written
|
||||
* blocklist; guesses are all of Wordnik's five-letter words.
|
||||
*/
|
||||
export const wordList = {
|
||||
answers: 4603,
|
||||
guesses: 11846,
|
||||
/** Share of answers ending in a plain plural S. */
|
||||
endsInS: '32.9%',
|
||||
/** Share of answers ending in -ED. */
|
||||
endsInEd: '6.1%',
|
||||
/** Both together. */
|
||||
endsInSorEd: '39.0%',
|
||||
/** The original game's hand-curated answer list, for comparison. */
|
||||
originalAnswers: 2315,
|
||||
rebuild: 'uv run python envs/wordle_five/words/build_words.py',
|
||||
sources: [
|
||||
{ label: 'Wordnik word list (2021-07-29)', href: 'https://github.com/wordnik/wordlist' },
|
||||
{ label: 'SCOWL / wamerican (2020.12.07)', href: 'http://wordlist.aspell.net/' },
|
||||
],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The famous 3.42. It belongs to the original game's 2,315-word answer list and
|
||||
* to Alex Selby's exact solver, and it is quoted on this site only to say that
|
||||
* it is not ours.
|
||||
*/
|
||||
export const optimalPlay = {
|
||||
average: '3.42 guesses',
|
||||
label: 'Selby’s exact optimal solution for the original 2,315-word list',
|
||||
href: 'https://sonorouschocolate.com/notes/index.php/The_best_strategies_for_Wordle',
|
||||
} as const;
|
||||
|
||||
/** Commands anyone can run against a clone of this repository. */
|
||||
export const reproduce = {
|
||||
clone: 'git clone https://github.com/karti-ai/PIG-Demo',
|
||||
install: 'cd PIG-Demo && uv sync --all-packages',
|
||||
evaluate: 'uv run vf-eval wordle-five -n 8',
|
||||
conformance: 'pnpm conformance',
|
||||
} as const;
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Icon names to icon components, for the names that live in data.
|
||||
*
|
||||
* `DemoMeta.icon` and `VerticalEntry.icon` are strings, so the data stays
|
||||
* serialisable and one demo's icon does not drag lucide into every chunk. That
|
||||
* only holds if the resolver imports icons BY NAME, as below. Do not replace
|
||||
* this with `import * as lucide` and a lookup — it compiles, it renders, and it
|
||||
* quietly ships all fourteen hundred icons.
|
||||
*
|
||||
* Adding a demo or a vertical with an icon that is not in this map is not an
|
||||
* error: it falls back to `Blocks`. Add the name here when you notice.
|
||||
*/
|
||||
|
||||
import {
|
||||
Blocks,
|
||||
Braces,
|
||||
Cpu,
|
||||
Grid3x3,
|
||||
Headset,
|
||||
Puzzle,
|
||||
RadioTower,
|
||||
Scale,
|
||||
ShieldCheck,
|
||||
Siren,
|
||||
Stethoscope,
|
||||
Table2,
|
||||
Tag,
|
||||
Truck,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
const REGISTRY: Record<string, LucideIcon> = {
|
||||
Blocks,
|
||||
Braces,
|
||||
Cpu,
|
||||
Grid3x3,
|
||||
Headset,
|
||||
Puzzle,
|
||||
RadioTower,
|
||||
Scale,
|
||||
ShieldCheck,
|
||||
Siren,
|
||||
Stethoscope,
|
||||
Table2,
|
||||
Tag,
|
||||
Truck,
|
||||
Zap,
|
||||
};
|
||||
|
||||
export function iconFor(name: string): LucideIcon {
|
||||
return REGISTRY[name] ?? Blocks;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* The join between the demo registry and the vertical lineup.
|
||||
*
|
||||
* This is the ONLY place the marketing pages touch the registry. Home, Gallery
|
||||
* and Vertical all read demos through here, so when the registry's export name
|
||||
* or path moves, it moves in one line instead of in four pages.
|
||||
*/
|
||||
|
||||
import { demos } from '@/lib/demo-kit';
|
||||
import type { DemoMeta, Vertical } from '@/lib/demo-kit/types';
|
||||
import { VERTICALS, verticalByKey } from '@/content/verticals';
|
||||
import type { VerticalEntry } from '@/content/verticals';
|
||||
|
||||
/**
|
||||
* Route shapes, written down once. The router owns the actual `<Route>`
|
||||
* elements; these are what every link on the marketing side builds, so if the
|
||||
* two ever disagree, they disagree here and not in twenty JSX attributes.
|
||||
*/
|
||||
export const routes = {
|
||||
home: '/',
|
||||
gallery: '/gallery',
|
||||
honesty: '/honesty',
|
||||
demo: (slug: string) => `/demos/${slug}`,
|
||||
vertical: (slug: string) => `/verticals/${slug}`,
|
||||
/** Gallery pre-filtered to one vertical. Deep-linkable on purpose. */
|
||||
galleryFiltered: (key: Vertical) => `/gallery?vertical=${key}`,
|
||||
} as const;
|
||||
|
||||
/** The public repository. Every claim on the site is meant to end up here. */
|
||||
export const REPO_URL = 'https://github.com/karti-ai/PIG-Demo';
|
||||
|
||||
/** Registry order is authorial; this is the order every list renders in. */
|
||||
export const allDemos: readonly DemoMeta[] = [...demos].sort(
|
||||
(a, b) => a.order - b.order || a.slug.localeCompare(b.slug),
|
||||
);
|
||||
|
||||
export const liveDemos: readonly DemoMeta[] = allDemos.filter((d) => d.status === 'live');
|
||||
|
||||
/**
|
||||
* The demo the site leads with. `reference` is the hello-world vertical, and
|
||||
* the first live one in it is the front door; if there is none yet, any live
|
||||
* demo will do, and only then do we fall back to whatever the registry has.
|
||||
* Written as a function of the registry so adding a demo never edits Home.
|
||||
*/
|
||||
export const featuredDemo: DemoMeta | undefined =
|
||||
liveDemos.find((d) => d.vertical === 'reference') ?? liveDemos[0] ?? allDemos[0];
|
||||
|
||||
/** Every vertical key that at least one demo is filed under. Filter source. */
|
||||
export const verticalKeysInUse: readonly Vertical[] = Array.from(
|
||||
new Set(allDemos.map((d) => d.vertical)),
|
||||
);
|
||||
|
||||
export function demosForVertical(key: Vertical | null): readonly DemoMeta[] {
|
||||
if (!key) return [];
|
||||
return allDemos.filter((d) => d.vertical === key);
|
||||
}
|
||||
|
||||
export function demoBySlug(slug: string | undefined): DemoMeta | undefined {
|
||||
if (!slug) return undefined;
|
||||
return allDemos.find((d) => d.slug === slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* A demo's vertical, for a label on a card. `reference` deliberately resolves
|
||||
* to nothing — the word game is not an industry, and labelling it as one would
|
||||
* be the first small lie on a site whose whole argument is that it doesn't
|
||||
* tell them.
|
||||
*/
|
||||
export function verticalForDemo(demo: DemoMeta): VerticalEntry | undefined {
|
||||
return verticalByKey(demo.vertical);
|
||||
}
|
||||
|
||||
/** The lineup, in the order we would build it. */
|
||||
export const lineup: readonly VerticalEntry[] = [...VERTICALS].sort((a, b) => a.rank - b.rank);
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* The class strings the marketing pages share.
|
||||
*
|
||||
* These pages own no components — the shared UI primitives belong to another
|
||||
* part of the tree — so the alternative to this file was the same forty
|
||||
* characters of Tailwind copied into five pages and drifting apart. Strings,
|
||||
* not components, so nothing here reaches for React.
|
||||
*
|
||||
* Horizontal padding is written against the safe-area tokens rather than a
|
||||
* flat value: on a notched phone in landscape, a flat `px-5` puts the first
|
||||
* character of every heading under the rounded corner.
|
||||
*/
|
||||
|
||||
/** Page-width container with safe-area-aware gutters. */
|
||||
export const shell =
|
||||
'mx-auto w-full max-w-canvas pl-[max(1.25rem,var(--safe-left))] pr-[max(1.25rem,var(--safe-right))] sm:pl-[max(2rem,var(--safe-left))] sm:pr-[max(2rem,var(--safe-right))]';
|
||||
|
||||
/** Vertical rhythm between top-level sections. */
|
||||
export const section = 'py-12 sm:py-16';
|
||||
|
||||
/** Small uppercase label above a section heading. */
|
||||
export const eyebrow = 'text-xs font-semibold uppercase tracking-[0.14em] text-muted';
|
||||
|
||||
export const h1 =
|
||||
'text-[2rem] leading-[1.08] font-extrabold tracking-tight text-fg sm:text-5xl lg:text-6xl';
|
||||
|
||||
export const h2 = 'text-2xl font-bold tracking-tight text-fg sm:text-3xl';
|
||||
|
||||
export const h3 = 'text-base font-semibold text-fg';
|
||||
|
||||
export const lede = 'text-lg leading-relaxed text-muted sm:text-xl';
|
||||
|
||||
export const prose = 'text-[0.9375rem] leading-relaxed text-muted';
|
||||
|
||||
/** Filled call to action. 44px tall via `.tap`. */
|
||||
export const btnPrimary =
|
||||
'tap inline-flex items-center justify-center gap-2 rounded-lg bg-brand px-5 py-2.5 text-sm font-semibold text-accent-on transition-colors duration-2 ease-enter hover:bg-accent-fg';
|
||||
|
||||
/** Outlined call to action, for the second-choice action beside a primary. */
|
||||
export const btnSecondary =
|
||||
'tap inline-flex items-center justify-center gap-2 rounded-lg border border-border bg-surface px-5 py-2.5 text-sm font-semibold text-fg transition-colors duration-2 ease-enter hover:bg-surface-2';
|
||||
|
||||
/** Inline link inside running text. Underlined, because colour alone is not a link. */
|
||||
export const link =
|
||||
'font-medium text-accent-fg underline decoration-accent-fg/40 underline-offset-4 transition-colors duration-1 ease-enter hover:decoration-accent-fg';
|
||||
|
||||
/** Small pill. Neutral by default; pass a colour class after it. */
|
||||
export const pill =
|
||||
'inline-flex items-center gap-1.5 rounded-md border border-border bg-surface-2 px-2 py-1 text-xs font-medium text-muted';
|
||||
|
||||
/** The standing "these are our proposals" badge. */
|
||||
export const proposalPill =
|
||||
'inline-flex items-center gap-1.5 rounded-md border border-border bg-surface-2 px-2 py-1 text-[0.6875rem] font-semibold uppercase tracking-wider text-muted';
|
||||
|
||||
/** A card that is also a link: the whole rectangle is the target. */
|
||||
export const cardLink =
|
||||
'card group flex flex-col p-5 transition-colors duration-2 ease-enter hover:border-brand/40 hover:bg-surface-2';
|
||||
|
||||
/** Monospaced command block. Scrolls itself rather than the page. */
|
||||
export const codeBlock =
|
||||
'nums overflow-x-auto rounded-lg border border-border bg-surface-2 p-3 font-mono text-[0.8125rem] leading-relaxed text-fg';
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* The twelve verticals, as data.
|
||||
*
|
||||
* These are OUR proposals. Nothing here is Prime Intellect's roadmap, nothing
|
||||
* here describes a customer, and no company is named anywhere in this file.
|
||||
* `PROPOSAL_NOTICE` is rendered on every surface that shows a vertical, and it
|
||||
* is a constant rather than page copy so it cannot be dropped from one page and
|
||||
* kept on another.
|
||||
*
|
||||
* Each entry is an argument in four parts: the task an environment would run,
|
||||
* the reward in the buyer's own KPI, the counterweight that stops that reward
|
||||
* being farmed the crude way, and — where it applies — the caveat that says
|
||||
* where the argument stops being honest. An entry without a counterweight is
|
||||
* not a vertical, it is a slide.
|
||||
*/
|
||||
|
||||
import type { Vertical } from '@/lib/demo-kit/types';
|
||||
|
||||
/** Rendered verbatim wherever a vertical appears. Never edit per page. */
|
||||
export const PROPOSAL_NOTICE = 'Proposed by PIG-Demo';
|
||||
|
||||
export interface VerticalEntry {
|
||||
/** URL segment: `/verticals/<slug>`. */
|
||||
slug: string;
|
||||
title: string;
|
||||
/**
|
||||
* The registry's `Vertical` key, used to join a vertical to any demo built
|
||||
* for it. `null` where the contract has no key — see the note on
|
||||
* `semiconductor` at the bottom of this file.
|
||||
*/
|
||||
key: Vertical | null;
|
||||
/** A lucide-react icon NAME. Resolved by the page, never imported here. */
|
||||
icon: string;
|
||||
/** Priority order across the whole lineup. 1 is the one we would build next. */
|
||||
rank: number;
|
||||
/** The job title that owns the budget for this. */
|
||||
persona: string;
|
||||
/** The question already in their head when they land. Written as they'd say it. */
|
||||
anxiety: string;
|
||||
/** The concrete unit of work one episode of the environment would cover. */
|
||||
task: string;
|
||||
/** What the reward pays for, stated in their KPI and not in ML vocabulary. */
|
||||
reward: string;
|
||||
/** What stops the reward being maximised the crude way. In genuine tension. */
|
||||
counterweight: string;
|
||||
/** Whether this is in the set we intend to build after the reference demo. */
|
||||
plannedForV1: boolean;
|
||||
/** Where the argument stops. Rendered as a standing warning, not a footnote. */
|
||||
caveat?: string;
|
||||
}
|
||||
|
||||
export const VERTICALS: readonly VerticalEntry[] = [
|
||||
{
|
||||
slug: 'customer-support-resolution',
|
||||
title: 'Customer Support Resolution',
|
||||
key: 'support',
|
||||
icon: 'Headset',
|
||||
rank: 1,
|
||||
persona: 'VP of Customer Support',
|
||||
anxiety:
|
||||
'Deflection went up and CSAT went down in the same quarter. Nobody can tell me which of those the assistant caused.',
|
||||
task: 'Work one inbound ticket against a frozen snapshot of the help centre, the order record and the refund policy. Resolve it, or hand it to a human with the reason attached.',
|
||||
reward:
|
||||
'Pays for a first-contact resolution the customer does not reopen within seven days. One number, the one already on the support scorecard.',
|
||||
counterweight:
|
||||
'Refunding everything closes every ticket. So the reward subtracts for any resolution that granted more than the policy allowed, and for a handoff written to look like an answer. Escalating honestly outscores a generous close.',
|
||||
plannedForV1: true,
|
||||
},
|
||||
{
|
||||
slug: 'healthcare-denial-appeal',
|
||||
title: 'Healthcare RCM Denial Appeal',
|
||||
key: 'healthcare',
|
||||
icon: 'Stethoscope',
|
||||
rank: 2,
|
||||
persona: 'Revenue Cycle Director',
|
||||
anxiety:
|
||||
'We appeal a fraction of our denials because we cannot staff the rest. I do not know what that fraction costs us.',
|
||||
task: 'Given the denial code, the payer’s published medical policy and the chart excerpt, draft the appeal and cite the specific policy paragraph that supports it.',
|
||||
reward:
|
||||
'Pays overturned dollars per appeal, scored against the payer’s adjudicated outcome on the same claim.',
|
||||
counterweight:
|
||||
'Every cited policy line must appear verbatim in the attached policy, and every clinical fact must appear in the chart. One invented citation zeroes the appeal no matter how well the letter reads. A persuasive fabrication is the failure mode here, so the grader checks the sources before it reads the argument.',
|
||||
plannedForV1: true,
|
||||
},
|
||||
{
|
||||
slug: 'insurance-coverage-reserve',
|
||||
title: 'Insurance Coverage & Reserve',
|
||||
key: 'insurance',
|
||||
icon: 'ShieldCheck',
|
||||
rank: 3,
|
||||
persona: 'Chief Claims Officer',
|
||||
anxiety:
|
||||
'Adjusters set the initial reserve by feel. My development triangle is a monthly report on how expensive that feel is.',
|
||||
task: 'Read the first notice of loss, the policy form and the endorsements. Decide covered or not covered, name the clause that decides it, and set the initial reserve.',
|
||||
reward:
|
||||
'Pays on reserve accuracy: the gap between the number set on day one and the cost the claim actually closed at. The coverage call has to match the closed file.',
|
||||
counterweight:
|
||||
'Reserving high is accurate and expensive, so tied-up capital is charged against the score. Denying to protect the number is charged at the rate those denials were later overturned. The two pull in opposite directions on purpose.',
|
||||
plannedForV1: true,
|
||||
},
|
||||
{
|
||||
slug: 'financial-crime-alert-triage',
|
||||
title: 'Financial-Crime Alert Triage',
|
||||
key: 'financial-crime',
|
||||
icon: 'Siren',
|
||||
rank: 4,
|
||||
persona: 'BSA / AML Officer',
|
||||
anxiety:
|
||||
'Almost every alert my team reads is a false positive. The handful that are not is the entire conversation with my regulator.',
|
||||
task: 'Triage one transaction-monitoring alert against the customer’s KYC file and twelve months of account history. Close it, or escalate it for a suspicious-activity filing with a written narrative.',
|
||||
reward:
|
||||
'Pays for closing false positives, in analyst hours per thousand alerts. That is the number the operating budget is built on.',
|
||||
counterweight:
|
||||
'A missed escalation on an alert that later became a filed report costs more than every hour saved that month. The asymmetry lives in the reward weights, where you can read it and argue with it, instead of in a policy memo.',
|
||||
plannedForV1: true,
|
||||
},
|
||||
{
|
||||
slug: 'energy-day-ahead-bid',
|
||||
title: 'Energy Day-Ahead Bid',
|
||||
key: 'energy',
|
||||
icon: 'Zap',
|
||||
rank: 5,
|
||||
persona: 'Head of Power Trading',
|
||||
anxiety:
|
||||
'A model that backtests beautifully and then blows out a real-time position is worse than no model at all.',
|
||||
task: 'Submit a day-ahead bid curve for one asset across twenty-four hours, given the load forecast, the outage schedule and the historical basis.',
|
||||
reward:
|
||||
'Pays settled day-ahead revenue net of real-time, in dollars, at the clearing prices the market operator actually published for that day.',
|
||||
counterweight:
|
||||
'Imbalance charges and ramp limits settle against the same score, and any bid the market operator would have rejected settles at zero. A schedule the plant cannot physically deliver loses money in the grader exactly as it would on the desk.',
|
||||
plannedForV1: true,
|
||||
},
|
||||
{
|
||||
slug: 'logistics-load-and-reroute',
|
||||
title: 'Logistics Load & Reroute',
|
||||
key: 'logistics',
|
||||
icon: 'Truck',
|
||||
rank: 6,
|
||||
persona: 'VP of Transportation',
|
||||
anxiety:
|
||||
'Cost per load and on-time delivery move in opposite directions, and my planners choose between them every hour without writing down why.',
|
||||
task: 'Build the day’s load plan from the order book, then reroute it live when a driver runs out of hours and a dock appointment slips.',
|
||||
reward:
|
||||
'Pays landed cost per load and on-time-in-full against the receiver’s appointment window. Both, together, because either one alone is trivially gamed.',
|
||||
counterweight:
|
||||
'Hours-of-service, weight and appointment windows are hard constraints. A cheaper plan that puts a driver over their clock is not a cheaper plan; that leg scores zero and the saving disappears with it.',
|
||||
plannedForV1: true,
|
||||
},
|
||||
{
|
||||
slug: 'code-fix-the-test',
|
||||
title: 'Code Fix-the-Test',
|
||||
key: 'code',
|
||||
icon: 'Braces',
|
||||
rank: 7,
|
||||
persona: 'VP of Engineering',
|
||||
anxiety:
|
||||
'Every vendor shows me a pass rate on a public benchmark my team has never run on code my team has never seen.',
|
||||
task: 'Given a repository at a specific commit and one failing test, make that test pass.',
|
||||
reward:
|
||||
'The test suite is the grader. It pays 1 when the target test passes and everything that passed before still passes.',
|
||||
counterweight:
|
||||
'Editing the test, weakening its assertion or marking it skipped is caught by diffing the test files, and scores zero. This is the vertical where the grader argues back the least, which is exactly why it is the cheapest one to trust.',
|
||||
plannedForV1: true,
|
||||
},
|
||||
{
|
||||
slug: 'retail-markdown-cadence',
|
||||
title: 'Retail Markdown Cadence',
|
||||
key: 'retail',
|
||||
icon: 'Tag',
|
||||
rank: 8,
|
||||
persona: 'Chief Merchant',
|
||||
anxiety:
|
||||
'We run the same markdown ladder every season because relitigating it costs more than the margin it would save.',
|
||||
task: 'Set the weekly markdown for one style-colour across a season, given sell-through to date, units on hand and the weeks remaining.',
|
||||
reward:
|
||||
'Pays gross margin dollars at season end, computed on the sell-through curve that actually happened.',
|
||||
counterweight:
|
||||
'Whatever is left at the end is charged at its disposal cost, and the model cannot see the weeks it is pricing into. Holding price to protect margin ends the season owning the goods, and the score says so.',
|
||||
plannedForV1: false,
|
||||
},
|
||||
{
|
||||
slug: 'telecom-alarm-root-cause',
|
||||
title: 'Telecom Alarm → Root-Cause',
|
||||
key: 'telecom',
|
||||
icon: 'RadioTower',
|
||||
rank: 9,
|
||||
persona: 'SVP Network Operations',
|
||||
anxiety:
|
||||
'One fibre cut lights up thousands of alarms. My operations centre spends the first half of the outage deciding which one to read.',
|
||||
task: 'Correlate an alarm storm against the network topology and the change log, and name the single failing element.',
|
||||
reward:
|
||||
'Pays on time-to-identify, measured against the root cause the post-incident review recorded.',
|
||||
counterweight:
|
||||
'A confident wrong element costs the truck roll it triggers. Answering “insufficient evidence, here are the two candidates” scores higher than a fast wrong answer, which is the opposite of what a plain accuracy metric would teach.',
|
||||
plannedForV1: false,
|
||||
},
|
||||
{
|
||||
slug: 'data-column-split',
|
||||
title: 'Data Column Split',
|
||||
key: 'data',
|
||||
icon: 'Table2',
|
||||
rank: 10,
|
||||
persona: 'Chief Data Officer',
|
||||
anxiety:
|
||||
'A large part of my analytics backlog is a person reshaping a spreadsheet by hand and calling it a project.',
|
||||
task: 'Given one column of messy real values and a handful of worked examples, produce the transformation that splits or normalises the whole column.',
|
||||
reward:
|
||||
'Pays exact match on held-out rows the model never saw while it was writing the rule.',
|
||||
counterweight:
|
||||
'The rule is applied to those rows, not fitted to them, and a rule that special-cases individual values is penalised on length. Memorising the examples scores zero on the rows that pay.',
|
||||
plannedForV1: false,
|
||||
},
|
||||
{
|
||||
slug: 'legal-playbook-redline',
|
||||
title: 'Legal Playbook Redline',
|
||||
key: 'legal',
|
||||
icon: 'Scale',
|
||||
rank: 11,
|
||||
persona: 'General Counsel',
|
||||
anxiety:
|
||||
'First-pass review of a mutual NDA is not legal work, and it is still what my team does on a Thursday night.',
|
||||
task: 'Redline a counterparty contract against our own negotiation playbook and route each deviation to accept, negotiate, or escalate.',
|
||||
reward:
|
||||
'Pays for finding every clause the playbook flags and putting it in the right one of the three buckets. That is checkable against the playbook itself.',
|
||||
counterweight:
|
||||
'Escalating everything finds every clause and reviews nothing, so the escalation bucket has a budget and overspending it is penalised.',
|
||||
plannedForV1: false,
|
||||
caveat:
|
||||
'Where this stops being honest: finding the clause is checkable, but whether the replacement language is an acceptable redline is judgment, and grading judgment collapses to an LLM judge — the exact thing a verifiable reward is meant to replace. We would ship the detection half with a real verifier and say plainly that the drafting half is unverified. We would not put a judge behind a bar chart and call it a score.',
|
||||
},
|
||||
{
|
||||
slug: 'semiconductor-ppa-closure',
|
||||
title: 'Semiconductor PPA Closure',
|
||||
/*
|
||||
* No `Vertical` key exists for this one, and that is deliberate rather than
|
||||
* an oversight: it is on the page as the strongest form of the argument,
|
||||
* not as something we intend to build, so it is joined to no demo and never
|
||||
* appears as a gallery filter. See the contract note in the lane report.
|
||||
*/
|
||||
key: null,
|
||||
icon: 'Cpu',
|
||||
rank: 12,
|
||||
persona: 'VP of Silicon Engineering',
|
||||
anxiety:
|
||||
'Timing closure is six weeks of a senior engineer’s life per tape-out, and we do it again next tape-out.',
|
||||
task: 'Adjust synthesis and place-and-route constraints on one block until it closes timing at the target frequency.',
|
||||
reward:
|
||||
'The signoff report is the reward: worst negative slack, total negative slack, area, leakage power. Numbers the tool prints. No rubric, no judge, no human in the scoring loop.',
|
||||
counterweight:
|
||||
'Hitting frequency by spending area or power is priced into the same objective, and a run that fails design-rule checks scores nothing however good its timing looks.',
|
||||
plannedForV1: false,
|
||||
caveat:
|
||||
'The most rigorous reward on this page and the worst demo on it. Signoff needs licensed EDA tools and hours of compute for a single rollout, and none of that fits in a browser tab. We are listing it because it is where the argument is strongest, and we are telling you we are not building it.',
|
||||
},
|
||||
] as const;
|
||||
|
||||
/** Lookup by URL segment. Returns undefined for an unknown slug. */
|
||||
export function verticalBySlug(slug: string | undefined): VerticalEntry | undefined {
|
||||
if (!slug) return undefined;
|
||||
return VERTICALS.find((v) => v.slug === slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup by the registry's `Vertical` key, so a demo can find its vertical.
|
||||
* `reference` intentionally matches nothing: the hello-world demo belongs to
|
||||
* no industry.
|
||||
*/
|
||||
export function verticalByKey(key: Vertical | undefined): VerticalEntry | undefined {
|
||||
if (!key) return undefined;
|
||||
return VERTICALS.find((v) => v.key === key);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Identity functions with types attached.
|
||||
*
|
||||
* They exist for two reasons that a plain object literal does not give you:
|
||||
* inference (a demo writes `defineDemo<WordleState>({...})` once and every
|
||||
* callback inside is typed), and a stable grep target — `defineDemo(` finds
|
||||
* every demo in the repo, which is what `scripts/check-demos.mjs` and any
|
||||
* future codemod key off. Do not "simplify" these away.
|
||||
*/
|
||||
|
||||
import type { DemoMeta, DemoModule } from './types';
|
||||
|
||||
/** Wrap the object exported from a demo's `meta.ts`. */
|
||||
export function defineMeta(meta: DemoMeta): DemoMeta {
|
||||
return meta;
|
||||
}
|
||||
|
||||
/** Wrap the object exported from a demo's `demo.tsx`. */
|
||||
export function defineDemo<TState>(demo: DemoModule<TState>): DemoModule<TState> {
|
||||
return demo;
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Loading and scoring recorded runs.
|
||||
*
|
||||
* The one rule this module exists to enforce: **`null` is "not scored", and it
|
||||
* is never 0.0.** A run that failed to grade, a component the environment did
|
||||
* not emit, a truncated trace — all of those are absences. Rendering an absence
|
||||
* as a zero turns a missing measurement into a claim about the model, and this
|
||||
* whole site is an argument that the numbers are real.
|
||||
*/
|
||||
|
||||
import type { DemoEpisode, RewardComponent, RewardValues, RunRef } from './types';
|
||||
|
||||
/**
|
||||
* True when a value must not be summed, averaged or drawn as a bar.
|
||||
*
|
||||
* The signature narrows the FALSE branch to `number`, which is the point — the
|
||||
* caller gets a real number without a second check. It is a small lie for
|
||||
* exactly one input: `NaN` is a `number` but returns `true` here, because a
|
||||
* corrupted fixture must be treated as unscored rather than poison every
|
||||
* downstream sum. Callers only ever reach the narrowed branch when the value is
|
||||
* finite, so the lie is unobservable.
|
||||
*/
|
||||
export function isNotScored(value: number | null | undefined): value is null | undefined {
|
||||
return value === null || value === undefined || !Number.isFinite(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Total reward: sum of `score x weight` over the components that were scored.
|
||||
*
|
||||
* Unscored components are SKIPPED, not zeroed, and the weights are deliberately
|
||||
* NOT renormalised over the survivors — renormalising would quietly invent a
|
||||
* different reward function than the one the environment shipped. If nothing
|
||||
* was scored at all, the answer is `null`, not `0`.
|
||||
*/
|
||||
export function rewardTotal(
|
||||
values: RewardValues,
|
||||
components: readonly RewardComponent[],
|
||||
): number | null {
|
||||
let total = 0;
|
||||
let scored = 0;
|
||||
|
||||
for (const component of components) {
|
||||
const raw = values[component.key];
|
||||
if (isNotScored(raw)) continue;
|
||||
total += raw * component.weight;
|
||||
scored += 1;
|
||||
}
|
||||
|
||||
return scored === 0 ? null : total;
|
||||
}
|
||||
|
||||
/** How many of `components` the episode actually carries a number for. */
|
||||
export function scoredCount(
|
||||
values: RewardValues,
|
||||
components: readonly RewardComponent[],
|
||||
): number {
|
||||
return components.filter((c) => !isNotScored(values[c.key])).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-flight and settled fetches, keyed by path.
|
||||
*
|
||||
* The promise is cached, not the value, so two panels mounting in the same tick
|
||||
* share one request. A rejected promise is evicted, so a failed load can be
|
||||
* retried by simply calling again — a cached rejection would make one flaky
|
||||
* network moment permanent for the life of the tab.
|
||||
*/
|
||||
const episodeCache = new Map<string, Promise<DemoEpisode>>();
|
||||
|
||||
/** Fetch and cache one recorded run. */
|
||||
export function loadEpisode(runRef: RunRef): Promise<DemoEpisode> {
|
||||
const cached = episodeCache.get(runRef.path);
|
||||
if (cached) return cached;
|
||||
|
||||
const pending = fetch(runRef.path, { headers: { accept: 'application/json' } })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Could not load run "${runRef.id}" (${response.status} from ${runRef.path})`);
|
||||
}
|
||||
return assertEpisode(await response.json(), runRef);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
episodeCache.delete(runRef.path);
|
||||
throw error;
|
||||
});
|
||||
|
||||
episodeCache.set(runRef.path, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Drop a cached run. Only useful in tests and the dev-time fixture watcher. */
|
||||
export function clearEpisodeCache(path?: string): void {
|
||||
if (path === undefined) episodeCache.clear();
|
||||
else episodeCache.delete(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural check on a fixture.
|
||||
*
|
||||
* Loud and early beats a board that renders half a run. Everything checked here
|
||||
* is something the surfaces read without a guard.
|
||||
*/
|
||||
function assertEpisode(raw: unknown, runRef: RunRef): DemoEpisode {
|
||||
if (raw === null || typeof raw !== 'object') {
|
||||
throw new Error(`Run "${runRef.id}" is not a JSON object.`);
|
||||
}
|
||||
const episode = raw as Partial<DemoEpisode>;
|
||||
|
||||
if (!Array.isArray(episode.turns)) {
|
||||
throw new Error(`Run "${runRef.id}" has no \`turns\` array.`);
|
||||
}
|
||||
if (episode.rewards === null || typeof episode.rewards !== 'object') {
|
||||
throw new Error(`Run "${runRef.id}" has no \`rewards\` object.`);
|
||||
}
|
||||
if (typeof episode.outcome !== 'string') {
|
||||
throw new Error(`Run "${runRef.id}" has no \`outcome\`.`);
|
||||
}
|
||||
|
||||
return episode as DemoEpisode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The run manifest, `public/traces/manifest.json`.
|
||||
*
|
||||
* `RunRef` says runs are "listed in public/traces/manifest.json", but nothing in
|
||||
* the contract hands the shell a `RunRef[]` — `DemoModule` has no `runs` field.
|
||||
* So the manifest is the only source, and this is its reader. Three shapes are
|
||||
* accepted because the generator and the shell are written in different places
|
||||
* and a mismatch here would be a blank page rather than a type error:
|
||||
*
|
||||
* { "demos": { "wordle-five": [RunRef, ...] } }
|
||||
* { "runs": [ { ...RunRef, "demo": "wordle-five" }, ... ] }
|
||||
* [ { ...RunRef, "demo": "wordle-five" }, ... ]
|
||||
*/
|
||||
export const MANIFEST_PATH = '/traces/manifest.json';
|
||||
|
||||
export type RunManifest = Record<string, RunRef[]>;
|
||||
|
||||
let manifestPromise: Promise<RunManifest> | null = null;
|
||||
|
||||
export function loadManifest(path: string = MANIFEST_PATH): Promise<RunManifest> {
|
||||
if (manifestPromise) return manifestPromise;
|
||||
|
||||
manifestPromise = fetch(path, { headers: { accept: 'application/json' } })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Could not load the run manifest (${response.status} from ${path}).`);
|
||||
}
|
||||
return normaliseManifest(await response.json());
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
manifestPromise = null;
|
||||
throw error;
|
||||
});
|
||||
|
||||
return manifestPromise;
|
||||
}
|
||||
|
||||
/** The runs recorded for one demo, in manifest order. Empty when there are none. */
|
||||
export async function listRuns(slug: string): Promise<RunRef[]> {
|
||||
const manifest = await loadManifest();
|
||||
return manifest[slug] ?? [];
|
||||
}
|
||||
|
||||
function normaliseManifest(raw: unknown): RunManifest {
|
||||
const out: RunManifest = {};
|
||||
|
||||
const push = (slug: string, run: RunRef): void => {
|
||||
const bucket = out[slug];
|
||||
if (bucket) bucket.push(run);
|
||||
else out[slug] = [run];
|
||||
};
|
||||
|
||||
const flat = (entries: unknown[]): void => {
|
||||
for (const entry of entries) {
|
||||
if (entry === null || typeof entry !== 'object') continue;
|
||||
const record = entry as RunRef & { demo?: string; slug?: string };
|
||||
const slug = record.demo ?? record.slug;
|
||||
if (typeof slug !== 'string') continue;
|
||||
push(slug, record);
|
||||
}
|
||||
};
|
||||
|
||||
if (Array.isArray(raw)) {
|
||||
flat(raw);
|
||||
return out;
|
||||
}
|
||||
if (raw === null || typeof raw !== 'object') return out;
|
||||
|
||||
const object = raw as { demos?: unknown; runs?: unknown };
|
||||
if (Array.isArray(object.runs)) flat(object.runs);
|
||||
|
||||
const demos = object.demos;
|
||||
if (demos !== null && typeof demos === 'object') {
|
||||
for (const [slug, runs] of Object.entries(demos as Record<string, unknown>)) {
|
||||
if (Array.isArray(runs)) {
|
||||
for (const run of runs) {
|
||||
if (run !== null && typeof run === 'object') push(slug, run as RunRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* The demo registry: discovery by existence.
|
||||
*
|
||||
* There is no list of demos anywhere in this repo. A demo exists because
|
||||
* `src/demos/<slug>/meta.ts` and `src/demos/<slug>/demo.tsx` exist. Adding one
|
||||
* is `mkdir` plus two files; the header, the gallery, the vertical pages and
|
||||
* the router all pick it up with no edit to shared code. That property is the
|
||||
* whole reason this file is a glob and not an array, so resist the urge to
|
||||
* "just add an import" for the one awkward demo.
|
||||
*
|
||||
* Two globs, deliberately different:
|
||||
* - `meta.ts` is EAGER. Every page needs every meta (the header lists them),
|
||||
* it is plain serialisable data, and the contract forbids React or icon
|
||||
* components in it precisely so this eager glob stays cheap.
|
||||
* - `demo.tsx` is LAZY. It is the expensive half — components, adapters,
|
||||
* the reward source imported with `?raw` — and only one of them is ever
|
||||
* needed at a time.
|
||||
*
|
||||
* A demo whose meta is malformed is QUARANTINED: dropped from the registry with
|
||||
* a console error. One bad demo must never be able to white-page the site.
|
||||
*/
|
||||
|
||||
import type { DemoMeta, DemoModule, Vertical } from './types';
|
||||
|
||||
/**
|
||||
* The shell is generic over each demo's board type and never inspects it, but
|
||||
* the registry has to hand back demos of *different* board types from one
|
||||
* function. `DemoModule<unknown>` does not work: `Surface` takes `{ state: T }`
|
||||
* in a contravariant position, so `DemoModule<WordleState>` is not assignable
|
||||
* to `DemoModule<unknown>` and the shell could not pass a state back in either.
|
||||
* `any` is the one thing that is assignable in both directions here. Each demo
|
||||
* is still fully checked against `DemoModule<TState>` at its own `defineDemo()`
|
||||
* call site, which is where the type actually protects anyone.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type AnyDemoModule = DemoModule<any>;
|
||||
|
||||
/** Every vertical in the contract, in the order the site presents them. */
|
||||
export const VERTICAL_ORDER: readonly Vertical[] = [
|
||||
'reference',
|
||||
'support',
|
||||
'healthcare',
|
||||
'insurance',
|
||||
'financial-crime',
|
||||
'energy',
|
||||
'logistics',
|
||||
'code',
|
||||
'retail',
|
||||
'telecom',
|
||||
'data',
|
||||
'legal',
|
||||
];
|
||||
|
||||
/** Exec-facing names. The union member is a slug; this is what a person reads. */
|
||||
export const VERTICAL_LABELS: Readonly<Record<Vertical, string>> = {
|
||||
reference: 'Reference',
|
||||
support: 'Customer support',
|
||||
healthcare: 'Healthcare',
|
||||
insurance: 'Insurance',
|
||||
'financial-crime': 'Financial crime',
|
||||
energy: 'Energy',
|
||||
logistics: 'Logistics',
|
||||
code: 'Software',
|
||||
retail: 'Retail',
|
||||
telecom: 'Telecom',
|
||||
data: 'Data',
|
||||
legal: 'Legal',
|
||||
};
|
||||
|
||||
const VERTICAL_SET = new Set<string>(VERTICAL_ORDER);
|
||||
|
||||
export interface VerticalGroup {
|
||||
vertical: Vertical;
|
||||
label: string;
|
||||
demos: DemoMeta[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Demos may export their meta as `meta` or as the default. Both are accepted
|
||||
* because the alternative is a build that compiles and a site that is empty.
|
||||
*/
|
||||
interface MetaModuleShape {
|
||||
readonly meta?: unknown;
|
||||
readonly default?: unknown;
|
||||
}
|
||||
|
||||
interface DemoModuleShape {
|
||||
readonly demo?: unknown;
|
||||
readonly default?: unknown;
|
||||
}
|
||||
|
||||
const metaModules = import.meta.glob<MetaModuleShape>('../../demos/*/meta.ts', {
|
||||
eager: true,
|
||||
});
|
||||
|
||||
const demoLoaders = import.meta.glob<DemoModuleShape>('../../demos/*/demo.tsx');
|
||||
|
||||
/** `../../demos/wordle-five/meta.ts` -> `wordle-five` */
|
||||
function slugFromPath(path: string): string | null {
|
||||
const match = /\/demos\/([^/]+)\/[^/]+$/.exec(path);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the reason this meta is unusable, or `null` if it is fine.
|
||||
*
|
||||
* Everything checked here is read without a guard by some surface. `slug` is
|
||||
* checked against the directory name rather than merely being present, because
|
||||
* a slug that disagrees with its directory produces a card that links to a
|
||||
* route that 404s — the single most confusing failure this registry can have.
|
||||
*/
|
||||
function validationError(candidate: unknown, dirName: string): string | null {
|
||||
if (candidate === null || typeof candidate !== 'object') {
|
||||
return 'meta.ts must export a `meta` object (or a default export).';
|
||||
}
|
||||
const meta = candidate as Partial<DemoMeta>;
|
||||
|
||||
if (!isNonEmptyString(meta.slug)) return '`slug` is missing or empty.';
|
||||
if (meta.slug !== dirName) {
|
||||
return `\`slug\` is "${meta.slug}" but the directory is "${dirName}". They must match.`;
|
||||
}
|
||||
if (!isNonEmptyString(meta.title)) return '`title` is missing or empty.';
|
||||
if (!isNonEmptyString(meta.tagline)) return '`tagline` is missing or empty.';
|
||||
if (!isNonEmptyString(meta.icon)) return '`icon` is missing or empty.';
|
||||
if (!isNonEmptyString(meta.persona)) return '`persona` is missing or empty.';
|
||||
if (!isNonEmptyString(meta.rewardLine)) return '`rewardLine` is missing or empty.';
|
||||
if (!isNonEmptyString(meta.ogImage)) return '`ogImage` is missing or empty.';
|
||||
if (!isNonEmptyString(meta.vertical) || !VERTICAL_SET.has(meta.vertical)) {
|
||||
return `\`vertical\` is "${String(meta.vertical)}", which is not one of: ${VERTICAL_ORDER.join(', ')}.`;
|
||||
}
|
||||
if (meta.status !== 'live' && meta.status !== 'spec') {
|
||||
return `\`status\` is "${String(meta.status)}"; expected "live" or "spec".`;
|
||||
}
|
||||
if (typeof meta.order !== 'number' || !Number.isFinite(meta.order)) {
|
||||
return '`order` must be a finite number.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function byOrderThenSlug(a: DemoMeta, b: DemoMeta): number {
|
||||
return a.order - b.order || a.slug.localeCompare(b.slug);
|
||||
}
|
||||
|
||||
/** Built once at module load. Quarantine decisions are logged exactly once. */
|
||||
const demosBySlug: ReadonlyMap<string, DemoMeta> = (() => {
|
||||
const accepted = new Map<string, DemoMeta>();
|
||||
|
||||
for (const [path, module] of Object.entries(metaModules)) {
|
||||
const dirName = slugFromPath(path);
|
||||
if (dirName === null) {
|
||||
console.error(`[demo-kit] Ignoring "${path}": could not read a slug from the path.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidate = module.meta ?? module.default;
|
||||
const problem = validationError(candidate, dirName);
|
||||
if (problem !== null) {
|
||||
console.error(`[demo-kit] Quarantined demo "${dirName}": ${problem}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const meta = candidate as DemoMeta;
|
||||
if (!Object.hasOwn(demoLoaders, `../../demos/${dirName}/demo.tsx`)) {
|
||||
console.error(
|
||||
`[demo-kit] Quarantined demo "${dirName}": meta.ts exists but demo.tsx does not.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
accepted.set(meta.slug, meta);
|
||||
}
|
||||
|
||||
return accepted;
|
||||
})();
|
||||
|
||||
const orderedDemos: readonly DemoMeta[] = [...demosBySlug.values()].sort(byOrderThenSlug);
|
||||
|
||||
/** Every demo that survived validation, sorted by `order` then slug. */
|
||||
export function listDemos(): DemoMeta[] {
|
||||
return [...orderedDemos];
|
||||
}
|
||||
|
||||
/** One demo's meta, or `undefined` for an unknown or quarantined slug. */
|
||||
export function getDemo(slug: string): DemoMeta | undefined {
|
||||
return demosBySlug.get(slug);
|
||||
}
|
||||
|
||||
export function hasDemo(slug: string): boolean {
|
||||
return demosBySlug.has(slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Demos grouped by vertical, in `VERTICAL_ORDER`. Verticals with no demos are
|
||||
* omitted — an empty "Telecom" heading reads as a broken page, not a roadmap.
|
||||
*/
|
||||
export function listVerticals(): VerticalGroup[] {
|
||||
const groups = new Map<Vertical, DemoMeta[]>();
|
||||
for (const demo of orderedDemos) {
|
||||
const bucket = groups.get(demo.vertical);
|
||||
if (bucket) bucket.push(demo);
|
||||
else groups.set(demo.vertical, [demo]);
|
||||
}
|
||||
|
||||
return VERTICAL_ORDER.flatMap((vertical) => {
|
||||
const demos = groups.get(vertical);
|
||||
if (!demos || demos.length === 0) return [];
|
||||
return [{ vertical, label: VERTICAL_LABELS[vertical], demos }];
|
||||
});
|
||||
}
|
||||
|
||||
export function getVertical(slug: string): VerticalGroup | undefined {
|
||||
return listVerticals().find((group) => group.vertical === slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Promise cache, not value cache: two callers in the same tick (the route
|
||||
* loader and the page itself) share one dynamic import instead of racing.
|
||||
* A rejection is evicted so a failed chunk fetch can be retried.
|
||||
*/
|
||||
const moduleCache = new Map<string, Promise<AnyDemoModule>>();
|
||||
|
||||
/** Load a demo's heavy half. Rejects for an unknown or quarantined slug. */
|
||||
export function loadDemoModule(slug: string): Promise<AnyDemoModule> {
|
||||
const cached = moduleCache.get(slug);
|
||||
if (cached) return cached;
|
||||
|
||||
const meta = demosBySlug.get(slug);
|
||||
const loader = demoLoaders[`../../demos/${slug}/demo.tsx`];
|
||||
if (!meta || !loader) {
|
||||
return Promise.reject(new Error(`No demo named "${slug}".`));
|
||||
}
|
||||
|
||||
const pending = loader()
|
||||
.then((module) => {
|
||||
const candidate = module.demo ?? module.default;
|
||||
if (candidate === null || typeof candidate !== 'object') {
|
||||
throw new Error(`Demo "${slug}" does not export a demo object from demo.tsx.`);
|
||||
}
|
||||
const demo = candidate as AnyDemoModule;
|
||||
if (demo.meta?.slug !== slug) {
|
||||
throw new Error(
|
||||
`Demo "${slug}" exports a module whose meta.slug is "${String(demo.meta?.slug)}".`,
|
||||
);
|
||||
}
|
||||
return demo;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
moduleCache.delete(slug);
|
||||
throw error;
|
||||
});
|
||||
|
||||
moduleCache.set(slug, pending);
|
||||
return pending;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Taking a reward apart, and putting it back together with different weights.
|
||||
*
|
||||
* The reward editor is the point of the whole site: change what "good" means
|
||||
* and watch the ranking move. This module is the arithmetic behind that, and it
|
||||
* has one job beyond adding numbers up — never to manufacture one. An unscored
|
||||
* component stays `null` all the way to the bar chart.
|
||||
*/
|
||||
|
||||
import { isNotScored, rewardTotal } from './episode';
|
||||
import type { RewardComponent, RewardValues } from './types';
|
||||
|
||||
/** Weights closer than this are the same weight. Guards float drift in sliders. */
|
||||
export const WEIGHT_EPSILON = 1e-9;
|
||||
|
||||
/** A user's edits to the shipped weights, keyed by component. */
|
||||
export type WeightOverrides = Readonly<Record<string, number>>;
|
||||
|
||||
export interface RewardRow {
|
||||
key: string;
|
||||
/** Plain English, straight off the component. */
|
||||
label: string;
|
||||
description: string;
|
||||
/** The raw score the environment emitted. `null` means not scored. */
|
||||
score: number | null;
|
||||
/** The weight in force — shipped, or edited, depending what you passed in. */
|
||||
weight: number;
|
||||
/** `score x weight`, the component's actual contribution. `null` when unscored. */
|
||||
value: number | null;
|
||||
role: RewardComponent['role'];
|
||||
}
|
||||
|
||||
export interface RewardBreakdown {
|
||||
rows: RewardRow[];
|
||||
/** Sum of the scored contributions, or `null` when nothing was scored. */
|
||||
total: number | null;
|
||||
/** How many components carry a real number. */
|
||||
scored: number;
|
||||
/** Components the environment did not grade. Rendered as "not scored". */
|
||||
unscored: string[];
|
||||
}
|
||||
|
||||
/** Per-component rows plus the total, ready to render. */
|
||||
export function decompose(
|
||||
values: RewardValues,
|
||||
components: readonly RewardComponent[],
|
||||
): RewardBreakdown {
|
||||
const rows: RewardRow[] = components.map((component) => {
|
||||
const raw = values[component.key];
|
||||
const scored = !isNotScored(raw);
|
||||
return {
|
||||
key: component.key,
|
||||
label: component.label,
|
||||
description: component.description,
|
||||
score: scored ? raw : null,
|
||||
weight: component.weight,
|
||||
value: scored ? raw * component.weight : null,
|
||||
role: component.role,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
rows,
|
||||
total: rewardTotal(values, components),
|
||||
scored: rows.filter((row) => row.score !== null).length,
|
||||
unscored: rows.filter((row) => row.score === null).map((row) => row.key),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the visitor's weight edits and renormalise so the weights sum to 1.0.
|
||||
*
|
||||
* Renormalising is what makes the editor honest. Without it, dragging one
|
||||
* slider up raises the total for every arm at once and the ranking looks like
|
||||
* it moved when only the scale did. With it, the visitor is trading weight
|
||||
* between components — which is the actual decision a reward designer makes.
|
||||
*
|
||||
* Negative weights are clamped to zero: a negative weight survives
|
||||
* normalisation as a sign flip somewhere else in the vector, and the resulting
|
||||
* chart is arithmetically correct and completely unreadable. If you want a
|
||||
* component to subtract, that belongs in the environment's grader, not here.
|
||||
*
|
||||
* If every weight is edited to zero the result is all zeros — there is no
|
||||
* honest way to normalise a zero vector, and inventing an equal split would be
|
||||
* putting words in the visitor's mouth. Call `weightsAreDegenerate()` on the
|
||||
* result and render "no weight assigned" rather than a 0.00 total.
|
||||
*/
|
||||
export function reweight(
|
||||
components: readonly RewardComponent[],
|
||||
overrides: WeightOverrides,
|
||||
): RewardComponent[] {
|
||||
const clamped = components.map((component) => {
|
||||
const override = overrides[component.key];
|
||||
const weight = override === undefined || !Number.isFinite(override) ? component.weight : override;
|
||||
return { component, weight: Math.max(0, weight) };
|
||||
});
|
||||
|
||||
const sum = clamped.reduce((acc, entry) => acc + entry.weight, 0);
|
||||
if (sum <= WEIGHT_EPSILON) {
|
||||
return clamped.map(({ component }) => ({ ...component, weight: 0 }));
|
||||
}
|
||||
|
||||
return clamped.map(({ component, weight }) => ({ ...component, weight: weight / sum }));
|
||||
}
|
||||
|
||||
/** True when `reweight` could not normalise, i.e. everything was zeroed. */
|
||||
export function weightsAreDegenerate(components: readonly RewardComponent[]): boolean {
|
||||
return components.reduce((acc, c) => acc + c.weight, 0) <= WEIGHT_EPSILON;
|
||||
}
|
||||
|
||||
/**
|
||||
* Has the visitor actually changed anything?
|
||||
*
|
||||
* Pass `components` whenever you have them. Without them this can only ask
|
||||
* "are there any override keys", which reports an edit for a slider that was
|
||||
* dragged and put back — and then the page shows a "modified reward" badge over
|
||||
* the shipped numbers, which is a lie in the other direction.
|
||||
*/
|
||||
export function isEdited(
|
||||
overrides: WeightOverrides,
|
||||
components?: readonly RewardComponent[],
|
||||
): boolean {
|
||||
const keys = Object.keys(overrides);
|
||||
if (keys.length === 0) return false;
|
||||
if (!components) return true;
|
||||
|
||||
return components.some((component) => {
|
||||
const override = overrides[component.key];
|
||||
if (override === undefined || !Number.isFinite(override)) return false;
|
||||
return Math.abs(override - component.weight) > WEIGHT_EPSILON;
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop overrides that match the shipped weight, so a reset yields a clean URL. */
|
||||
export function pruneOverrides(
|
||||
overrides: WeightOverrides,
|
||||
components: readonly RewardComponent[],
|
||||
): WeightOverrides {
|
||||
const out: Record<string, number> = {};
|
||||
for (const component of components) {
|
||||
const override = overrides[component.key];
|
||||
if (override === undefined || !Number.isFinite(override)) continue;
|
||||
if (Math.abs(override - component.weight) > WEIGHT_EPSILON) out[component.key] = override;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Re-deriving a recorded reward in the visitor's own browser.
|
||||
*
|
||||
* The page claims the numbers on it are real. This is the only part of the site
|
||||
* that can actually demonstrate that rather than assert it: it runs the demo's
|
||||
* own `verify()` over the recorded trace and compares the answer to the numbers
|
||||
* shipped in the fixture.
|
||||
*
|
||||
* The failure modes are asymmetric and that asymmetry is the whole design.
|
||||
* `mismatch` is a serious accusation — it says the published fixture disagrees
|
||||
* with the code that supposedly produced it. It must only ever be reached by
|
||||
* comparing two real numbers. Everything else — no verifier, a truncated trace,
|
||||
* an ungraded run, a verifier that threw — is `unverifiable`, which is an
|
||||
* honest "we can't check this here" and is NEVER a zero and NEVER a mismatch.
|
||||
*/
|
||||
|
||||
import { isNotScored, rewardTotal } from './episode';
|
||||
import type { AnyDemoModule } from './registry';
|
||||
import type { DemoEpisode, RewardValues } from './types';
|
||||
|
||||
/**
|
||||
* Floating-point tolerance. The recorded numbers came out of Python and the
|
||||
* recomputed ones out of JavaScript; both are IEEE 754 doubles doing the same
|
||||
* arithmetic in a different order, so they agree to roughly this much and no
|
||||
* further. Anything above it is a real disagreement, not a rounding artefact.
|
||||
*/
|
||||
export const VERIFY_TOLERANCE = 1e-7;
|
||||
|
||||
export type VerifyStatus = 'match' | 'mismatch' | 'unverifiable';
|
||||
|
||||
export interface ComponentComparison {
|
||||
key: string;
|
||||
label: string;
|
||||
recorded: number | null;
|
||||
recomputed: number | null;
|
||||
/** `recomputed - recorded`, or `null` when either side is unscored. */
|
||||
delta: number | null;
|
||||
}
|
||||
|
||||
export interface VerifyResult {
|
||||
status: VerifyStatus;
|
||||
/** Weighted total from re-running the grader here. */
|
||||
recomputed: number | null;
|
||||
/** Weighted total as shipped in the fixture. */
|
||||
recorded: number | null;
|
||||
/** `recomputed - recorded`. `null` when either side is unavailable. */
|
||||
delta: number | null;
|
||||
/**
|
||||
* The first component that disagrees, worst delta first. Named so the UI can
|
||||
* say WHICH term is wrong instead of just flashing red at a total.
|
||||
*/
|
||||
culprit?: ComponentComparison;
|
||||
/** Every component that could be compared, plus the ones that could not. */
|
||||
components: ComponentComparison[];
|
||||
/** Why this is unverifiable, in a sentence fit to render. */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
function unverifiable(reason: string, recorded: number | null = null): VerifyResult {
|
||||
return { status: 'unverifiable', recomputed: null, recorded, delta: null, components: [], reason };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a demo's browser-side grader against its recorded fixture.
|
||||
*
|
||||
* Pure and synchronous: the demo's `verify()` is required to be pure over the
|
||||
* episode, so this can run during render without a loading state.
|
||||
*/
|
||||
export function verifyEpisode(module: AnyDemoModule, episode: DemoEpisode): VerifyResult {
|
||||
const components = module.reward.components;
|
||||
const recordedTotal = rewardTotal(episode.rewards, components);
|
||||
|
||||
if (typeof module.verify !== 'function') {
|
||||
return unverifiable('This demo does not ship a browser-side grader, so the recorded numbers cannot be re-derived here. The environment source is in the repository.', recordedTotal);
|
||||
}
|
||||
|
||||
if (episode.truncated === true) {
|
||||
return unverifiable('The recorded trace is truncated, so the grader has nothing complete to score. A truncated run is unverifiable, not a zero.', recordedTotal);
|
||||
}
|
||||
|
||||
let recomputedValues: RewardValues | null;
|
||||
try {
|
||||
recomputedValues = module.verify(episode);
|
||||
} catch (error: unknown) {
|
||||
// A grader that throws is a bug in the grader, not evidence about the run.
|
||||
// Reporting it as a mismatch would accuse the fixture of being wrong.
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
return unverifiable(`The browser-side grader could not run: ${detail}`, recordedTotal);
|
||||
}
|
||||
|
||||
if (recomputedValues === null) {
|
||||
return unverifiable('The demo reported this run as unverifiable — the trace does not carry everything the grader needs.', recordedTotal);
|
||||
}
|
||||
|
||||
const comparisons: ComponentComparison[] = components.map((component) => {
|
||||
const recordedRaw = episode.rewards[component.key];
|
||||
const recomputedRaw = recomputedValues[component.key];
|
||||
const recorded = isNotScored(recordedRaw) ? null : recordedRaw;
|
||||
const recomputed = isNotScored(recomputedRaw) ? null : recomputedRaw;
|
||||
return {
|
||||
key: component.key,
|
||||
label: component.label,
|
||||
recorded,
|
||||
recomputed,
|
||||
delta: recorded === null || recomputed === null ? null : recomputed - recorded,
|
||||
};
|
||||
});
|
||||
|
||||
const comparable = comparisons.filter((c) => c.delta !== null);
|
||||
if (comparable.length === 0) {
|
||||
return unverifiable('This run carries no graded components to compare against, so there is nothing to verify. Not scored is not zero.', recordedTotal);
|
||||
}
|
||||
|
||||
const recomputedTotal = rewardTotal(recomputedValues, components);
|
||||
|
||||
// Worst first, so the culprit is the component that actually moved the total.
|
||||
const disagreeing = comparable
|
||||
.filter((c) => Math.abs(c.delta ?? 0) > VERIFY_TOLERANCE)
|
||||
.sort((a, b) => Math.abs(b.delta ?? 0) - Math.abs(a.delta ?? 0));
|
||||
|
||||
const totalDelta =
|
||||
recomputedTotal === null || recordedTotal === null ? null : recomputedTotal - recordedTotal;
|
||||
|
||||
const totalsAgree = totalDelta !== null && Math.abs(totalDelta) <= VERIFY_TOLERANCE;
|
||||
const matched = disagreeing.length === 0 && totalsAgree;
|
||||
|
||||
const result: VerifyResult = {
|
||||
status: matched ? 'match' : 'mismatch',
|
||||
recomputed: recomputedTotal,
|
||||
recorded: recordedTotal,
|
||||
delta: totalDelta,
|
||||
components: comparisons,
|
||||
};
|
||||
|
||||
const culprit = disagreeing[0];
|
||||
if (culprit) result.culprit = culprit;
|
||||
return result;
|
||||
}
|
||||
|
||||
/** One line an exec can read, given a result. Keeps the wording in one place. */
|
||||
export function verifySummary(result: VerifyResult): string {
|
||||
switch (result.status) {
|
||||
case 'match':
|
||||
return 'Re-computed in your browser from the recorded trace. It matches the published number.';
|
||||
case 'mismatch':
|
||||
return result.culprit
|
||||
? `Re-computed in your browser and it disagrees on "${result.culprit.label}".`
|
||||
: 'Re-computed in your browser and it disagrees with the published number.';
|
||||
case 'unverifiable':
|
||||
return result.reason ?? 'This run cannot be re-computed in the browser.';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
/**
|
||||
* The shadcn `cn`. `twMerge` is v2 here — importing from a v3 path
|
||||
* (`tailwind-merge/v3` or the `createTailwindMerge` split entry) resolves at
|
||||
* type level and then fails at bundle time, so keep this import bare.
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight, ArrowUpRight, Play } from 'lucide-react';
|
||||
|
||||
import { helloWorldCitations, reproduce, trainingResult } from '@/content/evidence';
|
||||
import { iconFor } from '@/content/icons';
|
||||
import { featuredDemo, lineup, routes } from '@/content/lineup';
|
||||
import { PROPOSAL_NOTICE } from '@/content/verticals';
|
||||
import * as s from '@/content/styles';
|
||||
|
||||
/**
|
||||
* The four boxes. This is the definition the whole site rests on, so it is
|
||||
* written once, here, in the order a person who has never heard the words
|
||||
* "reinforcement learning" can read it: what the job is, what you are allowed
|
||||
* to do, who marks it, what the mark is.
|
||||
*/
|
||||
const ANATOMY: readonly { label: string; body: string }[] = [
|
||||
{
|
||||
label: 'A task',
|
||||
body: 'One unit of work with a beginning and an end. Guess a five-letter word in six tries.',
|
||||
},
|
||||
{
|
||||
label: 'Legal moves',
|
||||
body: 'What the player is allowed to do. Any word on the list, once, five letters.',
|
||||
},
|
||||
{
|
||||
label: 'A grader',
|
||||
body: 'Code that marks the attempt. It runs the same way every time and there is nobody to appeal to.',
|
||||
},
|
||||
{
|
||||
label: 'A score that moves',
|
||||
body: 'One number per attempt. Train against it and it goes up, or it does not and you found that out cheaply.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Home() {
|
||||
const Featured = featuredDemo;
|
||||
|
||||
return (
|
||||
<main>
|
||||
{/* ── The thesis ─────────────────────────────────────────────────── */}
|
||||
<section className={`${s.shell} pt-10 sm:pt-16`}>
|
||||
<p className={s.eyebrow}>Environments, demonstrated</p>
|
||||
<h1 className={`${s.h1} mt-3 max-w-4xl`}>
|
||||
An environment is an eval you can take the gradient of.
|
||||
</h1>
|
||||
<p className={`${s.lede} mt-5 max-w-2xl`}>
|
||||
You write down what good means, in code. A model attempts the work. The grader scores it
|
||||
and cannot be argued with. Then you train against that score and watch the number move —
|
||||
or watch it not move, which you found out in an afternoon instead of a quarter.
|
||||
</p>
|
||||
|
||||
<div className="mt-7 flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
{Featured ? (
|
||||
<Link className={s.btnPrimary} to={routes.demo(Featured.slug)}>
|
||||
<Play aria-hidden="true" className="size-4" />
|
||||
Play the environment
|
||||
</Link>
|
||||
) : null}
|
||||
<Link className={s.btnSecondary} to={routes.honesty}>
|
||||
What we measured, and what we didn’t
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── The credential, before anything else we say ────────────────── */}
|
||||
<section className={`${s.shell} ${s.section}`}>
|
||||
<div className="card p-5 sm:p-7">
|
||||
<p className={s.eyebrow}>Why a word game</p>
|
||||
<h2 className={`${s.h2} mt-2`}>We didn’t pick a game. We picked theirs.</h2>
|
||||
<p className={`${s.prose} mt-3 max-w-3xl`}>
|
||||
Wordle is Prime Intellect’s own hello-world. It is one of five basic end-to-end examples
|
||||
in their trainer, a shipped environment in their library, and the environment their
|
||||
official tutorial optimises prompts against. A demo of their idea should start where
|
||||
they start.
|
||||
</p>
|
||||
<ul className="mt-5 grid gap-3 sm:grid-cols-3">
|
||||
{helloWorldCitations.map((c) => (
|
||||
<li key={c.href}>
|
||||
<a
|
||||
className={`${s.cardLink} h-full bg-surface-2`}
|
||||
href={c.href}
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<span className={`${s.h3} inline-flex items-start gap-1.5`}>
|
||||
<span className="font-mono text-[0.8125rem] leading-6">{c.label}</span>
|
||||
<ArrowUpRight aria-hidden="true" className="mt-1 size-4 shrink-0 text-muted" />
|
||||
</span>
|
||||
<span className={`${s.prose} mt-2 text-sm`}>{c.claim}</span>
|
||||
<span className="sr-only">(opens in a new tab)</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── What an environment is, in four boxes ──────────────────────── */}
|
||||
<section className={`${s.shell} pb-12 sm:pb-16`}>
|
||||
<h2 className={s.h2}>Four parts. That is the whole of it.</h2>
|
||||
<ol className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{ANATOMY.map((box, i) => (
|
||||
<li className="card flex flex-col p-5" key={box.label}>
|
||||
<span className="nums text-xs font-semibold text-brand">0{i + 1}</span>
|
||||
<h3 className={`${s.h3} mt-2`}>{box.label}</h3>
|
||||
<p className={`${s.prose} mt-2 text-sm`}>{box.body}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* ── The one measured number ────────────────────────────────────── */}
|
||||
<section className={`${s.shell} pb-12 sm:pb-16`}>
|
||||
<div className="card overflow-hidden">
|
||||
<div className="grid gap-6 p-5 sm:p-7 lg:grid-cols-[minmax(0,22rem)_minmax(0,1fr)] lg:gap-10">
|
||||
<div>
|
||||
<p className={s.eyebrow}>Published by Prime Intellect</p>
|
||||
<p className="nums mt-3 flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span className="text-4xl font-extrabold tracking-tight text-muted sm:text-5xl">
|
||||
{trainingResult.before}
|
||||
</span>
|
||||
<ArrowRight aria-hidden="true" className="size-6 shrink-0 text-muted" />
|
||||
<span className="text-4xl font-extrabold tracking-tight text-positive sm:text-5xl">
|
||||
{trainingResult.after}
|
||||
</span>
|
||||
</p>
|
||||
<p className={`${s.prose} mt-2 text-sm`}>
|
||||
{trainingResult.model} {trainingResult.metric} on this task, before and after
|
||||
training.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center">
|
||||
<p className={s.prose}>
|
||||
Out of the box, a 1.7-billion-parameter model never once guesses the word. After an{' '}
|
||||
{trainingResult.method}, it wins about six games in ten. Measured on{' '}
|
||||
{trainingResult.evalDescription}. Both checkpoints are public, so the claim is
|
||||
checkable rather than quotable.
|
||||
</p>
|
||||
<p className="mt-4 flex flex-wrap gap-2">
|
||||
<a
|
||||
className={s.pill}
|
||||
href={trainingResult.source.href}
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
The write-up
|
||||
<ArrowUpRight aria-hidden="true" className="size-3.5" />
|
||||
</a>
|
||||
{trainingResult.checkpoints.map((c) => (
|
||||
<a
|
||||
className={s.pill}
|
||||
href={c.href}
|
||||
key={c.href}
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="font-mono">{c.label.replace('PrimeIntellect/', '')}</span>
|
||||
<ArrowUpRight aria-hidden="true" className="size-3.5" />
|
||||
</a>
|
||||
))}
|
||||
</p>
|
||||
{/*
|
||||
The same write-up publishes average-reward figures for these
|
||||
runs. They are deliberately not on this page — see /honesty.
|
||||
*/}
|
||||
<p className="mt-3 text-xs text-muted">
|
||||
We quote the win rate only. The reward numbers in that write-up span versions of the
|
||||
environment and were never re-measured together.{' '}
|
||||
<Link className={s.link} to={routes.honesty}>
|
||||
Why that matters
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── The live demo ──────────────────────────────────────────────── */}
|
||||
{Featured ? (
|
||||
<section className={`${s.shell} pb-12 sm:pb-16`}>
|
||||
<p className={s.eyebrow}>The live one</p>
|
||||
<h2 className={`${s.h2} mt-2`}>Play it, then change what counts as good.</h2>
|
||||
<p className={`${s.prose} mt-3 max-w-2xl`}>
|
||||
The demo runs the same environment the repository ships. You can play a board yourself,
|
||||
watch a recorded model play the same board, read the Python that scored it, and then
|
||||
move the reward weights and watch the ranking of two recorded runs change under you.
|
||||
</p>
|
||||
<Link className={`${s.cardLink} mt-6 sm:p-7`} to={routes.demo(Featured.slug)}>
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span className={`${s.pill} border-positive/30 bg-positive/10 text-positive`}>
|
||||
Live
|
||||
</span>
|
||||
<span className="text-xs text-muted">For the {Featured.persona}</span>
|
||||
</span>
|
||||
<span className="mt-3 text-xl font-bold tracking-tight text-fg sm:text-2xl">
|
||||
{Featured.title}
|
||||
</span>
|
||||
<span className={`${s.prose} mt-2`}>{Featured.tagline}</span>
|
||||
<span className="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<span className="text-sm text-muted">
|
||||
Reward: <span className="text-fg">{Featured.rewardLine}</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 text-sm font-semibold text-accent-fg">
|
||||
Open the demo
|
||||
<ArrowRight
|
||||
aria-hidden="true"
|
||||
className="size-4 transition-transform duration-2 ease-enter group-hover:translate-x-0.5"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted">
|
||||
Or skip the browser
|
||||
</p>
|
||||
<pre className={`${s.codeBlock} mt-2`}>
|
||||
<code>
|
||||
{reproduce.clone}
|
||||
{'\n'}
|
||||
{reproduce.install}
|
||||
{'\n'}
|
||||
{reproduce.evaluate}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
<p className={`${s.prose} self-end text-sm`}>
|
||||
Three commands and you have the environment on your own machine, scoring your own
|
||||
model. Nothing on this page needs our servers to be up.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* ── The lineup ─────────────────────────────────────────────────── */}
|
||||
<section className={`${s.shell} pb-16 sm:pb-24`}>
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<p className={s.eyebrow}>The lineup</p>
|
||||
<h2 className={`${s.h2} mt-2`}>Twelve of these, ranked.</h2>
|
||||
</div>
|
||||
<span className={s.proposalPill}>{PROPOSAL_NOTICE}</span>
|
||||
</div>
|
||||
<p className={`${s.prose} mt-3 max-w-2xl`}>
|
||||
Each one is a task an environment could run, a reward in a number your board already
|
||||
reads, and the counterweight that stops that reward being farmed the crude way. They are
|
||||
our proposals. Nobody’s roadmap, nobody’s customer list.
|
||||
</p>
|
||||
|
||||
<ul className="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{lineup.map((v) => {
|
||||
const Icon = iconFor(v.icon);
|
||||
return (
|
||||
<li key={v.slug}>
|
||||
<Link className={`${s.cardLink} h-full`} to={routes.vertical(v.slug)}>
|
||||
<span className="flex items-start justify-between gap-3">
|
||||
<Icon aria-hidden="true" className="size-5 shrink-0 text-brand" />
|
||||
<span className="nums text-xs font-semibold text-muted">
|
||||
{String(v.rank).padStart(2, '0')}
|
||||
</span>
|
||||
</span>
|
||||
<span className={`${s.h3} mt-3`}>{v.title}</span>
|
||||
<span className={`${s.prose} mt-1.5 text-sm`}>{v.reward}</span>
|
||||
{!v.plannedForV1 ? (
|
||||
<span className="mt-3 text-xs text-muted">Not in the first set</span>
|
||||
) : null}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
|
||||
<Link className={s.btnSecondary} to={routes.gallery}>
|
||||
See what is built
|
||||
</Link>
|
||||
<Link className={s.btnSecondary} to={routes.honesty}>
|
||||
Read the honesty page first
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user