feat(eval): Phase 3 world.html explorer + eval:* CLI surface

Contributor DX magical moment. Static HTML explorer renders the full
canonical world (240 entities) as an explorable tree, opens in any browser,
zero install. Every string HTML-entity-encoded (XSS-safe — direct vuln
class per eng pass 2, confidence 9/10).

Added:
  eval/generators/world-html.ts         — renderer (~240 LOC; single-file
                                          HTML with inline CSS + minimal JS)
  eval/generators/world-html.test.ts    — 16 tests (XSS + rendering correctness)
  eval/cli/world-view.ts                — render + open in default browser
  eval/cli/query-validate.ts            — CLI wrapper for queries/validator
  eval/cli/query-new.ts                 — scaffold a query template

Modified:
  package.json                          — 7 new eval:* scripts
  .gitignore                            — ignore generated world.html

package.json scripts shipped:
  bun run test:eval                 all eval unit tests (57 pass)
  bun run eval:run                  full 4-adapter N=5 side-by-side
  bun run eval:run:dev              N=1 fast dev iteration
  bun run eval:world:view           render world.html + open in browser
  bun run eval:world:render         render only (CI-friendly, --no-open)
  bun run eval:query:validate       validate built-in T5+T5.5 (or a file path)
  bun run eval:query:new            scaffold a new Query JSON template
  bun run eval:type-accuracy        per-link-type accuracy report

XSS safety:
  escapeHtml() encodes the 5 critical chars (& < > " '). Tested directly
  with representative Opus-generated attacks:
    <img src=x onerror=alert('xss')>  → &lt;img src=x onerror=alert(&#39;xss&#39;)&gt;
    <script>fetch('/steal')</script>  → &lt;script&gt;fetch(&#39;/steal&#39;)&lt;/script&gt;
  Ledger metadata (generated_at, model) also escaped — covers the less
  obvious attack surface where Opus could emit tag-like content into the
  metadata file.

world.html structure:
  - Left rail: entities grouped by type with counts (companies, people,
    meetings, concepts), alphabetical within type
  - Right pane: per-entity cards with title + slug + compiled_truth +
    timeline + canonical _facts as collapsed JSON
  - URL fragment deep-links (#people/alice-chen)
  - Sticky rail on desktop; responsive stack on mobile
  - Vanilla JS for active-link highlighting on scroll (no framework)

Generated file: ~1MB for 240 entities (full prose). Gitignored; rebuild
with `bun run eval:world:view`. Regeneration is ~50ms.

Contributor TTHW (Tier 5.5 query authoring):
  1. bun run eval:world:view                         # see entities
  2. bun run eval:query:new --tier externally-authored --author "@me"
  3. edit template with real slug + query text
  4. bun run eval:query:validate path/to/file.json
  5. submit PR

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-19 00:15:18 +08:00
co-authored by Claude Opus 4.7
parent e2a5dc4a2b
commit f0649e2f32
7 changed files with 695 additions and 0 deletions
+1
View File
@@ -12,3 +12,4 @@ supabase/.temp/
.claude/skills/
.idea
eval/reports/
eval/data/world-v1/world.html
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bun
/**
* eval:query:new — scaffold a new Tier 5.5 query from template.
*
* Prints a well-formed Query JSON block that passes `eval:query:validate`.
* Contributors copy-paste into their own query file (or a PR against
* eval/external-authors/<author-slug>/queries.json).
*
* Usage:
* bun run eval:query:new # default tier 5.5
* bun run eval:query:new --tier fuzzy
* bun run eval:query:new --tier externally-authored --author "@alice"
* bun run eval:query:new --id q-custom-0042
*/
import type { Query, Tier } from '../runner/types.ts';
function printHelp() {
console.log(`eval:query:new — scaffold a Query template
USAGE
bun run eval:query:new scaffold default (tier 5.5)
bun run eval:query:new --tier easy specify tier
bun run eval:query:new --id q-0001 specify id
bun run eval:query:new --author "@alice-researcher" external author
OPTIONS
--tier easy | medium | hard | adversarial | fuzzy | externally-authored
--id Query ID (default: q-<timestamp>)
--author Author handle (required for tier=externally-authored)
OUTPUT
Prints a JSON object that passes eval:query:validate. Copy into
your query file, fill in gold.relevant (or expected_abstention for
abstention queries), and iterate.
`);
}
function getArg(name: string, fallback?: string): string | undefined {
const prefix = `--${name}=`;
for (const a of process.argv.slice(2)) {
if (a.startsWith(prefix)) return a.slice(prefix.length);
if (a === `--${name}`) {
const next = process.argv[process.argv.indexOf(a) + 1];
if (next && !next.startsWith('--')) return next;
}
}
return fallback;
}
function main() {
if (process.argv.includes('--help') || process.argv.includes('-h')) {
printHelp();
return;
}
const tier = (getArg('tier') ?? 'externally-authored') as Tier;
const id = getArg('id') ?? `q-${Date.now().toString().slice(-6)}`;
const author = getArg('author');
const template: Query = {
id,
tier,
text: 'REPLACE with your query text (a question or search fragment)',
expected_output_type: 'cited-source-pages',
gold: {
relevant: ['REPLACE/with-real-slug', 'REPLACE/with-another-slug-if-needed'],
},
tags: ['REPLACE-with-tier-or-theme-tags'],
};
if (tier === 'externally-authored') {
template.author = author ?? 'REPLACE-with-your-handle';
}
// If the query text contains a temporal verb, the validator will require
// as_of_date. Leave a helpful placeholder.
template.as_of_date = 'REPLACE if temporal ("corpus-end" | "per-source" | YYYY-MM-DD); else delete this field';
console.log(JSON.stringify(template, null, 2));
console.log(`\n// Next: save as a JSON file, run 'bun run eval:query:validate <path>'`);
console.log(`// For Tier 5.5: submit a PR to eval/external-authors/${author ?? '<your-slug>'}/queries.json`);
}
main();
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bun
/**
* eval:query:validate — validate a query file (or the built-in Tier 5/5.5 set).
*
* Usage:
* bun run eval:query:validate # validate all built-in T5+T5.5
* bun run eval:query:validate path/to/file.ts # validate a file that exports Query[]
* bun run eval:query:validate --help
*
* Exit code 0 if all queries pass, 1 otherwise. Suitable for CI.
*/
import { readFileSync } from 'fs';
import { validateAll, validateQuerySet, formatIssues } from '../runner/queries/index.ts';
import type { Query } from '../runner/types.ts';
function printHelp() {
console.log(`eval:query:validate — validate a Query set
USAGE
bun run eval:query:validate validate all built-in T5 + T5.5 queries
bun run eval:query:validate <path> validate a JSON file containing Query[]
VALIDATOR CHECKS
- id, text, tier, expected_output_type present
- Temporal verbs (is/was/were/current/now/at the time/during/as of/when did)
require as_of_date ("corpus-end" | "per-source" | ISO-8601)
- cited-source-pages requires non-empty gold.relevant with valid slug format
- abstention requires gold.expected_abstention === true
- externally-authored (Tier 5.5) requires author field
- Duplicate IDs caught at batch level
EXIT CODES
0 all queries valid
1 one or more queries failed validation
`);
}
async function main() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
printHelp();
return;
}
const filePath = args[0];
if (!filePath) {
// Validate built-in T5 + T5.5 sets
const result = validateAll();
console.log(result.report);
process.exit(result.ok ? 0 : 1);
}
// Validate a file — supports JSON with { queries: Query[] } or Query[]
let queries: Query[] = [];
try {
const raw = readFileSync(filePath, 'utf-8');
const parsed = JSON.parse(raw);
queries = Array.isArray(parsed) ? parsed : (parsed.queries ?? []);
} catch (e) {
console.error(`Error reading ${filePath}: ${(e as Error).message}`);
process.exit(1);
}
if (queries.length === 0) {
console.error(`No queries found in ${filePath}. Expected JSON Query[] or { queries: Query[] }.`);
process.exit(1);
}
const result = validateQuerySet(queries);
console.log(formatIssues(result));
process.exit(result.ok ? 0 : 1);
}
main().catch(e => {
console.error(e);
process.exit(1);
});
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bun
/**
* eval:world:view — (re)generate world.html and open it in the default browser.
*
* Combines two steps the contributor shouldn't have to think about:
* 1. Render eval/data/world-v1/world.html from the shard JSONs.
* 2. Open it in the default browser via `open` (macOS) / `xdg-open` (Linux).
*
* If the HTML already exists and shards haven't changed since, we'd ideally
* skip step 1 — but comparing timestamps is fragile and regeneration is fast
* (~50ms on 240 entities). Just regenerate every time.
*
* Usage:
* bun run eval:world:view (generates + opens)
* bun run eval:world:view --no-open (generates only; useful in CI)
*/
import { execSync } from 'child_process';
import { platform } from 'os';
import { renderWorldHtmlToFile } from '../generators/world-html.ts';
function openInBrowser(path: string): void {
const cmd = platform() === 'darwin' ? 'open' : platform() === 'win32' ? 'start' : 'xdg-open';
try {
execSync(`${cmd} "${path}"`, { stdio: 'ignore' });
} catch (e) {
// Don't fail hard — the file is rendered, that's the main thing.
console.error(`Could not open browser automatically. Open manually: ${path}`);
console.error(`(${(e as Error).message})`);
}
}
async function main() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`eval:world:view — render + open world.html
USAGE
bun run eval:world:view render eval/data/world-v1/world.html and open it
bun run eval:world:view --no-open render only (CI-friendly)
bun run eval:world:view --dir=PATH render from a different shard directory
OUTPUT
eval/data/world-v1/world.html (gitignored; regenerate with this command).
`);
return;
}
const dir = args.find(a => a.startsWith('--dir='))?.slice('--dir='.length) ??
'eval/data/world-v1';
const noOpen = args.includes('--no-open');
const target = renderWorldHtmlToFile(dir);
console.log(`Rendered ${target}`);
if (!noOpen) {
openInBrowser(target);
}
}
main().catch(e => {
console.error(e);
process.exit(1);
});
+134
View File
@@ -0,0 +1,134 @@
import { describe, test, expect } from 'bun:test';
import { escapeHtml, renderWorldHtml } from './world-html.ts';
describe('escapeHtml — XSS safety', () => {
test('escapes the 5 critical chars', () => {
expect(escapeHtml('<script>alert(1)</script>'))
.toBe('&lt;script&gt;alert(1)&lt;/script&gt;');
});
test('escapes ampersand FIRST (or double-escape bug happens)', () => {
expect(escapeHtml('&lt;'))
.toBe('&amp;lt;'); // the input &lt; becomes &amp;lt;
});
test('escapes quotes (attribute context)', () => {
expect(escapeHtml('onclick="alert(1)"'))
.toBe('onclick=&quot;alert(1)&quot;');
expect(escapeHtml("onclick='alert(1)'"))
.toBe('onclick=&#39;alert(1)&#39;');
});
test('passthrough for safe ASCII', () => {
expect(escapeHtml('Hello world.')).toBe('Hello world.');
});
test('handles null and undefined', () => {
expect(escapeHtml(null)).toBe('');
expect(escapeHtml(undefined)).toBe('');
});
test('handles numbers', () => {
expect(escapeHtml(42)).toBe('42');
});
test('preserves Unicode (non-ASCII that\'s not special HTML)', () => {
expect(escapeHtml('Héctor García 🎉')).toBe('Héctor García 🎉');
});
test('real Opus prose injection attempt neutralized', () => {
// Representative: if Opus generates this in an entity backstory,
// the explorer HTML must NOT execute it. The XSS protection works
// because `<img` is escaped to `&lt;img` — the browser renders the
// resulting string as text, not as an img element, so the embedded
// `onerror=` attribute never gets parsed as an attribute.
const attack = `<img src=x onerror=alert('xss')>`;
const safe = escapeHtml(attack);
// The opening `<` must be escaped (this is what neutralizes the tag).
expect(safe).not.toContain('<img');
// The single quote (which would break out of an attribute) is escaped.
expect(safe).not.toContain("'xss'");
expect(safe).toBe('&lt;img src=x onerror=alert(&#39;xss&#39;)&gt;');
});
test('javascript: URL neutralized when inserted as text', () => {
// Even as text content, escape quotes so it can't break attribute context.
const attack = `"javascript:alert(1)//`;
expect(escapeHtml(attack)).toBe('&quot;javascript:alert(1)//');
});
});
describe('renderWorldHtml', () => {
const samplePage = {
slug: 'people/alice-chen',
type: 'person' as const,
title: 'Alice Chen',
compiled_truth: 'Alice Chen is a senior engineer.',
timeline: '- **2023-01-15** | Promoted to staff engineer',
_facts: { type: 'person', role: 'engineer' },
};
test('renders an HTML document', () => {
const html = renderWorldHtml([samplePage]);
expect(html).toContain('<!doctype html>');
expect(html).toContain('<html');
expect(html).toContain('Alice Chen');
});
test('includes page in rail and in main cards', () => {
const html = renderWorldHtml([samplePage]);
// Rail link uses #slug anchor
expect(html).toContain('href="#people/alice-chen"');
// Entity card has matching id
expect(html).toContain('id="people/alice-chen"');
});
test('escapes all user content (XSS neutralization)', () => {
const maliciousPage = {
...samplePage,
title: '<img src=x onerror=alert(1)>',
compiled_truth: `<script>fetch('/steal')</script>`,
};
const html = renderWorldHtml([maliciousPage]);
// Raw <script> / <img with onerror must NOT be present.
expect(html).not.toContain('<img src=x');
expect(html).not.toContain('<script>fetch');
// Escaped forms must be present.
expect(html).toContain('&lt;img src=x');
expect(html).toContain('&lt;script&gt;fetch');
});
test('groups by type in the rail', () => {
const pages = [
{ ...samplePage, slug: 'people/alice', title: 'Alice', type: 'person' as const },
{ ...samplePage, slug: 'companies/acme', title: 'Acme', type: 'company' as const },
{ ...samplePage, slug: 'meetings/standup', title: 'Standup', type: 'meeting' as const },
];
const html = renderWorldHtml(pages);
expect(html).toContain('companys'); // rendered as "${type}s" — imperfect plural but cheap
expect(html).toContain('persons');
expect(html).toContain('meetings');
});
test('empty corpus produces a valid (if sparse) document', () => {
const html = renderWorldHtml([]);
expect(html).toContain('<!doctype html>');
expect(html).toContain('<nav');
expect(html).toContain('<main');
});
test('ledger metadata renders when provided', () => {
const ledger = { generated_at: '2026-04-19T10:00:00Z', model: 'claude-opus-4-7', costUsd: 3.14 };
const html = renderWorldHtml([samplePage], ledger);
expect(html).toContain('claude-opus-4-7');
expect(html).toContain('$3.14');
});
test('ledger HTML-escapes generated_at and model', () => {
const ledger = { generated_at: '<malicious>', model: 'opus<script>' };
const html = renderWorldHtml([samplePage], ledger);
expect(html).not.toContain('<malicious>');
expect(html).not.toContain('opus<script>');
expect(html).toContain('&lt;malicious&gt;');
});
});
+324
View File
@@ -0,0 +1,324 @@
/**
* World explorer HTML renderer (Phase 3 — contributor DX).
*
* Reads the world-v1 shards + _ledger.json and emits a static HTML file
* that renders the entire canonical world as an explorable tree. Zero
* install, opens in any browser via `bun run eval:world:view`.
*
* Design:
* - Single-file HTML with inline CSS + minimal vanilla JS
* - Left rail: list of entities grouped by type (companies / people /
* meetings / concepts), each a clickable link
* - Right pane: per-entity card with compiled_truth, timeline, and a
* relationships section showing incoming + outgoing facts from _facts
* - URL fragment (#people/alice-chen) deep-links to an entity
*
* XSS safety:
* Every generated string field passes through escapeHtml() before being
* inserted into the DOM as text. `<`, `&`, `"`, `'` all get entity-encoded.
* Opus can (and sometimes does) generate content that looks like
* HTML/script tags; unescaped, any contributor opening world.html would
* run attacker-controlled JS. High-confidence vuln class, 9/10.
*
* Markdown:
* compiled_truth + timeline can contain inline markdown (bold, links).
* For safety, we DON'T render markdown — we preserve linebreaks + show
* `[Name](slug)` links as plain text. This is intentionally minimal;
* a proper renderer would sanitize every AST node which is out of scope
* for v1.1.
*
* Output: writes to eval/data/world-v1/world.html by default.
*/
import { readdirSync, readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
// ─── HTML escaping (XSS safety) ──────────────────────────────────
/**
* HTML-entity-encode a string for safe insertion as text content.
* Escapes the 5 characters that matter for XSS: & < > " '
* Does NOT escape Unicode (surrogate pairs etc. — those are fine as-is).
*/
export function escapeHtml(s: unknown): string {
if (s === null || s === undefined) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* Preserve linebreaks from Opus prose (they're meaningful) by replacing
* \n with <br>. Called AFTER escapeHtml so the <br> isn't escaped.
*/
function preserveLineBreaks(escaped: string): string {
return escaped.replace(/\n/g, '<br>');
}
// ─── Types ──────────────────────────────────────────────────────
interface Page {
slug: string;
type: 'person' | 'company' | 'meeting' | 'concept';
title: string;
compiled_truth: string;
timeline: string;
_facts: Record<string, unknown>;
}
interface Ledger {
generated_at?: string;
model?: string;
costUsd?: number;
files_total?: number;
[key: string]: unknown;
}
// ─── Corpus loader ──────────────────────────────────────────────
function loadCorpus(dir: string): { pages: Page[]; ledger: Ledger } {
const files = readdirSync(dir)
.filter(f => f.endsWith('.json'));
const pages: Page[] = [];
let ledger: Ledger = {};
for (const f of files) {
const raw = readFileSync(join(dir, f), 'utf-8');
if (f === '_ledger.json') {
try { ledger = JSON.parse(raw) as Ledger; } catch { /* fall through */ }
continue;
}
try {
const p = JSON.parse(raw) as Page;
if (Array.isArray(p.timeline)) p.timeline = (p.timeline as unknown as string[]).join('\n');
if (Array.isArray(p.compiled_truth)) {
p.compiled_truth = (p.compiled_truth as unknown as string[]).join('\n\n');
}
p.title = String(p.title ?? '');
p.compiled_truth = String(p.compiled_truth ?? '');
p.timeline = String(p.timeline ?? '');
pages.push(p);
} catch {
// skip malformed files, but don't fail the whole render
}
}
return { pages, ledger };
}
// ─── Rendering helpers ──────────────────────────────────────────
function renderEntityCard(p: Page): string {
const slugSafe = escapeHtml(p.slug);
const titleSafe = escapeHtml(p.title);
const typeSafe = escapeHtml(p.type);
const compiledSafe = preserveLineBreaks(escapeHtml(p.compiled_truth));
const timelineSafe = preserveLineBreaks(escapeHtml(p.timeline));
const factsJson = escapeHtml(JSON.stringify(p._facts, null, 2));
return `
<article class="entity" id="${slugSafe}" data-type="${typeSafe}">
<header>
<div class="entity-type">${typeSafe}</div>
<h2>${titleSafe}</h2>
<code class="slug">${slugSafe}</code>
</header>
<section>
<h3>Compiled truth</h3>
<div class="prose">${compiledSafe || '<em>(no compiled truth)</em>'}</div>
</section>
${p.timeline ? `
<section>
<h3>Timeline</h3>
<div class="prose">${timelineSafe}</div>
</section>` : ''}
<section class="facts">
<h3>Canonical facts (_facts)</h3>
<pre>${factsJson}</pre>
</section>
</article>
`;
}
function renderRail(pages: Page[]): string {
const grouped = new Map<string, Page[]>();
for (const p of pages) {
const arr = grouped.get(p.type) ?? [];
arr.push(p);
grouped.set(p.type, arr);
}
const order = ['company', 'person', 'meeting', 'concept'];
const sections: string[] = [];
for (const type of order) {
const list = grouped.get(type) ?? [];
if (list.length === 0) continue;
list.sort((a, b) => a.title.localeCompare(b.title));
const items = list.map(p => {
const title = escapeHtml(p.title);
const slug = escapeHtml(p.slug);
return `<li><a href="#${slug}" data-slug="${slug}">${title}</a></li>`;
}).join('');
sections.push(`
<section class="rail-section">
<h4>${escapeHtml(type)}s <span class="count">(${list.length})</span></h4>
<ul>${items}</ul>
</section>
`);
}
return sections.join('');
}
function renderLedger(ledger: Ledger, pageCount: number): string {
const lines: string[] = [];
lines.push(`<strong>${pageCount}</strong> entities`);
if (ledger.generated_at) {
lines.push(`generated <code>${escapeHtml(ledger.generated_at)}</code>`);
}
if (ledger.model) {
lines.push(`via <code>${escapeHtml(ledger.model)}</code>`);
}
if (typeof ledger.costUsd === 'number') {
lines.push(`cost <code>$${ledger.costUsd.toFixed(2)}</code>`);
}
return lines.join(' \u00b7 ');
}
// ─── Top-level render ───────────────────────────────────────────
export function renderWorldHtml(pages: Page[], ledger: Ledger = {}): string {
const entityCards = pages
.slice()
.sort((a, b) => {
const byType = a.type.localeCompare(b.type);
return byType !== 0 ? byType : a.title.localeCompare(b.title);
})
.map(renderEntityCard)
.join('\n');
const rail = renderRail(pages);
const ledgerLine = renderLedger(ledger, pages.length);
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BrainBench twin-amara world explorer</title>
<style>
:root {
--fg: #1a1a1a; --fg-dim: #6b6b6b; --bg: #fafafa; --accent: #0a66c2;
--card-bg: #fff; --border: #e5e5e5; --code-bg: #f4f4f4;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font: 15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: var(--bg); color: var(--fg);
display: grid; grid-template-columns: 280px 1fr; min-height: 100vh;
}
nav.rail {
background: #fff; border-right: 1px solid var(--border);
padding: 16px 12px 40px; overflow-y: auto; position: sticky; top: 0; height: 100vh;
}
nav.rail h1 { font-size: 14px; margin: 0 0 4px; }
nav.rail .subtitle { font-size: 11px; color: var(--fg-dim); margin-bottom: 16px; line-height: 1.4; }
nav.rail .ledger { font-size: 10px; color: var(--fg-dim); margin-bottom: 20px; padding-bottom: 12px; border-bottom: 1px solid var(--border); }
nav.rail h4 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--fg-dim); margin: 14px 0 4px; }
nav.rail .count { font-weight: normal; }
nav.rail ul { list-style: none; padding: 0; margin: 0; }
nav.rail li { margin: 0; }
nav.rail a {
display: block; padding: 3px 6px; color: var(--fg); text-decoration: none;
font-size: 12.5px; border-radius: 3px;
}
nav.rail a:hover { background: #f0f7ff; color: var(--accent); }
nav.rail a.active { background: #e8f1fc; color: var(--accent); font-weight: 500; }
main { padding: 28px 36px 80px; max-width: 900px; }
h1.page-title { font-size: 22px; margin: 0 0 4px; }
p.page-subtitle { color: var(--fg-dim); margin: 0 0 28px; font-size: 13px; }
article.entity {
background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px;
padding: 20px 24px; margin-bottom: 28px; scroll-margin-top: 16px;
}
article.entity header { margin-bottom: 14px; }
article.entity .entity-type {
display: inline-block; font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em;
color: var(--fg-dim); background: var(--code-bg); padding: 2px 6px; border-radius: 3px;
}
article.entity h2 { font-size: 19px; margin: 4px 0; }
article.entity .slug { font-size: 11px; color: var(--fg-dim); }
article.entity h3 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--fg-dim); margin: 16px 0 6px; }
article.entity .prose { font-size: 14px; line-height: 1.55; }
article.entity .prose em { color: var(--fg-dim); }
article.entity section.facts pre {
font: 11px/1.5 ui-monospace, "SF Mono", Menlo, monospace;
background: var(--code-bg); border-radius: 4px; padding: 10px;
overflow-x: auto; white-space: pre-wrap; color: var(--fg);
}
@media (max-width: 800px) {
body { grid-template-columns: 1fr; }
nav.rail { position: static; height: auto; border-right: none; border-bottom: 1px solid var(--border); }
}
</style>
</head>
<body>
<nav class="rail">
<h1>twin-amara world</h1>
<div class="subtitle">BrainBench v1.1 canonical corpus</div>
<div class="ledger">${ledgerLine}</div>
${rail}
</nav>
<main>
<h1 class="page-title">Explore the canonical world</h1>
<p class="page-subtitle">
Click an entity in the sidebar to jump to it. All content is HTML-entity-encoded
to prevent XSS; Opus-generated prose can contain tag-like fragments. Use this
as a reference when writing Tier 5.5 queries.
</p>
${entityCards}
</main>
<script>
// Highlight the active nav link based on scroll position. Minimal vanilla.
(function() {
const railLinks = document.querySelectorAll('nav.rail a[data-slug]');
const entities = document.querySelectorAll('article.entity');
function updateActive() {
const y = window.scrollY + 120;
let active = null;
for (const e of entities) {
const top = e.getBoundingClientRect().top + window.scrollY;
if (top <= y) active = e.id;
}
railLinks.forEach(a => {
if (a.getAttribute('data-slug') === active) a.classList.add('active');
else a.classList.remove('active');
});
}
window.addEventListener('scroll', updateActive, { passive: true });
updateActive();
})();
</script>
</body>
</html>
`;
}
// ─── CLI entrypoint ─────────────────────────────────────────────
export function renderWorldHtmlToFile(dir: string, outPath?: string): string {
const { pages, ledger } = loadCorpus(dir);
const html = renderWorldHtml(pages, ledger);
const target = outPath ?? join(dir, 'world.html');
writeFileSync(target, html, 'utf-8');
return target;
}
// Run directly (e.g. `bun eval/generators/world-html.ts`)
if (import.meta.main) {
const dir = process.argv.find(a => a.startsWith('--dir='))?.slice('--dir='.length) ??
'eval/data/world-v1';
const out = process.argv.find(a => a.startsWith('--out='))?.slice('--out='.length);
const target = renderWorldHtmlToFile(dir, out);
console.log(`Wrote ${target}`);
}
+8
View File
@@ -22,6 +22,14 @@
"build:schema": "bash scripts/build-schema.sh",
"test": "bun test",
"test:e2e": "bun test test/e2e/",
"test:eval": "bun test eval/runner/queries/validator.test.ts eval/runner/adapters/ eval/generators/world-html.test.ts",
"eval:run": "bun eval/runner/multi-adapter.ts",
"eval:run:dev": "BRAINBENCH_N=1 bun eval/runner/multi-adapter.ts",
"eval:world:view": "bun eval/cli/world-view.ts",
"eval:world:render": "bun eval/cli/world-view.ts --no-open",
"eval:query:validate": "bun eval/cli/query-validate.ts",
"eval:query:new": "bun eval/cli/query-new.ts",
"eval:type-accuracy": "bun eval/runner/type-accuracy.ts",
"postinstall": "gbrain --version >/dev/null 2>&1 && gbrain apply-migrations --yes --non-interactive 2>/dev/null || true",
"prepublish:clawhub": "bun run build:all",
"publish:clawhub": "clawhub package publish . --family bundle-plugin"