diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66cfa3e..691980d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,6 @@ name: CI -# Build + test gate. Runs on every PR and on pushes to main, so a branch must +# Build + typecheck + test gate. Runs on every PR and on pushes to main, so a branch must # be green before it is merged (enable "Require status checks to pass" on the # main branch protection rule to enforce it). Deploy lives in deploy.yml and # runs only after a merge to main. @@ -37,8 +37,24 @@ jobs: - name: Install dependencies run: npm ci - - name: Build (type-checked) + # This step was called "Build (type-checked)". It was not type-checked, and neither was anything + # else: Astro and vitest both hand .ts to esbuild, which strips annotations without ever asking + # the compiler. So a build being green said nothing about types — proof, before the Typecheck step + # below existed: `tsc --noEmit` reported a real TS2345 in tests/skyShader.test.ts while `npm run + # build` and `npm test` were both green. Renamed to stop the name making a promise it cannot keep. + - name: Build run: npm run build + # THE ONLY PLACE TYPES ARE CHECKED. Two checkers, because neither covers the other: `astro check` + # is the only thing that reads the ~4,400 lines of diff --git a/src/components/PlushCow.astro b/src/components/PlushCow.astro index d9c2140..0f43afe 100644 --- a/src/components/PlushCow.astro +++ b/src/components/PlushCow.astro @@ -60,8 +60,8 @@ const measure = widestHalf(lines.slice(0, 1)) + 1; data-cow-lines={JSON.stringify(spoken)} style={`--moo-measure: ${measure}ch`} > - + {/* THE BUBBLE COMES FIRST in source order so a screen reader hears what the cow says before being told it + can press it, and so the visual order (bubble left, cow right) needs no reordering. */}
))} - - + before-first-press states both look finished. */}

{spoken[0]?.[0]} {spoken[0]?.[1]}

{size === 'large' && ( - + /* A hint that the cow is pressable — on /writing and /404 it is the only interactive thing on the page. + aria-hidden because the button's own label already says so; announcing it twice is noise. */ )} - + {/* The tail, from a matrix like every other pixel mark here. Not a CSS border-triangle: that has + anti-aliased diagonals, which would be the one non-pixel edge in the drawing. */} to navigate,

The desk — where to find my work

- -
- - - -
- - - -
- - - -
- - - - - diff --git a/src/components/Toc.astro b/src/components/Toc.astro index 77e334d..b06c1db 100644 --- a/src/components/Toc.astro +++ b/src/components/Toc.astro @@ -229,8 +229,15 @@ for (const s of stops) { document.documentElement.classList.add('no-toc-js'); function initToc() { - const toc = document.querySelector('[data-toc]'); - if (!toc || !('requestAnimationFrame' in window)) return; // CSS fallback stays + // ALIASED AFTER THE GUARD, and this is a type fix rather than a style choice. TypeScript does not carry a + // const's narrowing into a HOISTED `function` declaration — only into arrow functions — because a hoisted + // function could in principle be called before the guard ran. The guard below is real and correct, but the + // `function` bodies further down still saw `HTMLElement | null` and `astro check` reported them the first time it + // ever ran on this file. Declaring a non-null alias makes the type true at the declaration, so no narrowing + // has to survive anything. + const maybeToc = document.querySelector('[data-toc]'); + if (!maybeToc || !('requestAnimationFrame' in window)) return; // CSS fallback stays + const toc = maybeToc; document.documentElement.classList.remove('no-toc-js'); const links = Array.from(toc.querySelectorAll('a')); diff --git a/src/components/proto/FluidSky.astro b/src/components/proto/FluidSky.astro index c5f99eb..9d1a872 100644 --- a/src/components/proto/FluidSky.astro +++ b/src/components/proto/FluidSky.astro @@ -15,6 +15,20 @@ // // Perf: viewport-sized (scroll is a uniform), 0.5x internal resolution, ~30fps // idle / full rate while scrolling, paused on tab-hide, skipped under 640px. +// +// ONE SHAPE: the canvas is always the page-wide fixed backdrop. There used to be a second +// `mode="contained"` shape — position:absolute inside a 72vh slab, with its own uniform wiring +// (a fixed depth window, and uYOffset driven by the strength LEVEL rather than by scroll) so five +// strengths could be stacked on one screen and compared. It existed for /proto-ladder, the +// strength-calibration route, and for nothing else: `grep -rn 'contained' src/pages` had exactly +// one hit. The level it was built to pick has been picked and shipped (AMP_BY_LEVEL, below), the +// route is retired, and that grep is now 0 — so the prop, its class pair and its uniform branch +// are gone. Deliberately NOT kept "in case": a mode with no caller is a branch nobody exercises, +// and this component runs on every page, where an untested branch is the expensive kind. +// +// (Note the house habit: this rationale is a frontmatter comment, not an HTML one. Astro emits +// HTML comments into the build, and FluidSky renders on every route — an explanation this long +// would ship to every visitor on every page. Frontmatter `//` never reaches the output.) interface Props { /** * Where in the pattern the page STARTS, in viewport widths. @@ -26,41 +40,34 @@ interface Props { */ yStart?: number; strength?: 0 | 1 | 2 | 3 | 4; - mode?: 'background' | 'contained'; /** 'descent' (paper text, dawn→ground) | 'reading' (dark ink, luminous) */ variant?: 'descent' | 'reading'; } -const { strength = 4, mode = 'background', variant = 'descent', yStart = 0 } = Astro.props; +const { strength = 4, variant = 'descent', yStart = 0 } = Astro.props; --- diff --git a/src/data/desk.ts b/src/data/desk.ts index cac8e2e..f9de583 100644 --- a/src/data/desk.ts +++ b/src/data/desk.ts @@ -1,77 +1,21 @@ // src/data/desk.ts -// THE CONCRETE EXAMPLE — real tickers, invented year. +// THE DESK AS THE SITE DESCRIBES IT: what a real mandate forbids, how big the problem is, and what an +// attempt on it would be assembled from. // -// The owner: "let's say you have AAPL, NVDA, META, BOA, XAUUSD, WTIOIL, etc. imagine that you have news now -// and then, and you pick initial portfolio weights, during news, during the 1 year horizon, you shifted -// positions (just like every trader), you probably resulted in a turbulent portfolio pnl." +// WHAT LEFT THIS FILE, so nobody rebuilds it. It opened with a "concrete example" dataset — six real tickers +// (AAPL, NVDA, META, BAC, XAUUSD, WTI) with invented drifts and vols, six invented news headlines with a +// per-ticker shock each, a 48-path trader count, and a disclaimer sentence about the whole thing being made +// up. All of it fed one consumer: the 48-trader Monte Carlo in lib/scenario.ts, which no page renders. The +// slide that argues "same year, different decisions, different outcome" is src/sections/Choice.astro, and it +// reads data/define.ts — five NAMED policies over four holdings — because a reader can say out loud what +// separates five curves and cannot say anything at all about forty-eight. // -// HONESTY, AND IT MATTERS MORE HERE THAN ANYWHERE ELSE ON THE SITE. The ticker names are real; the prices, -// the news headlines and every path are INVENTED. Real names make invented numbers look authoritative, so -// the slide states this in its own copy — not only in a source comment — and the headlines are written as -// plainly generic events rather than as things that actually happened on a date. +// The disclaimer went with the data and that is the right direction, not a loss of honesty: the invented +// figures it disclaimed no longer exist. data/define.ts carries its own statement about its own numbers, +// next to the numbers, which is where such a sentence belongs. // -// The drifts and vols are rounded, textbook-plausible figures for each asset class, chosen so the example -// behaves recognisably (NVDA swings more than BOA; gold moves against equities on risk-off news). They are -// characteristics of a made-up world, not estimates anyone should trade on. - -import type { Instrument, NewsEvent } from '../lib/scenario'; - -export const INSTRUMENTS: Instrument[] = [ - { ticker: 'AAPL', name: 'Apple', glyph: 'tech', drift: 0.11, vol: 0.26 }, - { ticker: 'NVDA', name: 'NVIDIA', glyph: 'chip', drift: 0.24, vol: 0.52 }, - { ticker: 'META', name: 'Meta', glyph: 'tech', drift: 0.13, vol: 0.36 }, - { ticker: 'BAC', name: 'Bank of America', glyph: 'bank', drift: 0.07, vol: 0.24 }, - { ticker: 'XAUUSD', name: 'Gold', glyph: 'gold', drift: 0.05, vol: 0.14 }, - { ticker: 'WTI', name: 'Crude oil', glyph: 'oil', drift: 0.03, vol: 0.34 }, -]; - -/** - * Six headlines across the year, each with a one-week shock per instrument. - * - * Written generically on purpose: "a chipmaker beats expectations" rather than a real dated event, so the - * example cannot be mistaken for a claim about what happened. The shocks are internally consistent — a - * risk-off week lifts gold and hurts equities — because an inconsistent world would teach the wrong - * reflexes. - */ -export const NEWS: NewsEvent[] = [ - { - week: 6, - headline: 'Chipmaker beats expectations; AI capex guidance raised', - shock: { NVDA: 0.14, AAPL: 0.03, META: 0.05, BAC: 0.00, XAUUSD: -0.01, WTI: 0.01 }, - }, - { - week: 13, - headline: 'Inflation print comes in hot; rate-cut hopes pushed out', - shock: { NVDA: -0.09, AAPL: -0.04, META: -0.05, BAC: 0.03, XAUUSD: -0.03, WTI: 0.02 }, - }, - { - week: 21, - headline: 'Regional bank stress resurfaces; flight to safety', - shock: { NVDA: -0.06, AAPL: -0.03, META: -0.04, BAC: -0.11, XAUUSD: 0.06, WTI: -0.04 }, - }, - { - week: 30, - headline: 'Supply disruption lifts crude; energy costs jump', - shock: { NVDA: -0.02, AAPL: -0.02, META: -0.02, BAC: 0.00, XAUUSD: 0.02, WTI: 0.17 }, - }, - { - week: 38, - headline: 'Antitrust ruling lands against a large platform', - shock: { NVDA: 0.01, AAPL: -0.02, META: -0.13, BAC: 0.00, XAUUSD: 0.01, WTI: 0.00 }, - }, - { - week: 45, - headline: 'Soft landing narrative returns; risk appetite recovers', - shock: { NVDA: 0.10, AAPL: 0.05, META: 0.07, BAC: 0.04, XAUUSD: -0.03, WTI: 0.02 }, - }, -]; - -/** Stated on the slide, in the slide's own words. */ -export const DESK_DISCLAIMER = - 'Real tickers, invented year: the prices, the headlines and every path below are made up. A worked example, not a backtest.'; - -/** How many discretionary paths to draw. Enough to read as a population, few enough to stay legible. */ -export const TRADER_COUNT = 48; +// What remains is all live: CONSTRAINTS and FUND feed src/sections/Rules.astro, METHODOLOGIES and the OPEN_* +// copy feed src/sections/Solve.astro. // ── FUND SCALE, AND THE CONSTRAINTS THAT COME WITH IT ──────────────────────────────────────────────── // @@ -121,24 +65,32 @@ export const CONSTRAINTS: Constraint[] = [ }, ]; -/** Fund-scale figures, declared. */ +/** + * Fund-scale figures, declared — and ONLY the ones something reads. + * + * All three are read. Rules.astro prints `tickers` and `constraints` through lib/problemSize.ts's humanCount + * and derives its DP state-count exponent from `tickers`; `periods` feeds lib/complexity.ts's decisionVariables + * and scenarioLeaves, in that section and in tests/complexity.test.ts. + * + * Two more fields used to sit here — `aum: '$1B'` and `drawdownLimit: 0.08` — with no reader anywhere. Both + * facts DO reach the page, as prose inside the CONSTRAINTS entries above ("Max drawdown 8%…", "At $1B, your + * own order moves the price…"), so the unread copies bought nothing and risked something: the day one is + * edited and the other is not, the same slide states two different numbers for the same fact. Adding a field + * back means adding its reader in the same change — tests/problemSize.test.ts asserts the key set for that + * reason. + */ export const FUND = { - aum: '$1B', tickers: 3000, constraints: 2000, periods: 24, - drawdownLimit: 0.08, }; -/** WHAT THE JOB IS, in the owner's own framing: find a good strategy to optimise a SEQUENCE of decisions. */ -export const JOB_STATEMENT = - 'Find a strategy that optimises a sequence of decisions — consistent return, controlled drawdown, every constraint respected, at a scale where your own trading moves the price.'; - -export const JOB_NOTE = - 'Sharpe is how that consistency gets measured: return per unit of swing, not return alone. A path that doubles and halves is worth less than one that climbs steadily, because only the second one survives contact with a mandate.'; - -/** The toolkit, named as instruments with the job each does. */ - +// TWO COPY BLOCKS LEFT HERE TOO: JOB_STATEMENT ("Find a strategy that optimises a sequence of decisions…") +// and JOB_NOTE (the Sharpe gloss). Neither had appeared on the site in any build — `grep -c 'optimises a +// sequence of decisions' dist/index.html` returned 0 — because the sections that once framed the job were +// rewritten around their own inline copy. Unrendered prose in a data file is the worst kind of stale: it +// reads like the site's current voice on a claim the site may no longer make, and an editor fixing the page's +// wording never sees it. If the framing is wanted again it should be written next to the markup that shows it. // ── THE LAST SLIDE: STILL AN OPEN PROBLEM, AND A FEW GENERAL DIRECTIONS ────────────────────────────────── // diff --git a/src/data/making.ts b/src/data/making.ts index b36876a..6ef4c8f 100644 --- a/src/data/making.ts +++ b/src/data/making.ts @@ -8,9 +8,12 @@ // site states the PhD as "incoming" everywhere for the same reason — honesty over flourish. Naming what // the writing will cover is useful to a reader; inventing three fake essay titles would not be. -/** One line under the title, setting the hierarchy: this slide is evidence, not the argument. */ -export const MAKING_INTRO = - 'The research is the work. These are the things around it — notes I intend to write, and software that already runs.'; +// MAKING_INTRO USED TO OPEN THIS FILE — "The research is the work. These are the things around it — notes I +// intend to write, and software that already runs." A good sentence, and it has never been on the site: the +// only consumer of this module is src/sections/Work.astro, which imports WRITINGS_PROMISE and WRITINGS_TOPICS +// and writes its own heading. Deleted rather than wired up, because deciding what the section says is an +// editorial call, and a data file is the wrong place to make one silently. If the slide wants a lede, it +// should be written in the slide. /** The promise, in the same voice as the rest of the site. Italic display type on the page. */ export const WRITINGS_PROMISE = diff --git a/src/data/method.ts b/src/data/method.ts deleted file mode 100644 index acdb7fa..0000000 --- a/src/data/method.ts +++ /dev/null @@ -1,35 +0,0 @@ -// src/data/method.ts -// SLIDE 2's words. Short by design — the chart is the argument on that slide. -// -// The owner's brief: "at the next slide, you only need to say that i make and justify my decision -// systematically with math, and ML as a tool blablabla." -// -// "ML AS A TOOL" is the load-bearing phrase and the reason this copy is worth care. The site's identity -// hierarchy is quant-first: the mathematics decides, and machine learning is instrumentation. Copy that -// led with ML would quietly invert that, which the project's guide explicitly forbids. - -export const METHOD_HEADLINE = - 'Every weight is a decision I can justify — from the mathematics, not from a hunch.'; - -export const METHOD_BODY: string[] = [ - 'The structure comes first: an objective, the constraints it must respect, and the geometry that makes ' + - 'the problem solvable rather than merely stated. That is operations research, and it is what makes an ' + - 'allocation defensible to someone who wants to argue with it.', - - 'Machine learning is a tool inside that structure, not a replacement for it. It is good at the part ' + - 'classical methods assume away — how exposures should adapt as conditions change — and it is only ' + - 'trustworthy when the structure around it still holds.', -]; - -export interface MethodTool { - name: string; - role: string; -} - -/** What each tool is FOR. Named as instruments, in the order they actually apply. */ -export const METHOD_TOOLS: MethodTool[] = [ - { name: 'Convex optimization', role: 'the objective and the constraints, and a solution you can certify' }, - { name: 'Risk decomposition', role: 'where the risk actually sits, before deciding what to change' }, - { name: 'Reinforcement learning', role: 'how the exposures adapt, once the structure is fixed' }, - { name: 'Out-of-sample testing', role: 'the part that decides whether any of it was real' }, -]; diff --git a/src/data/moment.ts b/src/data/moment.ts deleted file mode 100644 index acab63d..0000000 --- a/src/data/moment.ts +++ /dev/null @@ -1,122 +0,0 @@ -// src/data/moment.ts -// ONE CONCRETE MOMENT — the setup the owner asked for, so the question stops being abstract. -// -// The owner: "the how would you invest is still lacking concreteness. what i want is more concrete. consider -// these tickers, we all know there will be news, consider a more concrete setting, like we have XXX news -// coming out next week, we have XXX coming out after that, in the long run i know this company is facing XXX -// pressure. at this moment, how do you decide the best positions given that you are allowed to reweight? -// also, you need to consider the reweight costs from slippage etc." -// -// So this file is a briefing, not a table. The reader is put at a specific desk on a specific morning, with -// three things a real analyst would actually hold in their head: what is scheduled, what is coming after it, -// and the slow structural pressure underneath. THEN the question lands, and it has a shape. -// -// EVERYTHING HERE IS INVENTED, and the slide says so. Real tickers, hypothetical calendar: no real earnings -// date, no real decision, no real guidance. The pressures are the sort of thing that is publicly discussed -// about each business, written generically enough that none of it is a claim about what will happen. - -/** Where the book stands this morning. */ -export interface Holding { - ticker: string; - name: string; - glyph: string; - /** Current weight, as a percentage of the book. */ - weight: number; - /** The one line that matters about holding it right now. */ - stance: string; -} - -export const BOOK: Holding[] = [ - { ticker: 'NVDA', name: 'NVIDIA', glyph: 'chip', weight: 24, stance: 'The biggest position, and the biggest single risk.' }, - { ticker: 'AAPL', name: 'Apple', glyph: 'tech', weight: 18, stance: 'Steadier, but exposed to the same demand cycle.' }, - { ticker: 'META', name: 'Meta', glyph: 'tech', weight: 14, stance: 'Cheap on earnings, with a legal tail.' }, - { ticker: 'BAC', name: 'Bank of America', glyph: 'bank', weight: 16, stance: 'Gains if rates stay high, suffers if credit turns.' }, - { ticker: 'XAUUSD', name: 'Gold', glyph: 'gold', weight: 16, stance: 'Insurance. Costs you when nothing goes wrong.' }, - { ticker: 'WTI', name: 'Crude oil', glyph: 'oil', weight: 12, stance: 'Hedges an inflation surprise, adds its own swings.' }, -]; - -/** What is on the calendar, in the order it arrives. */ -export interface CalendarItem { - when: string; - what: string; - /** Which tickers it lands on. */ - hits: string[]; - /** Why it is genuinely two-sided — the reason there is no obvious trade. */ - twoSided: string; -} - -export const CALENDAR: CalendarItem[] = [ - { - when: 'Next Tuesday', - what: 'NVDA earnings, with datacentre guidance', - hits: ['NVDA', 'AAPL', 'META'], - twoSided: 'A beat lifts the whole complex; a soft guide takes 15% off your largest position in a morning.', - }, - { - when: 'Two weeks out', - what: 'CPI print, then the rate decision eight days later', - hits: ['BAC', 'XAUUSD', 'WTI'], - twoSided: 'Hot print helps the bank and hurts gold. Cool print does the reverse. You hold both.', - }, - { - when: 'Next month', - what: 'Antitrust ruling expected on a large platform', - hits: ['META'], - twoSided: 'Binary, unschedulable in practice, and it can slip by a quarter.', - }, -]; - -/** The slow thing underneath, which no single event resolves. */ -export const STRUCTURAL = { - head: 'And underneath all of it', - body: - 'The AI capex cycle that carries your three largest holdings is funded by a handful of buyers. That ' + - 'concentration is not an event on a calendar — it is a pressure that builds for quarters and then ' + - 'resolves in a week, and it is correlated across everything you own except gold.', -}; - -/** THE QUESTION, now that the setting is concrete. */ -export const MOMENT_QUESTION = 'So: what do you hold on Monday?'; - -export const MOMENT_LEDE = - 'You may reweight whenever you like. Every reweight costs you the spread, and — because the position is ' + - 'large — it costs you more the faster you move. You will face this question again after each event, from ' + - 'whatever book this decision leaves you holding.'; - -/** Stated plainly, because real tickers make invented specifics look authoritative. */ -export const MOMENT_DISCLAIMER = - 'Real tickers, hypothetical calendar. The events, weights and figures below are invented to show how the decision is shaped — none of it is a forecast or a record.'; - -// ── THE SLIPPAGE PANEL ─────────────────────────────────────────────────────────────────────────────── -// -// "You need to consider the reweight costs from slippage etc. might good to make a visual to explain this." -// -// Two figures make it concrete rather than theoretical: the size of the trade in dollars, and what it costs -// at different speeds. The model is spread + square-root impact (see lib/decisionTree.ts) — the standard -// empirical form, labelled as a model rather than a law. - -export const SLIPPAGE = { - /** The trade being priced: trimming the largest position by a third. */ - tradeLabel: 'Trim NVDA from 24% to 16% of a $1B book', - notional: '$80M', - /** Illustrative daily volume for the participation axis. */ - advLabel: '≈ $2.5B daily volume', - spreadBp: 2, - impactBp: 35, - /** The edge you think the trade is worth, for the break-even line. */ - edgeBp: 18, - note: - 'Move it in a morning and you pay for the hurry. Move it over a week and the event arrives before you ' + - 'are done. That trade-off is the decision, and it does not exist for someone trading a thousand shares.', -}; - -/** THE TREE: three plausible actions at each of four decision points. */ -export const TREE = { - branch: 3, - depths: 4, - actionLabels: ['trim risk', 'hold', 'add'], - decisionLabels: ['today', 'after earnings', 'after CPI', 'after the ruling'], - note: - 'Three plausible actions, four decision points — and the last row is every book you could be holding by ' + - 'the end. Now make it three thousand names, twenty-four periods, and rules that forbid most of the tree.', -}; diff --git a/src/data/profile.ts b/src/data/profile.ts index f2f01b8..a50ac77 100644 --- a/src/data/profile.ts +++ b/src/data/profile.ts @@ -203,6 +203,11 @@ export const projects: Project[] = [ export const links: { label: string; href: string; primary?: boolean }[] = [ { label: 'Download CV', href: '/cv.pdf', primary: true }, { label: 'Research', href: '/research' }, + // WRITING WAS MISSING HERE while data/nav.ts's PAGES carried it, so the corner nav offered /writing and the + // footer did not — two page lists, already diverged. Caught by the dist smoke test, not by a human. + // The order matches PAGES so the two read the same way round; tests/distSmoke.test.ts now asserts every + // PAGES href reaches the rendered footer, which is what keeps them in step from here. + { label: 'Writing', href: '/writing' }, { label: 'Experience', href: '/experience' }, { label: 'Projects', href: '/projects' }, { label: 'Art', href: '/art' }, diff --git a/src/lib/capability.ts b/src/lib/capability.ts index b2a461e..f183f0a 100644 --- a/src/lib/capability.ts +++ b/src/lib/capability.ts @@ -1,6 +1,27 @@ // src/lib/capability.ts // BREADTH AND DEPTH OF A CAPABILITY PROFILE — the honest replacement for "predict r_TIAN". // +// ── NO PAGE IMPORTS THIS, AND THAT IS DELIBERATE. DO NOT DELETE IT AS DEAD CODE. ── +// An import-graph closure from every entry point in src/pages/ does not reach this file, so a +// dead-code sweep flags it every single time. It survived one such sweep only because a human +// went and read a design note in notes/, and that folder is now deleted too — so this comment is the +// ONLY surviving record of why an unreferenced module is here. It is quoted rather than referenced +// for exactly that reason: a pointer outlives its target, a quotation does not. +// +// The decision, verbatim from that note ("What IS decided, and committed"): +// "src/lib/capability.ts — breadth statistics (HHI, effective dimensions). Rejected as a +// *subject* but kept: they are honest and may serve as substructure." +// The showpiece slot at the bottom of the homepage is intentionally empty after five rejected +// attempts; the maths that survived each attempt is kept, the visuals are not. Breadth-as-a- +// headline was rejected ("too direct… like advertising and branding yourself like a commodity"), +// but the statistics themselves are correct and reproducible, and a sixth attempt is expected to +// want them as substructure rather than as the subject. +// +// Its spec (tests/capability.test.ts) is therefore the only thing keeping it honest — keep that +// green. The design-time PRINTER that used to sit beside it (tests/capabilityInspect.test.ts) is +// gone: it asserted almost nothing and only existed to read the numbers off while the rejected +// slide was being drawn. +// // WHY THIS EXISTS, and it is a correction worth stating: // the factor model was written as r_TIAN = α + Σ β_k f_k + ε. That reads as a RETURN, and a // return implies a P&L. There is no P&L for a person, so the equation was letting a metaphor diff --git a/src/lib/decisionTree.ts b/src/lib/decisionTree.ts deleted file mode 100644 index ef664e5..0000000 --- a/src/lib/decisionTree.ts +++ /dev/null @@ -1,197 +0,0 @@ -// src/lib/decisionTree.ts -// THE BRANCHING OF DECISIONS — why multi-period is hard, drawn rather than asserted. -// -// The owner: "seems like you didn't surface countless decisions you make in the multi period setting" and -// "why is it hard seems lack visuals". -// -// Both are the same gap. The slides SAID the choices multiply and printed 72,000 as a figure; neither showed -// the thing that actually makes the problem hard, which is that each decision opens a new set of decisions. -// A number in a table does not convey that. A tree does: every node is a state of the book, every edge is a -// reweight you could make, and the width of the last row is the answer to "how many ways could this year -// have gone". -// -// WHAT MAKES THE TREE HONEST. It is not a decoration with arbitrary branching — the branch factor IS the -// number of distinct actions offered at each decision point, and the leaf count is that raised to the number -// of decision points, which is exactly the arithmetic of the real problem. Nothing is rounded up for effect, -// and the module exposes the count so the copy cannot drift from the picture. -// -// Pure: no DOM. Unit-tested (tests/decisionTree.test.ts). - -/** A node in the decision tree, positioned in unit space (0..1 on both axes). */ -export interface TreeNode { - /** Depth: 0 is today, 1 is after the first news event, and so on. */ - depth: number; - /** Index within its depth. */ - index: number; - /** Unit x — position across the fan at this depth. */ - x: number; - /** Unit y — 0 at the root, 1 at the last decision. */ - y: number; - /** Parent's index within the previous depth, or -1 at the root. */ - parent: number; - /** Which action this node arrived by: 0..branch-1. Root is -1. */ - action: number; -} - -export interface TreeEdge { - from: TreeNode; - to: TreeNode; - /** The action taken, 0..branch-1. */ - action: number; -} - -export interface Tree { - nodes: TreeNode[]; - edges: TreeEdge[]; - /** Nodes grouped by depth, for drawing row by row. */ - byDepth: TreeNode[][]; - branch: number; - depths: number; - /** Total leaves = branch^depths. The honest headline number. */ - leaves: number; -} - -/** - * Build a complete branching tree. - * - * @param branch how many distinct actions are available at each decision point - * @param depths how many decision points there are - * - * Leaves grow as branch^depths, which is why this is drawn small (3^4 = 81 fits a slide) and stated large - * (the real problem's count is astronomical). The picture and the number are the same fact at two scales. - */ -export function buildTree(branch: number, depths: number): Tree { - const b = Math.max(1, Math.floor(branch)); - const d = Math.max(0, Math.floor(depths)); - - const byDepth: TreeNode[][] = []; - const nodes: TreeNode[] = []; - const edges: TreeEdge[] = []; - - for (let depth = 0; depth <= d; depth++) { - const count = Math.pow(b, depth); - const row: TreeNode[] = []; - for (let i = 0; i < count; i++) { - // Centre each node in its own slice of the row, so a row of one sits in the middle and a row of many - // spreads evenly. This is what makes the fan read as a fan rather than as a left-aligned ladder. - const x = (i + 0.5) / count; - const y = d > 0 ? depth / d : 0; - const node: TreeNode = { - depth, - index: i, - x, - y, - parent: depth === 0 ? -1 : Math.floor(i / b), - action: depth === 0 ? -1 : i % b, - }; - row.push(node); - nodes.push(node); - } - byDepth.push(row); - - if (depth > 0) { - const prev = byDepth[depth - 1]; - for (const n of row) { - edges.push({ from: prev[n.parent], to: n, action: n.action }); - } - } - } - - return { nodes, edges, byDepth, branch: b, depths: d, leaves: Math.pow(b, d) }; -} - -/** - * One path through the tree, as a list of actions — used to highlight a single "what if I do this, then - * this" sequence against the full fan. - * - * Deterministic: the caller passes the actions, so a highlighted path is a stated choice rather than a - * random walk (the project bans Math.random() at paint time). - */ -export function pathOf(tree: Tree, actions: readonly number[]): TreeNode[] { - const out: TreeNode[] = []; - if (!tree.byDepth.length) return out; - let node = tree.byDepth[0][0]; - out.push(node); - for (let depth = 1; depth <= tree.depths; depth++) { - const a = actions[depth - 1]; - if (a === undefined) break; - const action = ((a % tree.branch) + tree.branch) % tree.branch; - const index = node.index * tree.branch + action; - const row = tree.byDepth[depth]; - if (!row || !row[index]) break; - node = row[index]; - out.push(node); - } - return out; -} - -/** - * How the count explodes, as a per-depth series — for labelling each row of the drawing with the number of - * distinct books you could be holding by then. - */ -export function countsByDepth(tree: Tree): number[] { - return tree.byDepth.map((row) => row.length); -} - -/** - * SLIPPAGE: what a reweight actually costs, and why it is not linear. - * - * The owner: "you need to consider the reweight costs from slippage etc." - * - * Cost has two parts. The spread is paid on everything you trade — linear in size. Market impact is the part - * that makes size itself the problem: pushing a large order through a finite book moves the price against - * you, and the standard model for that is proportional to the square root of participation (your order as a - * fraction of daily volume). Square-root impact is the widely used empirical form; it is not a claim of - * precision, and the slide calls it a model rather than a law. - * - * @param fraction the fraction of the portfolio being reweighted (0..1) - * @param spreadBp half-spread paid per unit traded, in basis points - * @param impactBp impact coefficient in basis points at 100% participation - * @param participation the order as a fraction of the instrument's daily volume - * @returns cost as a fraction of the portfolio - */ -export function reweightCost( - fraction: number, - spreadBp = 2, - impactBp = 35, - participation = 0.1, -): number { - const f = Math.max(0, fraction); - const spread = (spreadBp / 10000) * f; - const impact = (impactBp / 10000) * Math.sqrt(Math.max(0, participation)) * f; - return spread + impact; -} - -/** Cost curve samples, for drawing the "why size hurts" line. */ -export function costCurve( - samples = 40, - maxParticipation = 1, - spreadBp = 2, - impactBp = 35, -): { participation: number; costBp: number }[] { - const out: { participation: number; costBp: number }[] = []; - for (let i = 0; i <= samples; i++) { - const p = (maxParticipation * i) / samples; - // Cost of reweighting the whole position, expressed in bp so the axis is readable. - out.push({ participation: p, costBp: reweightCost(1, spreadBp, impactBp, p) * 10000 }); - } - return out; -} - -/** - * The size at which trading cost overwhelms an expected edge — the honest reason "just rebalance" is not an - * answer at scale. Returns the participation level where cost equals the given edge in bp, or null when the - * edge covers the cost at every level modelled. - */ -export function breakEvenParticipation( - edgeBp: number, - spreadBp = 2, - impactBp = 35, -): number | null { - // spread + impact*sqrt(p) = edge -> sqrt(p) = (edge - spread) / impact - const net = edgeBp - spreadBp; - if (net <= 0) return 0; // the spread alone already eats the edge - const root = net / impactBp; - const p = root * root; - return p <= 1 ? p : null; -} diff --git a/src/lib/deck.ts b/src/lib/deck.ts index dc52af1..bc326a4 100644 --- a/src/lib/deck.ts +++ b/src/lib/deck.ts @@ -151,3 +151,37 @@ export function currentStop(stops: readonly number[], y: number): number | null for (const s of stops) if (Math.abs(s - y) < Math.abs(best - y)) best = s; return best; } + +/** + * How long one slide transition takes, in ms. + * + * 650, and the number came from the owner's own screen recording rather than taste. Measured on that clip + * (578 frames at 55.8fps), the browser's native smooth scroll finished a slide in about 500ms — but spent the + * first ~35ms going from a standstill to nearly peak velocity. A launch that fast is what he described as "the + * sudden change of slides instead of a smooth transition": the eye reads an instant departure and a soft + * arrival, which is the opposite shape from the one that feels deliberate. + * A little longer than native, because an ease-IN spends time at low velocity at the start; at 500ms with this + * curve the middle has to move faster than the native scroll did to cover the same distance. + */ +export const DECK_TWEEN_MS = 650; + +/** + * Symmetric ease for the deck's scroll — slow, fast, slow. + * + * WHY WE OWN THE ANIMATION AT ALL. `window.scrollTo({ behavior: 'smooth' })` hands the curve to the browser, + * and the browser's curve is front-loaded (see DECK_TWEEN_MS). It is also uncancellable: there is no API to + * stop an in-flight native smooth scroll, which is why two overlapping transitions showed up in the recording + * as a double lurch — the second scroll began while the first was still running and the position jumped. + * A rAF tween fixes both: the shape is ours, and cancelling is one cancelAnimationFrame. + * + * easeInOutCubic, deliberately, not a spring or a bezier with overshoot: this is a page moving to a resting + * position that the reader chose, and overshoot on a full-viewport scroll reads as sloppiness rather than + * bounce. Symmetric so departure and arrival feel like the same gesture. + * + * Pure and clamped, so it is unit-testable and cannot be handed a progress value outside [0, 1] by a frame + * that arrives late. + */ +export function easeInOutCubic(t: number): number { + const p = t <= 0 ? 0 : t >= 1 ? 1 : t; + return p < 0.5 ? 4 * p * p * p : 1 - (-2 * p + 2) ** 3 / 2; +} diff --git a/src/lib/descentPath.ts b/src/lib/descentPath.ts index 7086fbf..7afa7e2 100644 --- a/src/lib/descentPath.ts +++ b/src/lib/descentPath.ts @@ -227,11 +227,16 @@ export function trail(samples = 360): TrailPoint[] { return out; } -/** The trail parameter at which the walk reaches waypoint `i`. Waypoints are evenly spaced in the - * spline's parameter, so this is exact rather than a search. */ -export function waypointT(i: number): number { - return i / (WAYPOINTS.length - 1); -} +// `waypointT(i)` lived here — the trail parameter at which the walk reaches waypoint i, which is exactly +// i / (WAYPOINTS.length - 1) because the waypoints are evenly spaced in the spline's parameter. It was never +// imported. The one place that needs the number, components/DescentPath.astro's waypoint loop, writes that +// same division inline against its local `reveal`, and has since it was written. +// +// So this is a duplicate with no callers, not a shared helper: exporting it from here made the module look +// like the owner of a rule the drawing code had already decided for itself. Deleted in that direction rather +// than the other, because a one-expression identity is cheaper to state where it is used than to import — but +// if a second consumer ever appears, put the helper back and change DescentPath.astro at the same time. One +// place or the other, never both. /** Where the trail turns upward, as a t-range — for calling out the escape. */ export function climbSpan(pts: readonly TrailPoint[]): { t0: number; t1: number } | null { diff --git a/src/lib/equations.ts b/src/lib/equations.ts index b3b77a9..2fceeef 100644 --- a/src/lib/equations.ts +++ b/src/lib/equations.ts @@ -34,48 +34,23 @@ export const PAPER_EQUATIONS = { sectorCov: katex.renderToString('\\tilde{\\Sigma}_{gh} = (\\eta^{(g)})^{\\top}\\Sigma_{gh}\\,\\eta^{(h)}', display), }; -// ── The factor model, for the exposure fan ────────────────────────────────── -// Baked through the SAME KaTeX -> MathML path as everything else, which is the point: a first -// pass hand-built this from SVG elements and it read as monospace text pretending to -// be maths — wrong subscript sizing, wrong italic/upright distinction, no proper spacing -// around operators. Real typesetting is not optional for the one element whose whole job is -// to say "this is a model". - -/** The general form: one asset, many signals. */ -export const FACTOR_EQUATIONS = { - general: katex.renderToString( - 'r_{\\mathrm{TIAN}} = \\alpha + \\sum_{k=1}^{6} \\beta_k f_k + \\varepsilon', - display, - ), - /** How beta is defined — the caption's claim, typeset rather than described in prose. */ - betaDef: katex.renderToString( - '\\beta_k = \\frac{\\sum_{i \\in k} s_i}{\\sum_{j} s_j}', - display, - ), -}; - -/** - * The expanded form, with each term's fitted loading substituted in. - * - * Built from the live loadings so the displayed equation and the drawn fan can never - * disagree — the numbers come from one source. Zero-loading terms are rendered in a muted - * colour via \\textcolor so an honest absence reads as deliberate rather than as a typo. - */ -export function factorExpansion( - terms: readonly { symbol: string; beta: number }[], -): string { - const body = terms - .map(({ symbol, beta }) => { - const coef = beta.toFixed(2); - const term = `${coef}\\,f_{\\mathrm{${symbol}}}`; - return beta === 0 ? `\\textcolor{#8c8576}{${term}}` : term; - }) - .join(' + '); - return katex.renderToString( - `r_{\\mathrm{TIAN}} = \\alpha + ${body} + \\varepsilon`, - { ...display, trust: true }, - ); -} +// ── THE FACTOR MODEL IS GONE, AND IT WAS REJECTED BEFORE IT WAS DEAD ──────────────────────────────────── +// +// This section held FACTOR_EQUATIONS (a general form and a definition of beta) plus factorExpansion(), a +// function that baked the same equation with each fitted loading substituted in, muting zero-loading terms +// via \textcolor. It fed the exposure fan on /proto-showpiece, and that route has been retired, so nothing +// imports any of it. +// +// Two reasons it is deleted rather than parked. The obvious one is that a build-time KaTeX call with no +// consumer is pure cost — katex is a devDependency precisely so none of it ships, and unreachable bakes make +// the ledger of what the client receives harder to read. +// +// The one that actually settles it: all three rendered `r_{\mathrm{TIAN}}`, and the design note recorded +// that framing as rejected on a substantive point, not a visual one — "r_TIAN is a RETURN and implies a P&L +// that does not exist." The maths was mechanically correct and the betas were honest; the variable was a claim +// the site cannot make. A typeset equation is the most authoritative-looking thing on a page, so a rejected +// framing kept in perfectly baked form is exactly how it comes back — the next person needing a factor +// equation finds one ready, correct-looking, and wrong on the only point that mattered. // ── THE CURSE OF DIMENSIONALITY, for the difficulty slide's fourth beat ────────────────────────────────── // diff --git a/src/lib/fanFrame.ts b/src/lib/fanFrame.ts deleted file mode 100644 index 2e8f003..0000000 --- a/src/lib/fanFrame.ts +++ /dev/null @@ -1,168 +0,0 @@ -// src/lib/fanFrame.ts -// The factor exposure fan, as a build-time SVG still. -// -// This is the owner's two-stage idea rendered: the expression r = α + Σ β_k f_k + ε, and the -// same terms expanded into a fan of beams. One asset at the origin, one beam per factor, -// beam AREA proportional to that factor's beta. -// -// WHY SVG BEFORE WebGL: the numbers are already locked and tested (factorModel.ts, 284 -// tests), so the only open question is whether the picture is worth building in 3D. An SVG -// still answers that for the cost of one function, which is the same kill gate that let -// seriation and the simplex be judged cheaply instead of after five sessions. -// -// The projection here is the SAME maths a WebGL build would use — a perspective divide over -// the beam quads from factorModel.beamQuad — so the composition is representative rather -// than a mood board. - -import { fanBeams, beamQuad, loadings, type Beam, type Vec3 } from './factorModel'; - -const W = 1600; -const H = 880; - -/** Camera: back along −z, looking at the origin, slight downward pitch so the fan's dome - * reads. Same shape as podCamera's projection, kept local so this file has no dependency on - * the pod work that is being replaced. */ -// MEASURED, not chosen. The first pass used z −4.6 / focal 1.02 / pitch 0.10, which squashed -// the whole fan into the middle third of the frame and stacked the tip labels on top of each -// other. Pulling the camera IN (−2.5) and lifting it (1.15) with a longer focal spreads the -// beams across the frame and separates the tips vertically as well as horizontally. -const CAM = { z: -2.5, y: 1.15, focal: 1.05, pitch: 0.30 } as const; - -function project(p: Vec3): [number, number, number] { - const yc = p.y - CAM.y; - const zc = p.z - CAM.z; - const cosP = Math.cos(CAM.pitch); - const sinP = Math.sin(CAM.pitch); - const y1 = yc * cosP + zc * sinP; - const z1 = zc * cosP - yc * sinP; - const z = Math.max(0.05, z1); - const s = (CAM.focal * W) / z; - return [W * 0.5 + p.x * s, H * 0.52 - y1 * s, z]; -} - -const f2 = (n: number) => (Math.round(n * 10) / 10).toString(); -const pt = (p: Vec3) => { const [x, y] = project(p); return `${f2(x)} ${f2(y)}`; }; - -/** A beam's wedge as an SVG path. */ -function beamPath(b: Beam): string { - const [r0, t0, t1, r1] = beamQuad(b); - return `M${pt(r0)}L${pt(t0)}L${pt(t1)}L${pt(r1)}Z`; -} - -/** A ground shadow for a beam — the flattened wedge at y = 0. It is what stops the fan - * floating in a void, and it is honest: a real light would cast exactly this. */ -function beamShadow(b: Beam): string { - const [r0, t0, t1, r1] = beamQuad(b); - const flat = (p: Vec3): Vec3 => ({ x: p.x, y: 0, z: p.z }); - return `M${pt(flat(r0))}L${pt(flat(t0))}L${pt(flat(t1))}L${pt(flat(r1))}Z`; -} - -export function fanFrame(scored: readonly { id: string; label: string; score: number }[] | null = null): string { - const beams = fanBeams(); - const ls = loadings(scored); - const betaFor = new Map(ls.map((l) => [l.factor.key, l.beta])); - - // Painter's order: furthest first, so nearer beams overlap correctly. - const ordered = [...beams].sort((a, b) => project(b.tip)[2] - project(a.tip)[2]); - - const shadows = ordered - .map((b) => ``) - .join(''); - - // Beams: a filled wedge plus a brighter leading edge, which is what gives a flat shape - // the read of a solid object under light. - const wedges = ordered.map((b) => { - const zero = b.beta === 0; - const [r0, t0, t1] = beamQuad(b); - return [ - ``, - ``, - ``, - ].join(''); - }).join(''); - - // A ground ruling, so the plane the shadows fall on exists. - const rules: string[] = []; - for (let i = 1; i <= 5; i++) { - const r = i * 0.62; - const seg: string[] = []; - for (let k = 0; k <= 40; k++) { - const a = -Math.PI * 0.62 + (k / 40) * Math.PI * 1.24; - seg.push(`${k === 0 ? 'M' : 'L'}${pt({ x: Math.sin(a) * r, y: 0, z: Math.cos(a) * r })}`); - } - rules.push(``); - } - - // The asset: one mark at the origin. This is the ticker every beam loads onto. - const [ox, oy] = project({ x: 0, y: 0, z: 0 }); - const origin = - `` + - ``; - - // Labels: a LEADER LINE out to the frame's edge, then the text on a clear margin. - // - // A first pass put labels at the projected tips and they collided — six tips in a 150° arc - // are simply not far enough apart, and stacking two lines of text at each made it worse. - // Leader lines are the standard fix in technical illustration precisely because they - // decouple where a label POINTS from where it SITS: tips stay where the geometry says, - // text goes on a tidy vertical ladder in the margin where nothing can overlap. - const withScreen = beams.map((b) => { - const [tx, ty] = project(b.tip); - return { b, tx, ty }; - }); - const left = withScreen.filter((s) => s.tx < W * 0.5).sort((a, c) => a.ty - c.ty); - const right = withScreen.filter((s) => s.tx >= W * 0.5).sort((a, c) => a.ty - c.ty); - - // The ladder's x must leave room for the LONGEST label at its font size, or the right-hand - // text clips at the frame edge — "MARKET REPORTS" at 21px mono is ~290px, which is what - // overflowed a 150px margin. Measured from the label set rather than guessed. - const longest = Math.max(...beams.map((b) => b.factor.label.length)); - const MARGIN = Math.ceil(longest * 12.6) + 24; - const LADDER_TOP = 210; - const LADDER_GAP = 116; - const ladder = (rows: typeof withScreen, side: 'l' | 'r') => - rows.map((s, i) => { - const ly = LADDER_TOP + i * LADDER_GAP; - const lx = side === 'l' ? MARGIN : W - MARGIN; - const anchor = side === 'l' ? 'end' : 'start'; - const beta = betaFor.get(s.b.factor.key) ?? 0; - const zero = s.b.beta === 0; - // elbow: out from the tip horizontally, then to the ladder row - const midX = side === 'l' ? lx + 46 : lx - 46; - return [ - ``, - ``, - `${s.b.factor.label}`, - `β ${beta.toFixed(3)}${zero ? ' · not yet' : ''}`, - ].join(''); - }).join(''); - - // NOTE: the equation is NOT drawn here. It is rendered as real KaTeX→MathML in the page - // above this frame — hand-built SVG maths reads as monospace text pretending to be - // an equation, which is exactly what it looked like. - // Text styling lives INSIDE the svg, for the same reason the palette is inline on the - // wrapper: a class referenced only from a set:html string gets tree-shaken out of Astro's - // scoped stylesheet, and the labels then render at the browser's default 16px serif. - // Fonts still come from the site's tokens, so nothing is hardcoded twice. - const style = ` - `; - - return ` - - ${style} - ${rules.join('')} - ${shadows} - ${wedges} - ${origin} - ${ladder(left, 'l')} - ${ladder(right, 'r')} -`.trim(); -} diff --git a/src/lib/fanScene.ts b/src/lib/fanScene.ts deleted file mode 100644 index ca15585..0000000 --- a/src/lib/fanScene.ts +++ /dev/null @@ -1,412 +0,0 @@ -// src/lib/fanScene.ts -// The interactive 3D factor fan. Loaded ONLY from a dynamic import inside an -// IntersectionObserver (see FactorFan.astro), so three.js is never fetched on first paint, -// never during a Lighthouse audit, and never under prefers-reduced-motion. -// -// AN INSTRUMENT, NOT PIXEL ART. A first pass rendered into a low-res buffer upscaled with -// image-rendering: pixelated; against a Swiss-minimal page that read as a retro-game artefact -// and was dropped. Now: full resolution, antialiased, flat-shaded wedges over a hairline -// measuring frame — beta rings, radial spokes and tick marks — so the object looks measured, -// which is what it is. -// -// EVERY NUMBER COMES FROM THE MODEL. Beam length and width are the betas computed in -// factorModel.ts; nothing here invents geometry. The scene is the equation, rotatable. - -// A STATIC import, deliberately. This module is itself only ever reached through a dynamic -// import() in FactorFan.astro, so Vite emits three.js in this module's chunk and fetches it at -// that moment — not on first paint. Using require() here would break in ESM, and a second -// dynamic import inside would only add a round trip. -import * as THREE from 'three'; - -import type { Vec3 } from './factorModel'; - -export interface SceneBeam { - key: string; - label: string; - beta: number; - azimuth: number; - elevation: number; - length: number; - halfWidth: number; - tip: Vec3; -} - -export interface FanSceneOpts { - canvas: HTMLCanvasElement; - beams: SceneBeam[]; - /** Called when the pointer is over a beam, so the page can show its signals. */ - onActive: (key: string | null) => void; - /** Called every frame with each beam's tip in normalised [0,1] screen space, so the DOM - * labels ride the geometry instead of guessing where it went. */ - placeLabel: (key: string, x: number, y: number) => void; -} - -/** Device-pixel cap. 2 is the usual ceiling for this site's canvases: beyond it the GPU cost - * doubles for no visible gain on the hairlines this scene is made of. */ -const MAX_DPR = 2; - -export function mountFanScene(opts: FanSceneOpts): () => void { - const { canvas, beams, onActive, placeLabel } = opts; - let disposed = false; - let raf = 0; - - return start(); - - function start(): () => void { - const cleanups: Array<() => void> = []; - - const scene = new THREE.Scene(); - const camera = new THREE.PerspectiveCamera(38, 1600 / 880, 0.1, 100); - - const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true }); - renderer.setPixelRatio(Math.min(MAX_DPR, window.devicePixelRatio || 1)); - renderer.outputColorSpace = THREE.SRGBColorSpace; - renderer.toneMapping = THREE.NoToneMapping; // the colours ARE the palette tokens - - // ── Palette, read from the live CSS tokens so both themes work with no branching here. - const css = getComputedStyle(document.documentElement); - const tok = (name: string, fallback: string) => - new THREE.Color((css.getPropertyValue(name).trim() || fallback)); - const ochre = tok('--ochre', '#c8a36a'); - const indigo = tok('--indigo', '#6d7689'); - const seal = tok('--seal', '#b23a2e'); - const paper = tok('--paper', '#efe9dd'); - const ink5 = tok('--ink-5', '#b8b1a1'); - - // ── A soft ramp. Still banded (flat facets read as planes, which is what a diagram wants) - // but with enough steps that it no longer looks like a cel-shaded game. - const rampData = new Uint8Array([96, 140, 178, 210, 236, 255]); - const ramp = new THREE.DataTexture(rampData, rampData.length, 1, THREE.RedFormat); - ramp.needsUpdate = true; - ramp.minFilter = THREE.NearestFilter; - ramp.magFilter = THREE.NearestFilter; - - const root = new THREE.Group(); - scene.add(root); - - // ── Beams. Each is an extruded wedge: a flat quad given thickness, so it reads as a solid - // under the toon ramp rather than as a piece of paper. - const meshes: { - key: string; mesh: any; base: any; tip: any; - /** 0..1 eased hover weight, so the response is animated rather than switched. */ - hover: number; zero: boolean; edge: any; - }[] = []; - let gaugeGlow = 0; - for (const b of beams) { - const zero = b.beta === 0; - const geom = beamGeometry(b); - const mat = new THREE.MeshToonMaterial({ - color: zero ? indigo : ochre, - gradientMap: ramp, - transparent: true, - opacity: zero ? 0.42 : 1, - }); - const mesh = new THREE.Mesh(geom, mat); - mesh.userData.key = b.key; - root.add(mesh); - // The label anchor sits PAST the tip along the beam's own axis, so the text clears the - // wedge instead of sitting on top of it. 1.18 is enough at every loading, including the - // short zero stubs. - const anchor = new THREE.Vector3(b.tip.x, b.tip.y, b.tip.z).multiplyScalar(1.18); - const record = { - key: b.key, mesh, base: mat.color.clone(), tip: anchor, - hover: 0, zero, edge: null as any, - }; - meshes.push(record); - - // A bright leading edge along the beam's top — the single strongest cue that a flat - // shape is a solid one. - const edge = new THREE.Mesh( - beamEdgeGeometry(b), - new THREE.MeshBasicMaterial({ color: paper, transparent: true, opacity: zero ? 0.3 : 0.85 }), - ); - root.add(edge); - record.edge = edge; - } - - // ── The asset at the origin: one seal-red mark every beam loads onto. - const asset = new THREE.Mesh( - new THREE.IcosahedronGeometry(0.075, 0), // 0 subdivisions = chunky facets, on purpose - new THREE.MeshToonMaterial({ color: seal, gradientMap: ramp }), - ); - root.add(asset); - - // ── THE MEASURING FRAME. This is what fills the scene: without it six wedges float in a - // void and the whole thing reads as empty, which was the complaint. Everything here is - // derived from the model — no invented furniture. - // - // · concentric rings at beta = 0.1 … 0.5, so a beam's LENGTH is readable as a value - // rather than as a relative size; - // · one radial spoke per factor, running the full radius, so the six angular slots are - // visible even where a beam is short (the two zero factors especially); - // · tick marks along each spoke at the ring radii. - // `gauge`, not `frame`: `frame` is the rAF callback further down and esbuild rejected the - // duplicate binding. - const gauge = new THREE.Group(); - root.add(gauge); - - const RING_BETAS = [0.1, 0.2, 0.3, 0.4, 0.5]; - const betaToRadius = (b: number) => 0.55 + b * 2.6; // matches FAN.minLength/lengthGain - - const ringMat = new THREE.LineBasicMaterial({ color: ink5, transparent: true, opacity: 0.22 }); - ringMat.userData.baseOpacity = 0.22; - for (const rb of RING_BETAS) { - const rad = betaToRadius(rb); - const pts: THREE.Vector3[] = []; - for (let i = 0; i <= 96; i++) { - const a = (i / 96) * Math.PI * 2; - pts.push(new THREE.Vector3(Math.cos(a) * rad, 0, Math.sin(a) * rad)); - } - gauge.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), ringMat)); - } - - // Spokes + ticks, one per factor, at that factor's own azimuth. - const spokeMat = new THREE.LineBasicMaterial({ color: ink5, transparent: true, opacity: 0.16 }); - spokeMat.userData.baseOpacity = 0.16; - const tickMat = new THREE.LineBasicMaterial({ color: ink5, transparent: true, opacity: 0.34 }); - tickMat.userData.baseOpacity = 0.34; - const outer = betaToRadius(0.5); - for (const b of beams) { - const dx = Math.sin(b.azimuth); - const dz = Math.cos(b.azimuth); - gauge.add(new THREE.Line( - new THREE.BufferGeometry().setFromPoints([ - new THREE.Vector3(0, 0, 0), - new THREE.Vector3(dx * outer, 0, dz * outer), - ]), - spokeMat, - )); - // ticks across the spoke at each ring - for (const rb of RING_BETAS) { - const rad = betaToRadius(rb); - const px = Math.cos(b.azimuth) * 0.035; - const pz = -Math.sin(b.azimuth) * 0.035; - gauge.add(new THREE.Line( - new THREE.BufferGeometry().setFromPoints([ - new THREE.Vector3(dx * rad - px, 0, dz * rad - pz), - new THREE.Vector3(dx * rad + px, 0, dz * rad + pz), - ]), - tickMat, - )); - } - } - - // A vertical axis through the origin — the asset's own line, and it stops the object - // reading as flat when seen from a low angle. - gauge.add(new THREE.Line( - new THREE.BufferGeometry().setFromPoints([ - new THREE.Vector3(0, -0.02, 0), new THREE.Vector3(0, 1.05, 0), - ]), - new THREE.LineBasicMaterial({ color: ink5, transparent: true, opacity: 0.2 }), - )); - - // ── Light: one key, one fill. Flat and directional, which is what the toon ramp wants. - const key = new THREE.DirectionalLight(0xffffff, 2.1); - key.position.set(-2.4, 3.0, 2.2); - scene.add(key); - scene.add(new THREE.AmbientLight(0xffffff, 0.55)); - - camera.position.set(0, 1.15, -2.55); - camera.lookAt(0, 0.42, 0.6); - - // ── Drag to rotate. Pointer events only, so touch and mouse share one path. - let yaw = 0; - let pitch = 0; - let dragging = false; - let lastX = 0; - let lastY = 0; - let spin = 0.055; // gentle idle rotation, so it reads as interactive at a glance - - const onDown = (e: PointerEvent) => { - dragging = true; - lastX = e.clientX; - lastY = e.clientY; - canvas.classList.add('is-dragging'); - canvas.setPointerCapture?.(e.pointerId); - }; - const onMove = (e: PointerEvent) => { - if (!dragging) return; - yaw += (e.clientX - lastX) * 0.006; - pitch = Math.max(-0.5, Math.min(0.75, pitch + (e.clientY - lastY) * 0.004)); - lastX = e.clientX; - lastY = e.clientY; - spin = 0; // once the visitor takes control, stop drifting under them - }; - const onUp = (e: PointerEvent) => { - dragging = false; - canvas.classList.remove('is-dragging'); - canvas.releasePointerCapture?.(e.pointerId); - }; - canvas.addEventListener('pointerdown', onDown); - window.addEventListener('pointermove', onMove); - window.addEventListener('pointerup', onUp); - cleanups.push(() => { - canvas.removeEventListener('pointerdown', onDown); - window.removeEventListener('pointermove', onMove); - window.removeEventListener('pointerup', onUp); - }); - - // ── Hover: raycast to find which beam is under the pointer. - const ray = new THREE.Raycaster(); - const ndc = new THREE.Vector2(); - let hovered: string | null = null; - const onHover = (e: PointerEvent) => { - const r = canvas.getBoundingClientRect(); - ndc.x = ((e.clientX - r.left) / r.width) * 2 - 1; - ndc.y = -((e.clientY - r.top) / r.height) * 2 + 1; - ray.setFromCamera(ndc, camera); - const hit = ray.intersectObjects(meshes.map((m) => m.mesh), false)[0]; - const k = hit ? (hit.object.userData.key as string) : null; - if (k !== hovered) { - hovered = k; - onActive(k); - } - }; - canvas.addEventListener('pointermove', onHover); - cleanups.push(() => canvas.removeEventListener('pointermove', onHover)); - - // ── Render at the element's real size; the pixel-art downscale is gone. - const resize = () => { - const r = canvas.getBoundingClientRect(); - if (r.width < 2) return; - renderer.setPixelRatio(Math.min(MAX_DPR, window.devicePixelRatio || 1)); - renderer.setSize(r.width, r.height, false); - camera.aspect = r.width / r.height; - camera.updateProjectionMatrix(); - }; - resize(); - const ro = new ResizeObserver(resize); - ro.observe(canvas); - cleanups.push(() => ro.disconnect()); - - // ── Pause when offscreen: a rAF loop behind the fold is wasted battery. - let visible = true; - const vio = new IntersectionObserver((es) => { visible = es.some((e) => e.isIntersecting); }); - vio.observe(canvas); - cleanups.push(() => vio.disconnect()); - - const project = new THREE.Vector3(); - let last = 0; - const frame = (now: number) => { - if (disposed) return; - raf = requestAnimationFrame(frame); - if (!visible) return; - // ~30fps is plenty for a slow rotation and halves the GPU work. - if (now - last < 33) return; - const dt = Math.min(0.05, (now - last) / 1000); - last = now; - - if (!dragging) yaw += spin * dt; - root.rotation.y = yaw; - root.rotation.x = pitch; - - // HOVER RESPONSE, eased rather than switched. Three things move together, which is what - // makes it feel like an instrument answering instead of a colour swap: - // · the hovered beam LIFTS off the ground plane and scales out slightly; - // · it brightens toward paper while the others dim toward the base colour; - // · the whole gauge fades up, so the measuring frame is legible while you read a value. - for (const m of meshes) { - const active = m.key === hovered; - m.hover += ((active ? 1 : 0) - m.hover) * Math.min(1, dt * 9); - m.mesh.position.y = m.hover * 0.12; - const sc = 1 + m.hover * 0.05; - m.mesh.scale.setScalar(sc); - (m.mesh.material as any).color.copy(m.base).lerp(paper, m.hover * 0.55); - (m.mesh.material as any).opacity = m.zero ? 0.42 + m.hover * 0.4 : 1; - if (m.edge) m.edge.position.y = m.hover * 0.12; - } - gaugeGlow += ((hovered ? 1 : 0) - gaugeGlow) * Math.min(1, dt * 6); - for (const child of gauge.children) { - const mat = (child as any).material; - if (mat && typeof mat.opacity === 'number') { - mat.opacity = (mat.userData.baseOpacity ?? mat.opacity) * (1 + gaugeGlow * 0.9); - } - } - - // Labels ride the geometry: project each tip and hand normalised coords to the page. - for (const m of meshes) { - project.copy(m.tip).applyMatrix4(root.matrixWorld).project(camera); - placeLabel(m.key, (project.x + 1) / 2, (-project.y + 1) / 2); - } - renderer.render(scene, camera); - }; - raf = requestAnimationFrame(frame); - - return () => { - disposed = true; - cancelAnimationFrame(raf); - for (const c of cleanups) c(); - for (const m of meshes) { - m.mesh.geometry.dispose(); - (m.mesh.material as any).dispose(); - } - ramp.dispose(); - renderer.dispose(); - }; - } -} - -/** A beam as an extruded wedge: narrow at the origin, `halfWidth` at the tip, with real - * thickness so the toon ramp has facets to band. */ -function beamGeometry(b: SceneBeam) { - const root = 0.02; - const th = Math.max(0.018, b.halfWidth * 0.34); // thickness scales with the loading - const ca = Math.cos(b.azimuth), sa = Math.sin(b.azimuth); - const ce = Math.cos(b.elevation), se = Math.sin(b.elevation); - // axis along the beam, and a horizontal perpendicular for the width - const ax = { x: sa * ce, y: se, z: ca * ce }; - const px = { x: ca, y: 0, z: -sa }; - const up = { - x: px.y * ax.z - px.z * ax.y, - y: px.z * ax.x - px.x * ax.z, - z: px.x * ax.y - px.y * ax.x, - }; - const P = (along: number, across: number, thick: number) => [ - ax.x * along + px.x * across + up.x * thick, - ax.y * along + px.y * across + up.y * thick, - ax.z * along + px.z * across + up.z * thick, - ]; - const L = b.length; - const verts: number[] = []; - const quad = (a: number[], c: number[], d: number[], e: number[]) => { - verts.push(...a, ...c, ...d, ...a, ...d, ...e); - }; - // eight corners: root/tip × left/right × top/bottom - const rlt = P(0, -root, th), rrt = P(0, root, th), rlb = P(0, -root, -th), rrb = P(0, root, -th); - const tlt = P(L, -b.halfWidth, th), trt = P(L, b.halfWidth, th); - const tlb = P(L, -b.halfWidth, -th), trb = P(L, b.halfWidth, -th); - quad(rlt, rrt, trt, tlt); // top - quad(rrb, rlb, tlb, trb); // bottom - quad(rlb, rlt, tlt, tlb); // left - quad(rrt, rrb, trb, trt); // right - quad(tlt, trt, trb, tlb); // tip cap - const g = new THREE.BufferGeometry(); - g.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3)); - g.computeVertexNormals(); - return g; -} - -/** A thin bright strip along the beam's top leading edge. */ -function beamEdgeGeometry(b: SceneBeam) { - const th = Math.max(0.018, b.halfWidth * 0.34) + 0.004; - const ca = Math.cos(b.azimuth), sa = Math.sin(b.azimuth); - const ce = Math.cos(b.elevation), se = Math.sin(b.elevation); - const ax = { x: sa * ce, y: se, z: ca * ce }; - const px = { x: ca, y: 0, z: -sa }; - const up = { - x: px.y * ax.z - px.z * ax.y, - y: px.z * ax.x - px.x * ax.z, - z: px.x * ax.y - px.y * ax.x, - }; - const P = (along: number, across: number) => [ - ax.x * along + px.x * across + up.x * th, - ax.y * along + px.y * across + up.y * th, - ax.z * along + px.z * across + up.z * th, - ]; - const w = 0.012; - const a = P(0.02, -w), c = P(0.02, w), d = P(b.length, b.halfWidth), e = P(b.length, b.halfWidth - w * 2); - const g = new THREE.BufferGeometry(); - g.setAttribute('position', new THREE.Float32BufferAttribute([...a, ...c, ...d, ...a, ...d, ...e], 3)); - g.computeVertexNormals(); - return g; -} diff --git a/src/lib/feasible.ts b/src/lib/feasible.ts deleted file mode 100644 index a66abfc..0000000 --- a/src/lib/feasible.ts +++ /dev/null @@ -1,269 +0,0 @@ -// src/lib/feasible.ts -// THE FEASIBLE SET, AS ACTUAL LINEAR ALGEBRA. -// -// The owner: "for a feasible set, it can be animated in linear algebra right". -// -// Right, and that is what makes this buildable where "abstract shapes" would have been the sixth rejected -// showpiece. A portfolio constraint is a LINEAR INEQUALITY a·w <= b, which is a half-space. The set of legal -// portfolios is the intersection of all of them — a convex polytope. So "the rules collapse the space" is not -// a metaphor to illustrate: it is a computation. Add a half-space, clip the polytope, measure what is left. -// -// THE GEOMETRY. Three assets with a budget constraint (w1+w2+w3 = 1) and no shorting (wi >= 0) give the -// standard 2-simplex — a triangle. Every further constraint cuts it with a straight line. Drawn in barycentric -// coordinates the triangle is equilateral and undistorted, so the areas the animation shows are the real -// relative areas, not a projection artefact. -// -// WHAT IS HONEST AND WHAT IS SCALED. Three assets cannot carry a real mandate's 5% single-name cap (three -// names at 5% sum to 15%, which is infeasible against a budget of 100%). So the bounds here are loosened to -// fit a three-asset illustration, and the SLIDE SAYS SO. What carries over exactly is the shape of the -// argument: each rule removes a slab of the space, they compound, and the survivors are a small convex -// remainder. The count is what scales — 2,000 constraints in 3,000 dimensions, not five in two. -// -// Pure: no DOM. Deterministic. Unit-tested in tests/feasible.test.ts. - -/** A point in the 2D drawing plane (barycentric projection of the simplex). */ -export interface P2 { - x: number; - y: number; -} - -/** A linear constraint on three weights: a1*w1 + a2*w2 + a3*w3 <= b. */ -export interface Constraint { - /** Short label for the animation. */ - label: string; - /** Why it exists, in one clause — the part that makes it a rule rather than a line. */ - why: string; - a: [number, number, number]; - b: number; -} - -/** The three assets the illustration uses. Named, so the constraints read as real rules. */ -export const ASSETS = ['NVDA', 'BAC', 'Gold'] as const; - -const SQRT3_2 = Math.sqrt(3) / 2; - -/** Simplex vertex positions in the drawing plane: an equilateral triangle, so areas are undistorted. */ -export const SIMPLEX: P2[] = [ - { x: 0, y: 0 }, // w = (1,0,0) — all NVDA - { x: 1, y: 0 }, // w = (0,1,0) — all BAC - { x: 0.5, y: SQRT3_2 }, // w = (0,0,1) — all Gold -]; - -/** Weights -> drawing plane. */ -export function toPlane(w: readonly [number, number, number]): P2 { - return { x: w[1] + 0.5 * w[2], y: w[2] * SQRT3_2 }; -} - -/** Drawing plane -> weights. The inverse of toPlane, so a clipped vertex can be read back as a portfolio. */ -export function toWeights(p: P2): [number, number, number] { - const w3 = p.y / SQRT3_2; - const w2 = p.x - 0.5 * w3; - const w1 = 1 - w2 - w3; - return [w1, w2, w3]; -} - -/** - * A constraint as a half-plane in the drawing plane: n·p <= c. - * - * Substituting w1 = 1 - x - y/sqrt(3), w2 = x - y/sqrt(3), w3 = 2y/sqrt(3) into a·w <= b gives a linear - * inequality in (x, y). Derived rather than fitted, so a new constraint needs no hand-tuned line. - */ -export function toHalfPlane(c: Constraint): { nx: number; ny: number; cc: number } { - const [a1, a2, a3] = c.a; - // w1 = 1 - x - y/sqrt(3); w2 = x - y/sqrt(3); w3 = 2y/sqrt(3) - const invS3 = 1 / Math.sqrt(3); - const nx = -a1 + a2; - const ny = -a1 * invS3 - a2 * invS3 + a3 * 2 * invS3; - const cc = c.b - a1; - return { nx, ny, cc }; -} - -/** Signed slack of a point against a constraint: <= 0 means satisfied. */ -export function slack(p: P2, c: Constraint): number { - const h = toHalfPlane(c); - return h.nx * p.x + h.ny * p.y - h.cc; -} - -/** - * Clip a convex polygon by a half-plane (Sutherland–Hodgman). - * - * Textbook, and chosen because it is exact for convex input and cannot produce a self-intersecting result — - * which matters when the OUTPUT AREA is the number the slide quotes. A clipper that occasionally produced a - * bow-tie would report a meaningless area and nobody would notice by looking. - */ -export function clip(poly: readonly P2[], c: Constraint): P2[] { - if (poly.length === 0) return []; - const out: P2[] = []; - for (let i = 0; i < poly.length; i++) { - const cur = poly[i]; - const next = poly[(i + 1) % poly.length]; - const sCur = slack(cur, c); - const sNext = slack(next, c); - const inCur = sCur <= 1e-12; - const inNext = sNext <= 1e-12; - - if (inCur) out.push(cur); - if (inCur !== inNext) { - // The edge crosses the boundary: add the exact crossing point. - const t = sCur / (sCur - sNext); - out.push({ x: cur.x + (next.x - cur.x) * t, y: cur.y + (next.y - cur.y) * t }); - } - } - return dedupe(out); -} - -/** Drop points that coincide, which clipping can produce at a vertex touch. */ -function dedupe(poly: readonly P2[]): P2[] { - const out: P2[] = []; - for (const p of poly) { - const last = out[out.length - 1]; - if (!last || Math.hypot(last.x - p.x, last.y - p.y) > 1e-9) out.push(p); - } - if (out.length > 1) { - const first = out[0]; - const last = out[out.length - 1]; - if (Math.hypot(first.x - last.x, first.y - last.y) < 1e-9) out.pop(); - } - return out; -} - -/** Shoelace area, always non-negative. */ -export function area(poly: readonly P2[]): number { - if (poly.length < 3) return 0; - let s = 0; - for (let i = 0; i < poly.length; i++) { - const a = poly[i]; - const b = poly[(i + 1) % poly.length]; - s += a.x * b.y - b.x * a.y; - } - return Math.abs(s) / 2; -} - -/** Centroid, for placing a label inside whatever is left. */ -export function centroid(poly: readonly P2[]): P2 { - if (!poly.length) return { x: 0.5, y: SQRT3_2 / 3 }; - let x = 0; - let y = 0; - for (const p of poly) { - x += p.x; - y += p.y; - } - return { x: x / poly.length, y: y / poly.length }; -} - -/** - * THE CONSTRAINTS, in the order the animation applies them. - * - * Each is a genuine rule of the kind a real mandate carries, written as a linear inequality. The bounds are - * loosened for three assets (see the file header) and the slide says so; the KIND of each rule, and the fact - * that it removes a slab of the space, is exact. - */ -export const CONSTRAINTS: Constraint[] = [ - { - label: 'No name above 45%', - why: 'One position must not be able to end the fund.', - a: [1, 0, 0], - b: 0.45, - }, - { - label: 'Risk budget', - why: 'A linear proxy for variance: the risky names cost more of the budget than the safe one.', - a: [0.55, 0.30, 0.08], - b: 0.30, - }, - { - // Tightened from 12% to 20%: at 12% this rule removed barely 1% of the space (46.7% -> 45.5%), which made - // it look like decoration in the animation even though it is a real rule. A constraint worth drawing has - // to visibly bite, and 20% is the more realistic floor anyway. - label: 'Hold some insurance', - why: 'A floor on the defensive asset, so a bad week cannot take everything.', - a: [0, 0, -1], - b: -0.20, - }, - { - label: 'Turnover band', - why: 'You are already at 30% NVDA; moving further than 20% in one step costs more than it is worth.', - a: [-1, 0, 0], - b: -0.10, - }, - { - // Tightened from 50% to 35%: the point of the last step is that the survivors are a SLIVER, and at 50% - // the sequence ended with a quarter of the space still legal — a weak punchline for "most of what you - // want is forbidden". - label: 'Financials cap', - why: 'Sector limit, so one macro call cannot dominate the book.', - a: [0, 1, 0], - b: 0.35, - }, -]; - -/** - * THE SLAB constraint `n` removes — what was legal before it and is not legal after. - * - * This is the piece the slide was missing. Its copy says "every rule is a straight line through the space of - * legal portfolios; each one removes a slab", and the drawing showed only the survivor: the reader saw a shape - * get smaller without ever seeing the cut that did it, so the sentence was doing work the picture should do. - * - * Computed as the complement, by clipping the previous polygon with the constraint FLIPPED. Negating `a` and - * `b` turns `a·w <= b` into `a·w >= b`, and the intersection of the old region with that is exactly the part - * the rule deletes. Done this way the slab cannot disagree with the survivor — they are clipped from the same - * polygon by the same routine, so `area(slab) + area(survivor) === area(before)` holds by construction, and - * the test asserts it. - */ -export function removedBy(n: number): P2[] { - if (n < 1 || n > CONSTRAINTS.length) return []; - const before = feasibleAfter(n - 1); - const c = CONSTRAINTS[n - 1]; - const flipped: Constraint = { - ...c, - a: [-c.a[0], -c.a[1], -c.a[2]], - b: -c.b, - }; - return clip(before, flipped); -} - -/** The polytope after applying the first `n` constraints to the simplex. */ -export function feasibleAfter(n: number): P2[] { - let poly: P2[] = [...SIMPLEX]; - for (let i = 0; i < Math.min(n, CONSTRAINTS.length); i++) { - poly = clip(poly, CONSTRAINTS[i]); - } - return poly; -} - -/** Area remaining after each step, as a fraction of the unconstrained simplex — the number the slide quotes. */ -export function areaSeries(): number[] { - const full = area(SIMPLEX); - const out: number[] = []; - for (let n = 0; n <= CONSTRAINTS.length; n++) { - out.push(full > 0 ? area(feasibleAfter(n)) / full : 0); - } - return out; -} - -/** Does a portfolio satisfy the first `n` constraints? Used by tests and by the label logic. */ -export function satisfies(w: readonly [number, number, number], n: number): boolean { - const p = toPlane(w); - for (let i = 0; i < Math.min(n, CONSTRAINTS.length); i++) { - if (slack(p, CONSTRAINTS[i]) > 1e-9) return false; - } - return true; -} - -/** The line a constraint draws across the drawing plane, clipped to the simplex — for showing the cut itself - * rather than only its effect. Returns null when the line misses the triangle entirely. */ -export function cutLine(c: Constraint): [P2, P2] | null { - const h = toHalfPlane(c); - const pts: P2[] = []; - for (let i = 0; i < SIMPLEX.length; i++) { - const a = SIMPLEX[i]; - const b = SIMPLEX[(i + 1) % SIMPLEX.length]; - const sa = h.nx * a.x + h.ny * a.y - h.cc; - const sb = h.nx * b.x + h.ny * b.y - h.cc; - if ((sa <= 0 && sb > 0) || (sa > 0 && sb <= 0)) { - const t = sa / (sa - sb); - pts.push({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }); - } - } - return pts.length >= 2 ? [pts[0], pts[1]] : null; -} diff --git a/src/lib/pathspace.ts b/src/lib/pathspace.ts deleted file mode 100644 index 7b92d1b..0000000 --- a/src/lib/pathspace.ts +++ /dev/null @@ -1,186 +0,0 @@ -// src/lib/pathspace.ts -// THE MULTI-PERIOD FEASIBLE SET — a set of SEQUENCES, not a set of points. -// -// The owner's correction, and it is the right one: "triangle might not be enough actually. for single period -// might be yes. for multi period we are shrinking many potential paths right?" -// -// Exactly. lib/feasible.ts draws the feasible set of ONE weight vector: a convex polygon on the 2-simplex. -// That is the whole story for a single decision. But the field is multi-PERIOD, and the object being -// constrained is a whole sequence (w_0, w_1, ..., w_T). Constraints do two different things to it: -// -// 1. THEY COMPOUND. A per-period rule that leaves a fraction f of splits legal leaves f^T of sequences legal. -// At the site's own fund figure (24 periods, 20% legal per period) that is 1.7e-17 — one in sixty -// quadrillion. No discretisation is needed for that number; it is just arithmetic. -// -// 2. THEY COUPLE PERIODS, which is the deeper point and the one no single-period picture can show. A turnover -// band says where you may go NEXT depends on where you ARE. So two books holding the same thing today have -// different futures if they arrived differently, the problem stops being separable, and choosing period by -// period is no longer valid. That is precisely why the next slide needs dynamic programming. -// -// MEASURED, and it corrected my own first guess: at equal per-step severity a coupled rule and an uncoupled one -// survive within a factor of ~1.4 of each other, so "coupling shrinks it more" is NOT the claim. The claim is -// structural — the surviving set depends on where you stand. countFuturesFrom() below is the function that -// makes that visible, and the test pins it. -// -// DISCRETISATION IS A MODELLING CHOICE, and the slide says so. Exposure is cut into LEVELS bands and the horizon -// into PERIODS steps, because a continuum of paths cannot be drawn or counted. The COMPOUNDING figure is exact; -// the path counts are exact given this grid. Both are honest as long as the grid is stated, which it is. -// -// Deliberately a different grid and a different drawing from lib/bellman.ts, which the Method slide uses: that -// one is a value lattice with an optimal route traced through it, and this one is a thicket of candidate paths -// being combed away. Same family of object, opposite question — "what is allowed" versus "what is best". -// -// Pure: no DOM. Unit-tested in tests/pathspace.test.ts. - -/** Exposure bands. Five is the largest grid whose free path count (625) a reader can still accept as countable. */ -export const PATH_LEVELS = 5; - -/** Decision points in the illustrated horizon. */ -export const PATH_PERIODS = 4; - -/** A path is one level index per period — a sequence of decisions. */ -export type Path = number[]; - -/** - * A rule on a whole SEQUENCE. - * - * `kind` records what sort of rule it is, because the distinction is the slide's argument: - * - 'step' couples adjacent periods (a turnover band). It is why the problem is not separable. - * - 'level' applies to every period independently (a floor or a cap). It compounds but does not couple. - * - 'window' looks across several periods at once (no long run at full risk). Coupling, over a longer span. - */ -export interface PathRule { - label: string; - why: string; - kind: 'step' | 'level' | 'window'; - test: (p: Path) => boolean; -} - -/** - * THE RULES, in the order the slide applies them, and each one is a real category of mandate term. - * - * They are ordered so that the two COUPLING rules bracket the two per-period ones: the reader meets the idea - * that periods constrain each other first, since that is the part a single-period picture cannot express. - */ -export const PATH_RULES: PathRule[] = [ - { - label: 'Move one band a month at most', - why: 'Turnover costs money and moves the price, so a mandate caps how far you may travel in one step. Where you can go next now depends on where you are.', - kind: 'step', - test: (p) => p.every((v, i) => i === 0 || Math.abs(v - p[i - 1]) <= 1), - }, - { - label: 'Never below the insurance floor', - why: 'A floor on the defensive holding, every single month, so one bad week cannot take everything.', - kind: 'level', - test: (p) => p.every((v) => v >= 1), - }, - { - label: 'Never above the leverage cap', - why: 'Borrowed money is capped by the prospectus, whatever the opportunity looks like.', - kind: 'level', - test: (p) => p.every((v) => v <= PATH_LEVELS - 2), - }, - { - label: 'No two months in a row at full risk', - why: 'A drawdown limit measured over a window: after a run at the top you must take risk off, whether you want to or not.', - kind: 'window', - test: (p) => p.every((v, i) => !(i > 0 && v === PATH_LEVELS - 2 && p[i - 1] === PATH_LEVELS - 2)), - }, -]; - -/** Every sequence the grid allows before any rule is applied: LEVELS^PERIODS of them. */ -export function allPaths(levels = PATH_LEVELS, periods = PATH_PERIODS): Path[] { - const out: Path[] = []; - const walk = (acc: number[]) => { - if (acc.length === periods) { out.push([...acc]); return; } - for (let l = 0; l < levels; l++) { acc.push(l); walk(acc); acc.pop(); } - }; - walk([]); - return out; -} - -/** The sequences that survive the first `n` rules. n = 0 is the unconstrained set. */ -export function survivors(n: number, paths: readonly Path[] = ALL_PATHS): Path[] { - const rules = PATH_RULES.slice(0, Math.max(0, Math.min(n, PATH_RULES.length))); - return paths.filter((p) => rules.every((r) => r.test(p))); -} - -/** Precomputed once at module load: the grid is fixed, so there is no reason to walk it repeatedly. */ -export const ALL_PATHS: Path[] = allPaths(); - -/** How many sequences survive the first `n` rules. */ -export function countAfter(n: number): number { - return survivors(n).length; -} - -/** Surviving fraction after each rule, starting at 1 for the unconstrained set — the numbers the slide quotes. */ -export function survivalSeries(): number[] { - const total = ALL_PATHS.length; - const out: number[] = [1]; - for (let n = 1; n <= PATH_RULES.length; n++) out.push(countAfter(n) / total); - return out; -} - -/** - * THE PATHS ONE RULE KILLS — those that survived the previous rules and fail this one. - * - * Drawn as the deleted set, the same way lib/feasible's removedBy() draws the slab a constraint removes: the - * slide's claim is about what is TAKEN AWAY, so the drawing has to be able to show it rather than only showing - * the remainder. - */ -export function killedBy(n: number): Path[] { - if (n < 1 || n > PATH_RULES.length) return []; - const before = survivors(n - 1); - const rule = PATH_RULES[n - 1]; - return before.filter((p) => !rule.test(p)); -} - -/** - * HOW MANY LEGAL FUTURES EXIST FROM A GIVEN STARTING BAND, under the first `n` rules. - * - * This is the function that carries the structural argument. If the answer depends on the starting level, the - * problem is not separable — you cannot pick each period's split on its own, because your options tomorrow are - * a consequence of your choice today. Measured on the real grid the answers differ by 3x between the middle - * and the edge, which is the whole reason the Method slide exists. - */ -export function countFuturesFrom(start: number, n: number = PATH_RULES.length): number { - return survivors(n).filter((p) => p[0] === start).length; -} - -/** - * COMPOUNDING over a horizon, in closed form — no grid, no discretisation, exact. - * - * `perPeriod` is the fraction of splits a mandate leaves legal in ONE period (the Rules slide already measures - * this for its own constraint set), and the result is the fraction of SEQUENCES left legal over `periods`. - */ -export function compoundedFraction(perPeriod: number, periods: number): number { - return perPeriod ** periods; -} - -/** Screen position of one decision point. Separated from the drawing so it can be tested. */ -export function projectNode( - period: number, - level: number, - box: { x: number; y: number; w: number; h: number }, - levels = PATH_LEVELS, - periods = PATH_PERIODS, -): [number, number] { - const u = periods > 1 ? period / (periods - 1) : 0.5; - const v = levels > 1 ? level / (levels - 1) : 0.5; - // Level 0 is the LEAST exposure, so it sits at the bottom of the box — up means more risk, as every other - // chart on the site reads. - return [box.x + u * box.w, box.y + (1 - v) * box.h]; -} - -/** A path as an SVG polyline `points` string. */ -export function pathPoints( - p: Path, - box: { x: number; y: number; w: number; h: number }, - levels = PATH_LEVELS, - periods = PATH_PERIODS, -): string { - return p - .map((lvl, t) => projectNode(t, lvl, box, levels, periods).map((n) => n.toFixed(1)).join(',')) - .join(' '); -} diff --git a/src/lib/podCamera.ts b/src/lib/podCamera.ts deleted file mode 100644 index 85e32e1..0000000 --- a/src/lib/podCamera.ts +++ /dev/null @@ -1,264 +0,0 @@ -// src/lib/podCamera.ts -// ONE camera for the whole pod room. Pure maths: no canvas, no DOM, so it is unit-tested. -// -// WHY THIS EXISTS — the measured defect it fixes: -// The previous room drew each system with its own ad-hoc depth trick, and the systems -// disagreed. Extrapolating each one's receding edges to the centre axis gave these -// vanishing points, where a coherent perspective demands they all be identical: -// -// declared horizon in code ...... y = 300 -// floor seams .................. y = 501 -// ceiling seams ................ y = -303 -// desk objects (box()) ......... y = 825 .. 1326, DIFFERENT FOR EVERY OBJECT -// wall fixtures ................ y = infinity (drawn as flat elevation) -// -// Five mutually contradictory spatial systems in one picture. The eye does that -// arithmetic and reports "the shape is off" — which is exactly what the owner said. -// -// The root cause was architectural: the old `box()` helper took a per-object `depth` -// argument and derived its side face from the object's x position, so depth was never a -// property of the ROOM — it was a per-object fudge factor. -// -// The fix: define the room as real volumes in world space and project every corner of -// every object through ONE perspective transform. Objects cannot disagree about depth -// because none of them chooses its own any more. -// -// WORLD SPACE (right-handed, units are metres so the numbers stay human): -// x → right (0 = the room's centre line) -// y → up (0 = the floor) -// z → INTO the scene, away from the viewer (0 = the camera's film plane) -// The camera sits at (0, EYE_Y, 0) looking down +z, with a slight downward pitch so you -// read the desk surface from a seated height. - -export interface Vec3 { x: number; y: number; z: number } -/** A projected point plus the depth that produced it, for painter's-order sorting. */ -export interface Projected { sx: number; sy: number; depth: number; scale: number } - -// ── The room, in metres ───────────────────────────────────────────────────── - -/** Eye height above the floor. A seated person's eye is ~1.2 m. */ -export const EYE_Y = 1.20; -/** How far the back wall is from the viewer. */ -export const WALL_Z = 4.2; -/** Room half-width and ceiling height. */ -export const ROOM_HALF_W = 3.6; -export const CEIL_Y = 2.7; -/** The desk: a slab the viewer is sitting at. */ -export const DESK_Y = 0.74; // standing surface height -export const DESK_Z_NEAR = 1.35; // nearest edge; the pool fills the space in front -export const DESK_Z_FAR = 2.35; // where it meets the console -export const DESK_THICK = 0.06; - -/** Focal length in "screen widths". Larger = longer lens = less dramatic convergence. - * - * 0.62 is a wide-ish view that fits the whole terrace in frame. It was 1.5 (a ~50 mm - * equivalent), which is right for standing INSIDE a room but put the camera so close that - * one monitor filled the entire canvas — the scene is viewed, not inhabited. */ -const FOCAL = 0.62; -/** Downward pitch of the camera, radians. Small — looking level-ish, just enough to read - * the desk surface from a seated height. */ -const PITCH = 0.055; -/** How far BACK the camera sits from the origin of the world, in metres. Pulling it back - * (rather than shrinking the world) keeps every dimension in honest human units. */ -const CAM_Z = -3.4; - -// ── Projection ────────────────────────────────────────────────────────────── - -export interface CameraView { - /** Buffer dimensions this camera projects into. */ - W: number; H: number; - /** The single vanishing point every receding edge converges on. */ - vpx: number; vpy: number; - project(p: Vec3): Projected; - /** Convenience: project a world point and return only screen x/y. */ - xy(p: Vec3): [number, number]; -} - -/** - * Build a camera for a given buffer size. - * - * Because there is exactly one projection, the vanishing point is a PROPERTY of the - * camera rather than something each drawing routine improvises. Every edge parallel to - * +z converges on (vpx, vpy) automatically — that is the whole point. - */ -export function makeCamera(W: number, H: number): CameraView { - const f = FOCAL * W; - const cosP = Math.cos(PITCH), sinP = Math.sin(PITCH); - - const project = (p: Vec3): Projected => { - // Camera space: translate so the eye is the origin, then pitch down about x. - const yc = p.y - EYE_Y; - const zc = p.z - CAM_Z; // the camera stands back from the world origin - const y1 = yc * cosP + zc * sinP; // rotated up-axis - const z1 = zc * cosP - yc * sinP; // rotated depth (always > 0 in this scene) - const z = Math.max(0.05, z1); // never divide by ~0 - const scale = f / z; - return { sx: W * 0.5 + p.x * scale, sy: H * 0.5 - y1 * scale, depth: z, scale }; - }; - - // The vanishing point for the +z direction, derived in closed form rather than by - // projecting a huge coordinate (which loses precision and got this wrong once). - // - // As z → ∞ for a line along +z: y1/z1 → sinP/cosP = tanP, so - // sy → H/2 - f * tanP - // and x/z → 0, so sx → W/2. Every edge parallel to +z converges there, by construction. - const vpy = H * 0.5 - f * (sinP / cosP); - - return { - W, H, - vpx: W * 0.5, - vpy, - project, - xy: (p: Vec3) => { const q = project(p); return [q.sx, q.sy]; }, - }; -} - -// ── Boxes ─────────────────────────────────────────────────────────────────── - -/** An axis-aligned volume in world space. Position is its minimum corner. */ -export interface Box3 { - x: number; y: number; z: number; - w: number; h: number; d: number; -} - -export type Face = 'front' | 'back' | 'left' | 'right' | 'top' | 'bottom'; -/** A face as four projected screen points, plus the depth to sort it by. */ -export interface FaceQuad { - face: Face; - pts: [number, number][]; - depth: number; - /** True when this face turns toward the camera and should be drawn. */ - visible: boolean; -} - -const CORNERS: [number, number, number][] = [ - [0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], // near face (z = min) - [0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1], // far face (z = max) -]; - -// Every face is wound COUNTER-CLOCKWISE as seen from OUTSIDE the box. That consistency is -// what lets one shoelace-sign test decide visibility for all six; hand-written orders that -// disagree produce impossible results (a box showing both its front and its back at once, -// which is what an earlier version of this table did). -// -// Corner indices: 0-3 are the near face (z=min) as [x0y0, x1y0, x1y1, x0y1]; -// 4-7 are the far face (z=max) in the same order. -const FACE_IDX: Record = { - front: [0, 1, 2, 3], // z = min, normal toward -z (the viewer) - back: [5, 4, 7, 6], // z = max, normal toward +z - left: [4, 0, 3, 7], // x = min, normal toward -x - right: [1, 5, 6, 2], // x = max, normal toward +x - top: [3, 2, 6, 7], // y = max, normal toward +y - bottom: [4, 5, 1, 0], // y = min, normal toward -y -}; - -/** - * Project a world box into per-face screen quads, back to front. - * - * Visibility is decided by the SIGN OF THE PROJECTED AREA (the shoelace formula), not by - * an ad-hoc "is this object left or right of centre" test. That is what guarantees a box - * on the left shows its right face and vice versa, with no special cases and no per-object - * tuning — the geometry decides, exactly once. - */ -export function projectBox(cam: CameraView, b: Box3): FaceQuad[] { - const world = CORNERS.map(([i, j, k]) => ({ - x: b.x + i * b.w, y: b.y + j * b.h, z: b.z + k * b.d, - })); - const proj = world.map((p) => cam.project(p)); - - const quads: FaceQuad[] = (Object.keys(FACE_IDX) as Face[]).map((face) => { - const idx = FACE_IDX[face]; - const pts = idx.map((i) => [proj[i].sx, proj[i].sy] as [number, number]); - // Shoelace: negative area means counter-clockwise on screen, i.e. facing the camera - // given the winding order above. - let area = 0; - for (let i = 0; i < 4; i++) { - const [x1, y1] = pts[i], [x2, y2] = pts[(i + 1) % 4]; - area += x1 * y2 - x2 * y1; - } - const depth = idx.reduce((s, i) => s + proj[i].depth, 0) / 4; - // Faces are wound counter-clockwise seen from outside the box (see FACE_IDX). Screen y - // grows DOWNWARD, which mirrors the image and so inverts the shoelace sign: a face - // turned toward the camera comes out NEGATIVE. Verified against all six faces by the - // visibility tests, which caught both an inconsistent winding table and this sign. - return { face, pts, depth, visible: area < 0 }; - }); - - return quads.sort((a, b2) => b2.depth - a.depth); -} - -/** The visible faces only, back to front — what a painter actually draws. */ -export function visibleFaces(cam: CameraView, b: Box3): FaceQuad[] { - return projectBox(cam, b).filter((q) => q.visible); -} - -/** A box's screen-space bounding box, for laying out content or hit areas. */ -export function boxBounds(cam: CameraView, b: Box3): { x: number; y: number; w: number; h: number } { - const pts = CORNERS.map(([i, j, k]) => - cam.project({ x: b.x + i * b.w, y: b.y + j * b.h, z: b.z + k * b.d })); - const xs = pts.map((p) => p.sx), ys = pts.map((p) => p.sy); - const x = Math.min(...xs), y = Math.min(...ys); - return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y }; -} - -// ── Planes: for screens, posters, and anything with content mapped onto it ─── - -/** A rectangle in world space, tilted about its own horizontal axis. - * `tilt` > 0 leans the TOP away from the viewer, which is how a monitor is set. */ -export interface Plane3 { - /** Centre of the rectangle. */ - cx: number; cy: number; cz: number; - w: number; h: number; - /** Rotation about the world y axis (yaw), radians. Positive turns the panel's left edge - * toward the viewer. */ - yaw: number; - /** Rotation about the panel's own x axis (tilt back), radians. */ - tilt: number; -} - -/** The plane's four corners in world space: TL, TR, BR, BL as seen face-on. */ -export function planeCorners(pl: Plane3): Vec3[] { - const cy = Math.cos(pl.yaw), sy = Math.sin(pl.yaw); - const ct = Math.cos(pl.tilt), st = Math.sin(pl.tilt); - const hw = pl.w / 2, hh = pl.h / 2; - // Panel-local axes: u across, v up the face (tilted), then yawed into the world. - const u = { x: cy, y: 0, z: -sy }; - const v = { x: sy * st, y: ct, z: cy * st }; - return [ - [-hw, +hh], [+hw, +hh], [+hw, -hh], [-hw, -hh], - ].map(([a, b]) => ({ - x: pl.cx + u.x * a + v.x * b, - y: pl.cy + u.y * a + v.y * b, - z: pl.cz + u.z * a + v.z * b, - })); -} - -/** Project a plane to a screen quad (TL, TR, BR, BL). */ -export function projectPlane(cam: CameraView, pl: Plane3): { - pts: [number, number][]; depth: number; -} { - const cs = planeCorners(pl).map((p) => cam.project(p)); - return { - pts: cs.map((p) => [p.sx, p.sy] as [number, number]), - depth: cs.reduce((s, p) => s + p.depth, 0) / 4, - }; -} - -/** Screen bounds of a plane — used for the DOM overlay's click targets. */ -export function planeBounds(cam: CameraView, pl: Plane3): { x: number; y: number; w: number; h: number } { - const { pts } = projectPlane(cam, pl); - const xs = pts.map((p) => p[0]), ys = pts.map((p) => p[1]); - const x = Math.min(...xs), y = Math.min(...ys); - return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y }; -} - -/** - * Yaw for a monitor at world x, so the rig curves to face the seated viewer. - * - * A real multi-monitor desk is arranged on an arc: the further a panel sits from the - * centre line, the more it turns inward. Deriving it here means the whole rig shares one - * rule instead of each panel guessing. - */ -export function faceViewer(x: number, z: number, strength = 0.72): number { - return Math.atan2(x, Math.max(0.2, z)) * strength; -} diff --git a/src/lib/podRoom.ts b/src/lib/podRoom.ts deleted file mode 100644 index d333eba..0000000 --- a/src/lib/podRoom.ts +++ /dev/null @@ -1,266 +0,0 @@ -// src/lib/podRoom.ts -// The pod room's LAYOUT, CAMERA and PALETTE — pure data and pure maths, no canvas. The -// drawing lives in podRoomPaint.ts so this half stays unit-testable. -// -// WHY THE MONITOR RECTANGLES LIVE HERE: they are load-bearing twice — the painter draws -// the monitors from them AND the DOM overlay positions its real / diff --git a/src/pages/index.astro b/src/pages/index.astro index acbe398..282b3b3 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -16,16 +16,16 @@ import Deck from '../components/Deck.astro'; --- - + /research and /projects share .descent and must stay free-scrolling reading pages. */}
- + that as a bug, not as emphasis, so all four paper slides now share one palette and follow the theme. */} - + that read it are one surface. Settled piece. */} - - + deleted with it rather than left as dead weight — git holds them if it ever comes back. */}
- + {/* One gesture = one slide. Behaviour lives in lib/deck.ts (pure, tested); this only attaches the + listeners, and only on the homepage — the reading pages stay free-scrolling. */}
diff --git a/src/pages/proto-ladder.astro b/src/pages/proto-ladder.astro deleted file mode 100644 index 7a1ee8e..0000000 --- a/src/pages/proto-ladder.astro +++ /dev/null @@ -1,281 +0,0 @@ ---- -// PROTO LADDER — a diagnostic route, not a design proposal. -// -// Why this exists: the first tune landed BELOW the perceptual threshold. Measured -// against the shipped page, the content zone differed by a mean of 2.5/255 with -// only ~1.5% of pixels changed by more than 8 — literally invisible, which is -// why "I don't see the difference" was the correct read. -// -// You cannot calibrate an effect you can't see. So this page shows the same -// content at every fluid strength (including a deliberately TOO-STRONG ceiling), -// and the paper craft at 1× and 3× so the letterpress and typography are legible -// at all. Pick a level here; that becomes the shipped value. -import BaseLayout from '../layouts/BaseLayout.astro'; -import FluidSky from '../components/proto/FluidSky.astro'; -import { name, roles, phd, rolesSub, bio } from '../data/profile'; - -let bioHtml = bio.text; -for (const phrase of bio.strong) { - bioHtml = bioHtml.replace(phrase, `${phrase}`); -} - -// Notes carry the MEASURED delta from level 0, so the numbers on screen are the -// real ones rather than my guesses. -const LEVELS = [ - { n: 0, label: 'off', note: 'the shipped sky — your current site' }, - { n: 1, label: 'subtle', note: 'op .30 · amp .30 · measured Δ 5.6/255' }, - { n: 2, label: 'medium', note: 'op .48 · amp .40 · measured Δ 13.2/255' }, - { n: 3, label: 'strong', note: 'op .68 · amp .78 · measured Δ 25.8/255' }, - { n: 4, label: 'too much', note: 'op .90 · amp 1.15 · measured Δ 78.5/255 — the ceiling' }, -] as const; ---- - - -
-

Diagnostic

-

Find the level you can actually see

-

- You were right that there was no difference — but it wasn't a taste gap, it was a - bug. The old build composited a mid-grey field with soft-light, - which is near-identity: levels 1–3 all measured 0.3–0.4/255, then - jumped to 51 at level 4. A 110× cliff between neighbours is the signature of an effect - that isn't compositing at all. Two more bugs sat behind it — position: fixed - made all five panels cover the whole viewport, and a stray backtick in a comment silently - broke the shader build. Now the ladder is monotonic: - 5.6 → 13.2 → 25.8 → 78.5. Scroll, then tell me the number that reads as - alive but still restrained. Level 4 is meant to be too much. -

-
- - {LEVELS.map((lv) => ( -
-
- -
- {lv.n} - {lv.label} - {lv.note} -
-
-
- ))} - - -
-

Paper craft · why you couldn't see it

-

It's real, but it lives at 1–2px on text

-

- The letterpress, the raised initial, the rules — all operating at glyph scale while your - eye reads a full-screen composition. Left is actual size; right is the same thing at 3×. -

- -
-
-

actual size

-
-

I—Heights

-
-

{name.first} {name.last}

-

{roles}

-

{phd}  ·  {rolesSub}

-

-
-
- -
-

3× — the craft, legible

-
-

I—Heights

-
-

{name.first} {name.last}

-

{roles}

-

-
-
-
-
- - -
-

force all

- {[0, 1, 2, 3, 4].map((n) => ( - - ))} - -
-
- - - - diff --git a/src/pages/proto-paper.astro b/src/pages/proto-paper.astro deleted file mode 100644 index 4c3efed..0000000 --- a/src/pages/proto-paper.astro +++ /dev/null @@ -1,155 +0,0 @@ ---- -// PROTOTYPE ROUTE — revised after review. Two layers, not three: -// -// base paper editorial craft (letterpress, raised initial, marginalia, -// hanging punctuation, richer paper material) -// accent fluid modulation OVER the Monet gradient — combined, never replacing -// -// The layer stack, bottom → top: -// --descent-grad (untouched) → FluidSky (overlay) → SkyWash → PaperTooth → content -// -// CUT in this revision, all three for measured reasons: -// · MOTES — probed the live canvas: 370 lit pixels out of 3,454,560 (0.011%) -// at max alpha 35/255, mean 6.7/255. depthGate + the light-theme gain + the -// alpha multiplier stacked into nothing. The feature was running and -// mathematically invisible, so it went rather than getting rescued. -// · FOLIOS (I/II/III running heads) — the left-margin Toc already names the -// section AND tracks it live; the folio was a static duplicate. -// · PaperMountains — with motes and the folio gone the wrapper added nothing, -// so Mountains is imported verbatim ("the mountain is fine"). -// -// Fluid now defaults to strength 1: measured Δ 5.6/255 — visible, but the -// lightest rung that is. Level 2+ read as too strong. -import BaseLayout from '../layouts/BaseLayout.astro'; -import Toc from '../components/Toc.astro'; -import SkyWash from '../components/SkyWash.astro'; -import PaperTooth from '../components/proto/PaperTooth.astro'; -import FluidSky from '../components/proto/FluidSky.astro'; -import PaperHeights from '../sections/proto/PaperHeights.astro'; -import PaperInterlude from '../sections/proto/PaperInterlude.astro'; -import Mountains from '../sections/Mountains.astro'; -// Ground (the terminal) was deleted from the site — see index.astro. This prototype route predates -// that and only ever included it to judge the paper treatment against a full-length page. -import Signature from '../sections/Signature.astro'; ---- - - -
- - - - - - -
- - - - -
-

layers

- - -

warp

-
- {[0, 1, 2, 3, 4].map((n) => ( - - ))} -
-
-
- - - - diff --git a/src/pages/proto-showpiece.astro b/src/pages/proto-showpiece.astro deleted file mode 100644 index 9ef5efb..0000000 --- a/src/pages/proto-showpiece.astro +++ /dev/null @@ -1,339 +0,0 @@ ---- -// PROTO SHOWPIECE — a kill gate, not a design proposal. -// -// WHY THIS ROUTE EXISTS: three attempts at the homepage's bottom section were built and -// rejected, and each one was only judgeable AFTER it was finished. That is the expensive -// order. These are the three surviving candidate concepts as static build-time SVG — the -// same geometry that would go to the GPU, so the composition is representative — shown -// side by side so one look kills or keeps each for the price of one function. -// -// Deliberately honest about what a still frame CANNOT show, per frame, in the notes below. -// A frame that flatters its concept is worse than no frame. -import BaseLayout from '../layouts/BaseLayout.astro'; -import { protoFrame } from '../lib/protoFrames'; -import FactorFan from '../components/FactorFan.astro'; -import { loadings } from '../lib/factorModel'; -import { SIGNAL_WEIGHTS } from '../data/signalWeights'; -import { RUBRIC_PROMPT } from '../lib/signalRubric'; - -// The live model, so the page states the real numbers rather than a description of them. -const ls = loadings(SIGNAL_WEIGHTS.signals); - -const FRAMES = [ - { - id: 'seriation' as const, - n: 1, - title: 'Seriation', - score: 'ranked #1 — mean 21.6 / buildability 8.0', - pitch: 'A linkage tree standing on a correlation floor. The tiles are your real portfolio items; because rows are ordered by similarity, related work collapses into blocks down the diagonal. The six stems rising out of the tree are the six destinations.', - quant: 'Highest — it is the signature figure of the RL-BHRP paper you actually wrote. A quant reads it in one second.', - missing: 'A real build adds a cast shadow from one low light, which is the only thing that welds the tree to the floor as ONE object. SVG cannot cast it, so this frame under-sells the join — the one place it is pessimistic rather than flattering.', - risk: 'It lands as a journal plate: correct, cold, a chart at the foot of an atmospheric page.', - }, - { - id: 'stepwell' as const, - n: 2, - title: 'Stepwell', - score: 'ranked #2 — mean 20.7 / buildability 8.7 (highest)', - pitch: 'The ground opens into a stepped shaft. Six terraces descend inward, each rotated slightly so the corners spiral, with one destination per terrace — so tab order IS descent order, and the site’s own title becomes literal.', - quant: 'Near zero. It says architecture, not portfolio optimization. For a slot whose job is to be memorable to a quant audience, that is a real price, not a rounding error.', - missing: 'Real matte concrete under one shadow-casting light is the most forgiving image in rendering; flat SVG fills cannot show that softness, so this frame is harsher and more diagrammatic than the build would be.', - risk: 'It reads as a swimming pool or a stack of empty picture frames — not theoretical, since an earlier desk already read as a bathtub.', - }, - { - id: 'simplex' as const, - n: 3, - title: 'The Simplex Cage', - score: 'ranked #3 — mean 18.6 / buildability 7.3', - pitch: 'The space of portfolio weights as a wireframe solid. Its six vertices are six pure allocations and six destinations; a live long-only min-variance solution drifts inside and sticks to faces and edges when it goes sparse.', - quant: 'Highest content of the three — the visual IS your PhD subject, and six vertices is the only honest reason for exactly six panels anywhere in the set.', - missing: 'The interior veils and the solver are the whole appeal and neither survives a still: on the page the solution re-solves when you hover a corner, which is mathematics answering rather than a tween.', - risk: 'A slowly rotating translucent polyhedron with glowing labelled nodes is THE WebGL landing-page object of the last four years. It will look dated fast, and it tells the hero terrain’s story twice.', - }, -]; ---- - -
-
- ← back -

Showpiece candidates

-

- Three concepts as static frames, before any 3D is built. Same geometry that would go - to the GPU. Each note says what a still frame cannot show, so nothing here - is flattering itself. -

-

- Flip the theme with the corner nav — both must work. If none of these survives being - looked at, the concept dies here rather than after five sessions. -

-
- - -
-
-

00

-

Factor exposure fan

-

current direction · one asset, many signals

-

- You as a single ticker. The portfolio items are signals that load onto it, so the - object is a factor model — r = α + Σ βk fk + ε — with - each term expanded into one beam. Beam area is that factor’s loading. -

-
-
Where the numbers come from
-
- β = summed evidence score of a factor’s signals ÷ total. Every signal is scored - 1–5 against a fixed rubric by three independent raters; the median ships. 20 - items, 14 exact agreement, 20 within one point, 0 disputes. -
-
Live loadings
-
-
    - {ls.map((l) => ( -
  • - {l.factor.label} - - {l.beta.toFixed(3)} -
  • - ))} -
-
-
Interaction
-
- Drag the fan to rotate it. Hover or focus a beam to isolate that factor and list - the signals loading on it, with each signal’s evidence score. three.js loads only - when the section scrolls into view, so it costs nothing on first paint. -
-
The honest finding
-
- Experience still leads (β {ls.find((l) => l.factor.key === 'experience')!.beta.toFixed(3)}) - because nine items scoring 2 outweigh two research artefacts scoring 4. Writing and - Market reports sit at exactly zero — those pages don’t exist yet, and the model says - so rather than inventing a loading. -
-
-
- - -
- The rubric the scores were made against -
{RUBRIC_PROMPT}
-
-
- -

Earlier candidates

- - {FRAMES.map((f) => ( -
-
-

{String(f.n).padStart(2, '0')}

-

{f.title}

-

{f.score}

-

{f.pitch}

-
-
Quant signal
{f.quant}
-
What this frame can’t show
{f.missing}
-
Biggest risk
{f.risk}
-
-
-
-
- ))} - - -
-
- - diff --git a/src/pages/proto-sketches.astro b/src/pages/proto-sketches.astro index 9716d8c..59e7cc7 100644 --- a/src/pages/proto-sketches.astro +++ b/src/pages/proto-sketches.astro @@ -6,7 +6,7 @@ // src/lib/sketches/, add it to the batch, and it appears here at real width against the real // corpus and the real palette. // -// The rule this formalises (see notes/showpiece.md): A STILL FRAME FIRST. Judge it in one look. +// The rule this formalises: A STILL FRAME FIRST. Judge it in one look. // three.js only after a frame survives. Four of the five rejections were only judgeable once // finished; the one cheap gate that ever worked was a static SVG page. // @@ -20,13 +20,28 @@ import DescentPath from '../components/DescentPath.astro'; const data = sketchData(); const ctx = { w: FRAME.w, h: FRAME.h, pal: PAL, data }; const sketches = BATCH1.map((s) => ({ meta: s, html: s.draw(ctx) })); + +// TWO NOTES ON THE PROPS BELOW. +// +// noindex={true}: see the long version in proto-showpiece.astro. Without it BaseLayout takes its +// other branch and emits a self-referential instead — so this diagnostic route +// was not merely unprotected, it was asking to be indexed. Both of the two proto routes that lacked +// the prop were live and crawlable. tests/protoNoindex.test.ts asserts every src/pages/proto-*.astro +// passes it; tests/distSmoke.test.ts checks the built HTML. +// +// navZone: a `navZone="dark"` used to sit in this list and is DROPPED rather than corrected, because +// "dark" was never a value the prop has. BaseLayout and CornerNav both declare 'scroll' | 'light', +// and the nav script reads `dataset.navMode === 'light' ? 'light' : 'scroll'`, so "dark" fell through +// to 'scroll'. That branch then finds no `.descent` element on this page (
) and pins +// the zone to 'light' anyway — so removing it changes nothing about what renders, and removes an +// invalid literal from the one route that had it. ---
@@ -40,13 +55,13 @@ const sketches = BATCH1.map((s) => ({ meta: s, html: s.draw(ctx) }));

Flip the theme with the corner nav; both must work. Five earlier attempts and their reasons - are recorded in notes/showpiece.md. + are recorded in the commit history.

- + candidate that has moved past the still-frame gate. */}

live · descent path

@@ -116,6 +131,14 @@ const sketches = BATCH1.map((s) => ({ meta: s, html: s.draw(ctx) })); text-transform: uppercase; color: var(--ink-4); } dd { margin: 0; font-size: 14px; line-height: 1.66; color: var(--ink-3); } + /* THESE TWO LOOK DEAD TO A GREP AND ARE NOT — do not delete them. The class comes from + `class={`v-${meta.verdict.call}`}` in the template above, so the literal strings "v-kill" and "v-keep" + appear nowhere else in the repo, and no sketch in BATCH1 has a verdict filled in yet, so they are also + absent from the built HTML. That is the page working as designed: kit.ts declares + `verdict?: { call: 'keep' | 'kill' | 'unjudged' }` and says it is filled in AFTER a sketch has been looked + at, and round-one verdicts were recorded in a design note that has since been deleted. + The first one that is will need these colours. 'unjudged' deliberately has no rule — it keeps the neutral + dd colour, because "not yet decided" should not be styled as a decision. */ dd.v-kill { color: var(--seal); } dd.v-keep { color: var(--ochre); } @media (max-width: 820px) { dl { grid-template-columns: 1fr; gap: 2px 0; } dd { margin-bottom: 10px; } } diff --git a/src/pages/research.astro b/src/pages/research.astro index fe7ba6d..122f1c6 100644 --- a/src/pages/research.astro +++ b/src/pages/research.astro @@ -22,9 +22,9 @@ const stops = researchStops(publications, researchInterests); veilOnLoad={true} navZone="light" > - + the papers (a detail cannot be collapsed before it is parsed). */}
@@ -61,12 +61,12 @@ const stops = researchStops(publications, researchInterests); const headline = p.results?.[0]; return (
- + reachable, announces its expanded state, and works with assistive tech for free. */} - - + first paint whenever JS is available, so there is no flash for real visitors. */}
{p.idea && ( @@ -179,7 +179,7 @@ const stops = researchStops(publications, researchInterests); ); })} - + zero-height box is a keyboard trap on a page that looks empty. */} diff --git a/src/sections/Rules.astro b/src/sections/Rules.astro index 0406114..057e4d3 100644 --- a/src/sections/Rules.astro +++ b/src/sections/Rules.astro @@ -108,7 +108,7 @@ const dpUniverseAt = Math.ceil(ATOMS_EXP / Math.log10(DP_LEVELS));

2 / 3 · the difficulty

- + column. Budgeted at every viewport before it was built: the figure still gets 381px at 1366x768. ══ */}

Now imagine it is not your money.

- + hold four ideas on a single screen — it owns exactly 100vh and has no room for four figures. */}

The same decision, at four sizes Four kinds of rule, all binding at once @@ -134,8 +134,8 @@ const dpUniverseAt = Math.ceil(ATOMS_EXP / Math.log10(DP_LEVELS));

- + {/* ── BEAT 1: THE COST LADDER. Bars are log-scaled and the note says so: the span is six orders of + magnitude, and on a linear axis three of the four bars would be invisible. */}
    {ladder.map((r) => { @@ -167,16 +167,16 @@ const dpUniverseAt = Math.ceil(ATOMS_EXP / Math.log10(DP_LEVELS));

- + {/* ── BEAT 2: THE RULE CATEGORIES, each with the reason it exists. Four KINDS, not four examples, + and every one of them is a real category with a real cause. */}
- + identity. Compiled from character matrices in data/ruleGlyphs.ts, so they are data. */}
    {named.map((c) => { const g = RULE_GLYPHS[c.group as RuleGlyphKey]; @@ -205,11 +205,11 @@ const dpUniverseAt = Math.ceil(ATOMS_EXP / Math.log10(DP_LEVELS));
- + into a solid plaid: free trajectories overlap far less than paths through a shared grid. */}
@@ -230,12 +230,12 @@ const dpUniverseAt = Math.ceil(ATOMS_EXP / Math.log10(DP_LEVELS));

- - + any of which can put you in breach while you fix something else. */}
; })} - + pretending to be mathematics". */}

Solve it the exact way — dynamic programming, working backward from the horizon: @@ -307,8 +307,8 @@ const dpUniverseAt = Math.ceil(ATOMS_EXP / Math.log10(DP_LEVELS));

- + {/* THE FOUR BEATS, now a horizontal band. Each is still a real button with the same freeze-on-click + behaviour; only the direction changed. */}
diff --git a/src/sections/Solve.astro b/src/sections/Solve.astro index 976ebd5..7c0425c 100644 --- a/src/sections/Solve.astro +++ b/src/sections/Solve.astro @@ -63,11 +63,11 @@ const badPeriods = WORLD.tilt.map((t, i) => ({ t, i })).filter(({ t }) => t < 0)

3 / 3 · where to start

- + because it is the thing that blows up. */}

{OPEN_HEADING}

@@ -86,8 +86,8 @@ const badPeriods = WORLD.tilt.map((t, i) => ({ t, i })).filter(({ t }) => t < 0)
- + {/* THE SURFACE. Drawn as a wireframe from both directions; the script reveals it right-to-left, + which is the direction the algorithm actually runs. */} {timeLines.map((line, t) => ( @@ -97,12 +97,12 @@ const badPeriods = WORLD.tilt.map((t, i) => ({ t, i })).filter(({ t }) => t < 0) ))} - + {/* THE MANY: every candidate policy, at the value of the states it passes through. */} {candLines.map((line) => )} - + {/* THE ONE. Revealed last, forward through time. */} {optLine.map(([x, y], i) => ( @@ -117,12 +117,12 @@ const badPeriods = WORLD.tilt.map((t, i) => ({ t, i })).filter(({ t }) => t < 0)

A world small enough to solve exactly…

- + thirteenth rib at the horizon where value is zero by construction. */}
{LEVELS} exposure levels at {PERIODS} dates — {PERIODS * LEVELS} decisions, {PERIODS * LEVELS * LEVELS} comparisons, and {LEVELS}{PERIODS} = 2.8×1011 exposure paths the recursion never visits. It is @@ -134,10 +134,10 @@ const badPeriods = WORLD.tilt.map((t, i) => ({ t, i })).filter(({ t }) => t < 0)
- + attack rather than a numbered beat. */}
    {METHODOLOGIES.map((m, i) => (
  1. @@ -198,7 +198,14 @@ const badPeriods = WORLD.tilt.map((t, i) => ({ t, i })).filter(({ t }) => t < 0) :global(html.solve-js) .so-panel:first-child { opacity: 0; pointer-events: none; } :global(html.solve-js) .so-panel.is-on { opacity: 1; pointer-events: auto; } @media (prefers-reduced-motion: no-preference) { - .so-panel { transition: opacity 260ms ease; } + /* .5s, MATCHING Choice and Rules, and that is the whole fix. The owner: "where to start doesn't have the + smooth inter tab animation as other slides." He was right, and the cause was not a missing transition — + this one was 260ms while `.ch-panel` and `.ru-panel` are both `.5s ease`. Nearly twice as fast, on a + crossfade between two stacked panels, reads as a pop rather than a dissolve: at 260ms the outgoing text is + still legible when the incoming text is already solid, so the eye sees two states rather than one moving + between them. The stacking was never the problem (.so-textbox is a one-cell grid, like the others). + Three slides that share an interaction must share its timing, or the third one feels broken. */ + .so-panel { transition: opacity .5s ease; } } /* THE OPENNESS STATEMENT, directly under the heading — the sentence the slide rests on, so it is set above @@ -297,19 +304,10 @@ const badPeriods = WORLD.tilt.map((t, i) => ({ t, i })).filter(({ t }) => t < 0) font-size: clamp(20px, 1.8vw, 30px); line-height: 1.24; color: var(--s-ink); margin: 0 0 clamp(14px, 2vh, 22px); max-width: 32ch; letter-spacing: -0.008em; } - .so-body { - font-size: clamp(13.5px, 1vw, 15px); line-height: 1.68; color: var(--s-soft); - margin: 0 0 12px; max-width: 46ch; - } - .so-body em { font-style: italic; color: var(--s-ink); } - - - .so-tools { list-style: none; margin: clamp(14px, 2vh, 20px) 0 0; padding: 0; display: grid; gap: 7px; } - .so-tools li { display: grid; gap: 1px; } - .so-tool-name { - font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.06em; color: var(--s-accent); - } - .so-tool-role { font-size: 12px; line-height: 1.45; color: var(--s-soft); max-width: 46ch; } + /* (.so-body and the .so-tools list are gone with their markup. The slide used to carry a paragraph of prose + and a named list of "the tools this uses" beside the lattice; both went when the copy moved into the + term-definition strip and the lattice took the whole figure column. Deleted rather than parked, so the + sheet only describes elements this section actually renders.) */ /* Tall enough for the surface to read as a surface: at 340px the lattice's depth collapsed and the twelve time-slices crowded into a band. */ diff --git a/src/sections/Story.astro b/src/sections/Story.astro index 0726f21..0c607be 100644 --- a/src/sections/Story.astro +++ b/src/sections/Story.astro @@ -30,7 +30,7 @@ import { STORY } from '../data/story'; ---
    - + that you can read the claim and check the picture at the same time. */}
    - + {/* The graph, on the same paper. Passed in as a slot so this component owns the surface and the + ink tokens, and DescentPath stays a self-contained piece that inherits them. */}
    @@ -57,7 +57,7 @@ import { STORY } from '../data/story'; ))}
    - + this column again. */}
@@ -250,30 +250,11 @@ import { STORY } from '../data/story'; } .story-body:last-child { margin-bottom: 0; } - /* The figures: measured readings, mono, so they read as instrument output rather than pull-quotes. - Three across needed ~1300px; in the 720px prose panel they wrap to a responsive grid instead of - being crushed to 220px each. */ - .story-figures { - display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: clamp(20px, 2.4vh, 32px) clamp(20px, 2vw, 40px); - margin: clamp(28px, 4vh, 52px) 0 0; padding-top: clamp(22px, 3vh, 40px); - border-top: 1px solid var(--story-edge); - } - .story-figure dt { - font-family: var(--font-mono); font-size: clamp(26px, 3vw, 40px); - color: var(--story-accent); line-height: 1; - } - .story-figure dd { - margin: 14px 0 0; font-size: 14px; line-height: 1.6; color: var(--story-ink-faint); - } - .story-figure-label { - display: block; font-family: var(--font-mono); font-size: 10.5px; letter-spacing: 0.18em; - text-transform: uppercase; color: var(--story-ink-soft); margin-bottom: 7px; - } - @media (max-width: 700px) { - .story-figures { grid-template-columns: 1fr; gap: 4vh 0; } - } - - /* (.story-tail is gone with the element — see the markup comment. Anything placed at the foot of this - column is unreachable in the deck, so the rule is deleted rather than left for someone to reuse.) */ + /* (.story-tail and the .story-figures dl are both gone with their elements. The tail: anything placed at + the foot of this column is unreachable in the deck — see the markup comment. The figures were three mono + "instrument readings" under the prose, dropped when the section became one narrow column beside the + graph. Both rule sets are deleted rather than left for someone to reuse, because a rule with no markup + is a claim about a layout that no longer exists. Note that .story-figure-slot, just above, is LIVE and + unrelated: `.story-figure` never matched it — a class selector matches the whole class name, not a + prefix — which is exactly why the dead rules were invisible for so long. */ diff --git a/src/sections/Work.astro b/src/sections/Work.astro index 61f569b..00798eb 100644 --- a/src/sections/Work.astro +++ b/src/sections/Work.astro @@ -9,8 +9,10 @@ // WHY THIS IS NOT A RESUME even though it is a list. A résumé lists credentials — title, employer, dates, // reverse-chronological — and claims authorization. This lists ARTEFACTS and where to read them. The // distinction is that every row here is a thing a reader can go and check. +import { getCollection } from 'astro:content'; import { publications, projects } from '../data/profile'; import { WRITINGS_PROMISE, WRITINGS_TOPICS } from '../data/making'; +import { KINDS } from '../data/writing'; import PlushCow from '../components/PlushCow.astro'; import Signature from './Signature.astro'; @@ -18,6 +20,41 @@ const featured = publications.find((p) => p.featured); const others = publications.filter((p) => !p.featured); const shownProjects = projects.slice(0, 2); +// ── THE WRITING COLUMN IS DERIVED, NOT PROMISED. This column said "coming" and carried +// WRITINGS_PROMISE while /writing/favourite-quotes/ was published, in the nav and live — the homepage was +// telling visitors the writing did not exist yet. The cause was that its state came from a hand-written +// constant, so publishing a piece could not possibly update it: nothing linked the two. +// +// Reading the collection is what makes that class of lie impossible. `getCollection('writing')` filtered on +// `!draft` is EXACTLY how src/pages/writing.astro decides what exists, so the homepage and /writing cannot +// disagree about whether there is any writing — and the next piece is still just a file, with no code change +// here. The `coming` state below is kept and is correct when the folder is genuinely empty; it is now a +// consequence of the content rather than a claim about it. +const writings = (await getCollection('writing')) + .filter((e) => !e.data.draft) + // Newest first. Deliberately the same rule as sorted() in data/writing.ts — a plain compare on the + // schema-normalised YYYY-MM-DD, so lexical order IS date order — and deliberately NOT featured-first, + // because /writing ignores `featured` when ordering and two orderings of one shelf read as two shelves. + .sort((a, b) => (a.data.date < b.data.date ? 1 : a.data.date > b.data.date ? -1 : 0)); + +// Two rows, matching the projects column: this slide points, it does not enumerate. +const shownWritings = writings.slice(0, 2); + +// The section name a piece belongs to, taken from the same taxonomy /writing renders its headings from, so a +// piece cannot be filed under "Miscellaneous" there and something else here. The fallback is unreachable while +// the schema's `kind` enum and KINDS agree — a test asserts they do — and is there so a mismatch degrades to +// the raw key instead of `undefined`. +const kindLabel = (key: string) => KINDS.find((k) => k.key === key)?.label ?? key; + +// Month and year, which is the right grain for a running collection: a day is more precision than this line +// can use, and a bare year would make an August piece look like last winter's. The `T00:00:00Z` + UTC pair is +// what keeps it honest — parsed as local time, a YYYY-MM-DD date renders one day early west of Greenwich, and +// that is enough to move a 1st-of-the-month piece into the previous month. +const monthYear = (iso: string) => + new Date(`${iso}T00:00:00Z`).toLocaleDateString('en-GB', { + year: 'numeric', month: 'long', timeZone: 'UTC', + }); + // THE TWO HEADLINE NUMBERS, recovered from the shipped homepage. This slide replaced a "Selected writing" glass // card that carried them, and dropping them cost the homepage every number on it — which on a quant's site is the // wrong thing to spend for breadth: the Sharpe against its two baselines is the most checkable claim here, and it @@ -26,7 +63,7 @@ const shownProjects = projects.slice(0, 2); const compounded = featured?.results?.[0]; const sharpe = featured?.metrics?.rows.find((r) => /sharpe/i.test(r.metric)); --- - + register the rest of the site is written in. */}

Appendix

- + {/* PUBLICATIONS: the paper by name, so a reader knows what they are opening. */}

Publications

{featured && ( @@ -51,8 +88,8 @@ const sharpe = featured?.metrics?.rows.find((r) => /sharpe/i.test(r.metric)); {featured.subject} · {featured.venue} {featured.arxivId && <> · arXiv:{featured.arxivId}}

- + {/* Out-of-sample, against two baselines, both named — a number with nothing to compare it to is + decoration. Full table on /research. */}
{compounded && (
@@ -78,19 +115,39 @@ const sharpe = featured?.metrics?.rows.find((r) => /sharpe/i.test(r.metric)); Read the research
- + {/* WRITINGS: whatever is actually published, and a promise ONLY while nothing is. + Two states, and which one renders is decided by src/content/writing/ rather than by an editor + remembering to come back here. Published pieces get the same treatment as the papers and the + projects — name, one line, a real link — because that is this slide's whole contract: every row is + a thing the reader can go and check. No count and no "1 piece" tag: this panel is written without + counts on purpose (see the file header), and the rows themselves are the better answer to "how + much is there". */}

Writing

- coming + {writings.length === 0 && coming}
-

{WRITINGS_PROMISE}

-
    - {WRITINGS_TOPICS.slice(0, 3).map((t) =>
  • {t}
  • )} -
+ {writings.length === 0 ? ( + +

{WRITINGS_PROMISE}

+
    + {WRITINGS_TOPICS.slice(0, 3).map((t) =>
  • {t}
  • )} +
+
+ ) : ( + + {shownWritings.map((w) => ( +
+

{w.data.title}

+

{kindLabel(w.data.kind)} · {monthYear(w.data.date)}

+
+ ))} + Read the writing +
+ )}
- + {/* PROJECTS: last, and briefest — for a quant reader this is supporting evidence. */}

Projects

{shownProjects.map((p) => ( @@ -103,11 +160,11 @@ const sharpe = featured?.metrics?.rows.find((r) => /sharpe/i.test(r.metric));
- + page's closing aside. */}
@@ -115,10 +172,10 @@ const sharpe = featured?.metrics?.rows.find((r) => /sharpe/i.test(r.metric));
- + near-black: the page still ENDS in the dark, which is the whole point of the descent. */} diff --git a/src/sections/proto/PaperInterlude.astro b/src/sections/proto/PaperInterlude.astro deleted file mode 100644 index 42a0c35..0000000 --- a/src/sections/proto/PaperInterlude.astro +++ /dev/null @@ -1,105 +0,0 @@ ---- -// PaperInterlude (PROTOTYPE) — the tagline as a true editorial PULL-QUOTE. -// Copy of sections/Interlude.astro with print craft added: -// · real HANGING PUNCTUATION — the opening quote hangs OUTSIDE the text block's -// left edge (absolutely positioned, not an inline glyph that would shove the -// first line right) -// · an attribution rule beneath, the way a printed pull-quote is closed -// -// CUT: the folio ("II — INTERLUDE"). The Toc already names and tracks the -// section, so the folio just restated it statically. -// -// The two doorway links (/research, /art) and their distinct hover affordances -// are preserved exactly — they're shipped behaviour, not decoration. ---- -
-
- - -
- A researcher by day, an artist by night, and a mathematician at heart. -
-
- - the descent -
-
-
- - diff --git a/src/styles/global.css b/src/styles/global.css index 1983db1..8a1d5f5 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -153,65 +153,77 @@ body { background: var(--bg); color: var(--ink-1); font-family: var(--font-body) flex-direction: column; justify-content: center; } -/* ── THE NAV MUST NOT LAND ON A HEADLINE, which on phones it did — on every slide. - The corner nav is a fixed capsule at the top; the deck rests with a slide's top at the viewport top; and a - paper panel puts its kicker and headline in the first ~90px. So at rest, on a phone, the capsule sat across - the headline of each explainer slide. Measured at 390x844 resting on each stop, covered widths: - "What is multi-period portfolio optimization?" 228px · "Now imagine it is not your money." 341px · - "Still an open problem." 212px · "The work" 123px — plus every kicker ("1 / 3", "2 / 3 · the difficulty"). - Not transient: these are the positions the deck comes to REST at, so the headline was permanently obscured. - - THE FIX IS THE PANEL'S OWN PADDING, NOT --deck-lead. A lead was the obvious lever and it is the wrong one: - it lifts the deck's resting position ABOVE the slide, so a full-bleed opaque panel gets a strip of sky above - it and reads as a card that has slipped down — Story.astro measured exactly that (paper at 1953, deck resting - at 1893, 60px of sky) and documents why it carries no lead, naming the panel's padding-top as the right home - for this air. Padding keeps the paper flush to the top of the screen and moves only the content inside it. - - 78px clears the wrapped two-row capsule on phones (12px offset + ~54px tall) and the single-row capsule in - landscape (17px + ~44px). These selectors are (0,2,1) against Astro's scoped `.ch-paper[data-astro-cid-…]` at - (0,2,0), so the longhand wins over each panel's own `padding` shorthand — which is also why the first attempt - at this, a plain `.is-slides > section` rule, silently lost and changed nothing. */ -@media (max-width: 1100px), (max-height: 620px) { - .is-slides > section > .story-paper, - .is-slides > section > .ch-paper, - .is-slides > section > .ru-paper, - .is-slides > section > .so-paper, - .is-slides > section > .wk-paper { padding-top: 78px; } - /* The footer no longer needs its own clearance rule. It used to be a stop of its own, so the deck rested with - its first row at the top of the screen and the capsule sat across "Download CV ↓" — 95px of it at 320px, a - band where a tap would have hit the nav instead of the link. Nested inside #appendix it is never at the top - of the viewport at rest, and the appendix's own panel padding above covers the case. */ -} - -/* ── ON A PHONE THAT CLEARANCE IS NOW A HOLE, so it comes back down. ── - The 78px above was sized for the nav as it then was: a wrapped, effectively full-width capsule 68px tall, - with the deck resting each slide's top exactly at the viewport top so a headline landed right underneath it. - Neither premise holds any more. The phone nav is a 40x38 mark in the top-RIGHT corner, these headlines are - left-aligned, and the deck does not engage on phones at all, so no slide is parked with its top against the - chrome. What is left is 79px of blank paper above a one-line kicker — measured on all three explainers, and - on a COLLAPSED slide that band is a third of the whole section. It reads as something that failed to render, - which is part of what the owner saw when they said the slides "collide and doesn't work". - 22px keeps the panel from starting hard against its own edge. Placed after the rule it overrides and at the - same specificity, deliberately: source order is what decides here, and getting that backwards has cost this - project several silent fixes. */ -@media (max-width: 640px) { - .is-slides > section > .ch-paper, - .is-slides > section > .ru-paper, - .is-slides > section > .so-paper, - .is-slides > section > .story-paper { padding-top: 22px; } -} -/* The panel inside a slide must be able to fill it, or the centring above just - moves a short panel down the screen and leaves sky above and below it. */ +/* ============================================================ + THE FIVE SLIDE PANELS, ENUMERATED EXACTLY ONCE. + .story-paper (Story) · .ch-paper (Choice) · .ru-paper (Rules) · .so-paper (Solve) · + .wk-paper (Work) — the opaque full-bleed panel each slide hangs its content on. + + THIS USED TO BE THREE HAND-WRITTEN COPIES of the same five-class list (flex fill, + nav clearance, phone correction) and the third copy had only FOUR entries: .wk-paper + was missing from the phone correction. The consequence was live and measurable — the + 78px nav clearance below applies at max-width:1100px, the 22px correction only at + max-width:640px, so on a phone #appendix (class="wk-paper", confirmed in + dist/index.html) kept 78px of blank tan above its kicker while its four siblings got + 22px. A band of empty paper that reads as something that failed to render, in the one + place nobody was looking, because the bug was a missing line rather than a wrong one. + + Three parallel lists cannot be kept in sync by care, so there is now one. The nested + @media rules below inherit this selector list, which means a SIXTH panel is added in + exactly one place and cannot be half-added. Nesting rather than :is() so the emitted + selectors — and therefore the specificity — are byte-for-byte what they were: + `.is-slides > section > .wk-paper` is (0,2,1), which is what beats Astro's scoped + `.wk-paper[data-astro-cid-…]` at (0,2,0) and lets a longhand `padding-top` here + override each panel's own `padding` shorthand. An earlier attempt at the clearance + used a plain `.is-slides > section` rule, lost that specificity race, and silently + changed nothing. + ============================================================ */ .is-slides > section > .story-paper, .is-slides > section > .ch-paper, .is-slides > section > .ru-paper, .is-slides > section > .so-paper, .is-slides > section > .wk-paper { + /* The panel inside a slide must be able to fill it, or the centring above just moves a + short panel down the screen and leaves sky above and below it. */ flex: 1 0 auto; display: flex; flex-direction: column; justify-content: center; + + /* ── THE NAV MUST NOT LAND ON A HEADLINE, which on phones it did — on every slide. + The corner nav is a fixed capsule at the top; the deck rests with a slide's top at the viewport top; and a + paper panel puts its kicker and headline in the first ~90px. So at rest, on a phone, the capsule sat across + the headline of each explainer slide. Measured at 390x844 resting on each stop, covered widths: + "What is multi-period portfolio optimization?" 228px · "Now imagine it is not your money." 341px · + "Still an open problem." 212px · "The work" 123px — plus every kicker ("1 / 3", "2 / 3 · the difficulty"). + Not transient: these are the positions the deck comes to REST at, so the headline was permanently obscured. + + THE FIX IS THE PANEL'S OWN PADDING, NOT --deck-lead. A lead was the obvious lever and it is the wrong one: + it lifts the deck's resting position ABOVE the slide, so a full-bleed opaque panel gets a strip of sky above + it and reads as a card that has slipped down — Story.astro measured exactly that (paper at 1953, deck resting + at 1893, 60px of sky) and documents why it carries no lead, naming the panel's padding-top as the right home + for this air. Padding keeps the paper flush to the top of the screen and moves only the content inside it. + + 78px clears the wrapped two-row capsule on phones (12px offset + ~54px tall) and the single-row capsule in + landscape (17px + ~44px). + The footer needs no clearance rule of its own. It used to be a stop, so the deck rested with its first row at + the top of the screen and the capsule sat across "Download CV ↓" — 95px of it at 320px, a band where a tap + would have hit the nav instead of the link. Nested inside #appendix it is never at the top of the viewport at + rest, and this panel's padding above covers the case. ── */ + @media (max-width: 1100px), (max-height: 620px) { padding-top: 78px; } + + /* ── ON A PHONE THAT CLEARANCE IS NOW A HOLE, so it comes back down. ── + The 78px above was sized for the nav as it then was: a wrapped, effectively full-width capsule 68px tall, + with the deck resting each slide's top exactly at the viewport top so a headline landed right underneath it. + Neither premise holds any more. The phone nav is a 40x38 mark in the top-RIGHT corner, these headlines are + left-aligned, and the deck does not engage on phones at all, so no slide is parked with its top against the + chrome. What is left is 79px of blank paper above a one-line kicker — measured on all three explainers, and + on a COLLAPSED slide that band is a third of the whole section. It reads as something that failed to render, + which is part of what the owner saw when they said the slides "collide and doesn't work". + 22px keeps the panel from starting hard against its own edge. Placed after the rule it overrides and at the + same specificity, deliberately: source order is what decides here, and getting that backwards has cost this + project several silent fixes. ── */ + @media (max-width: 640px) { padding-top: 22px; } } /* The nested footer must NOT stretch. It is the last flex item in #appendix, and a stretched footer would put its links in the middle of a tall band instead of at the foot of the page. `flex: none` keeps it at its own height @@ -340,11 +352,12 @@ html[data-theme='dark'] .descent::after { .descent.is-fluid.fluid-live .sky-wash { opacity: 0.35; } } -/* Terminal chrome fill (Terminal.tsx is a React island with no