crucible / static /app.js
s-ttp's picture
ML models: walk-forward-judged panel rank, plain-language ML disclosure in report and progress
da2ea0f verified
Raw History Blame Contribute Delete
21.9 kB
/* Crucible front end. The page shows progress while a run is in flight and then the report the
engine audited, exactly as delivered (the same fragments as the markdown download). It
computes no figures, ratings or bands of its own. Progress events say which step is running
and who has finished, never what anyone concluded. */
const $ = (s, el = document) => el.querySelector(s);
const $$ = (s, el = document) => [...el.querySelectorAll(s)];
const escapeHtml = s => String(s ?? "").replace(/[&<>"']/g,
c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const BASE_TITLE = "Crucible";
async function loadHealth() {
const h = await fetch("/api/health").then(r => r.json()).catch(() => null);
const b = $("#env-badge");
if (!h) { b.textContent = "offline"; return; }
const llm = Object.values(h.llm).filter(Boolean).length;
b.textContent = h.engine ? `ready · ${llm}/4 analysts` : "engine unavailable";
if (!h.engine || !h.data || llm < 4) b.classList.add("warn");
}
/* ── progress model ─────────────────────────────────────────────────────────────────────
Milestones are the engine's stage percentages; START is when each typically begins (seconds,
from recent runs). Between milestones the bar eases toward the next one, never past it. */
const START = { 0: 0, 4: 2, 14: 10, 20: 14, 30: 16, 40: 24, 50: 90, 60: 92, 66: 94, 74: 124, 82: 140, 88: 150, 92: 178, 96: 180 };
const TYPICAL_END = 195;
const MILESTONES = Object.keys(START).map(Number).sort((a, b) => a - b);
const phaseOf = pct => pct >= 92 ? "report" : pct >= 66 ? "debate" : pct >= 40 ? "valuation" : "data";
const EXPLAIN = {
queue: [["In the queue", "Another analysis is running. Yours starts as soon as it finishes; one run at a time keeps the data sources within their limits."]],
data: [
["Checking the numbers first", "Reported revenue, margins and share counts are reconciled against the company's own SEC filings before anything is valued."],
["Why this matters", "Data providers mix currencies, share classes and restated periods. A figure that can't be reconciled is flagged, not guessed."],
],
valuation: [
["Stories to numbers", "Following Aswath Damodaran's framework, three AI storytellers each describe the business: market size, share, margins and reinvestment. A cash-flow model turns each story into a value."],
["The middle story counts", "The value is the median of the three stories, so one optimistic or gloomy storyteller can't move it."],
["One business at a time", "When a company's filed segments have very different economics, each storyteller must value them separately, as Damodaran did with SpaceX."],
["Where risk comes from", "The discount rate comes from Damodaran's own industry datasets and his monthly equity risk premium, not from the storytellers."],
],
debate: [
["Four models, four labs", "Four different AI models take the neutral, bear, bull and portfolio-manager roles, so the debate is not one model arguing with itself."],
["Same facts for everyone", "Every analyst sees the same validated figures. They can disagree about what matters, not about the numbers."],
["Rebuttals", "The bear and the bull answer each other's strongest points before the portfolio manager assigns probabilities."],
],
report: [["Nothing unverified gets published", "Every figure in the report is checked against the frozen analysis. If one doesn't match, the report is withheld rather than patched."]],
};
const P = { pct: 0, since: 0, t0: 0, timer: null, phase: null, card: 0, cardAt: 0, ticker: "", done: false, active: false,
facts: null, hood: null, hoodTimer: null };
/* ── under the hood: what the framework is doing with THIS company's inputs (never a result) ── */
const STAGE_WORDS = {
"young-growth": ["young growth", "the story centres on how big the market can get and whether the company survives to reach it"],
"high-growth": ["high growth", "growth lasts for years, reinvestment is heavy and margins are still climbing"],
"mature-growth": ["mature growth", "growth slows toward the economy's while margins settle near their peak"],
"mature-stable": ["mature and stable", "cash flow and returns on capital matter more than growth"],
"decline": ["decline", "shrinking revenue releases capital, and nothing grows forever"],
};
const monthWords = iso => { const d = new Date(`${String(iso).slice(0, 10)}T00:00:00Z`);
return isNaN(d) ? String(iso || "") : d.toLocaleString("en-US", { month: "long", year: "numeric", timeZone: "UTC" }); };
const pct1 = x => `${(x * 100).toFixed(1)}%`;
function valuationHood(f) {
const it = [];
if (f.industry) it.push(["Industry", `Damodaran's company data places ${P.ticker} in ${f.industry}. His industry averages anchor the story's margins and reinvestment.`]);
if (f.crp > 0 && f.country) it.push(["Country risk", `Operating in ${f.country} adds ${pct1(f.crp)} to the equity risk premium, from his country risk table.`]);
if (f.beta != null) it.push(["Risk", f.beta_basis === "business mix"
? `Beta ${f.beta.toFixed(2)}: the industry betas of its businesses, weighted by what each business is worth, re-levered at its debt.`
: `Beta ${f.beta.toFixed(2)}: the ${f.industry || "industry"} industry's beta, re-levered at the company's own debt.`]);
if (f.erp != null) it.push(["Market premium", `${pct1(f.erp)} equity risk premium, Damodaran's implied figure for ${monthWords(f.erp_month)}.`]);
const st = STAGE_WORDS[f.stage];
if (st) it.push(["Life cycle", `${st[0][0].toUpperCase() + st[0].slice(1)}: ${st[1]}.`]);
if ((f.segments || []).length > 1) it.push(["Businesses", f.segment_build
? `${f.segments.join(", ")} have different economics, so each storyteller must value them one by one.`
: `${f.segments.join(", ")} are valued together; their economics are close enough.`]);
if (f.consensus) it.push(["Near term", "The first two years lean on analysts' revenue estimates; after that, growth fades toward the economy's."]);
if (f.ml_rank === "LIMITED" || f.ml_rank === "VALIDATED") it.push(["Machine learning", "A model trained on twelve years of data ranks the stock against the largest US companies. Its test record makes it supporting evidence for the analysts; it does not set the rating."]);
else if (f.ml_rank === "UNRELIABLE") it.push(["Machine learning", "A model that ranks the stock against the largest US companies was checked and switched off for now: its recent test record is too weak to rely on."]);
it.push(["Storytellers", "Three different AI models are each writing a story at the same time: market size, share, margins and reinvestment."]);
it.push(["Cash flows", "Each story becomes ten years of cash flow, discounted at today's rate for five years and then at a rate drifting toward a mature company's."]);
it.push(["The value", "The middle of the three values is adopted, so no single storyteller sets it."]);
return it;
}
const DEBATE_ROUNDS = [
[66, "Round 1", "The neutral analyst sets a baseline from the validated figures."],
[74, "Round 2", "The bear and the bull build their cases on the same facts."],
[82, "Round 3", "Each answers the other's strongest point."],
[88, "Round 4", "The portfolio manager weighs the cases and assigns probabilities."],
];
function showHood(kind) {
const hood = $("#hood"), list = $("#hood-list");
clearInterval(P.hoodTimer);
if (kind === "valuation" && P.facts) {
P.hood = kind;
$("#hood-title").textContent = "· Damodaran's framework";
const items = valuationHood(P.facts);
list.innerHTML = "";
let i = 0;
const add = () => {
if (i >= items.length) { clearInterval(P.hoodTimer); return; }
list.querySelectorAll("li.latest").forEach(li => li.classList.remove("latest"));
list.insertAdjacentHTML("beforeend", `<li class="in latest"><b>${escapeHtml(items[i][0])}</b> ${escapeHtml(items[i][1])}</li>`);
i += 1;
};
add(); P.hoodTimer = setInterval(add, 2600);
} else if (kind === "debate") {
P.hood = kind;
$("#hood-title").textContent = "· the analyst debate";
list.innerHTML = DEBATE_ROUNDS.map(([p, r, t]) => `<li data-at="${p}"><b>${r}</b> ${escapeHtml(t)}</li>`).join("");
markRound();
} else {
P.hood = null;
}
hood.hidden = !P.hood;
$("#wait-card").hidden = !!P.hood;
}
function markRound() {
$$("#hood-list li[data-at]").forEach((li, i, all) => {
const at = Number(li.dataset.at), next = all[i + 1] ? Number(all[i + 1].dataset.at) : 92;
li.classList.toggle("done", P.pct >= next);
li.classList.toggle("latest", P.pct >= at && P.pct < next);
});
}
function fmtClock(s) { s = Math.max(0, Math.round(s)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; }
function shownPct(now) {
const i = MILESTONES.indexOf(P.pct);
const next = i >= 0 && i < MILESTONES.length - 1 ? MILESTONES[i + 1] : 100;
const dur = Math.max(3, (START[next] ?? TYPICAL_END) - (START[P.pct] ?? 0));
const t = (now - P.since) / 1000;
return P.pct + (next - P.pct) * 0.9 * (1 - Math.exp(-t / dur));
}
function remainingText(now) {
const elapsedInStage = (now - P.since) / 1000;
const left = TYPICAL_END - (START[P.pct] ?? 0) - elapsedInStage;
if (P.phase === "queue") return "waiting";
if (left > 90) return `about ${Math.round(left / 60)} min left`;
if (left > 25) return "about a minute left";
return "almost done";
}
function tick() {
if (P.done) return;
const now = Date.now();
const pct = P.phase === "queue" ? 1 : shownPct(now);
$("#progress").style.width = `${pct.toFixed(1)}%`;
$("#mini-fill").style.width = `${pct.toFixed(1)}%`;
const clock = `${fmtClock((now - P.t0) / 1000)} · ${remainingText(now)}`;
$("#run-clock").textContent = clock;
$("#mini-text").textContent = `${P.ticker} · ${$("#progress-label").textContent} · ${fmtClock((now - P.t0) / 1000)}`;
document.title = `${Math.round(pct)}% · ${P.ticker} · ${BASE_TITLE}`;
if (!P.hood && now - P.cardAt > 14000) nextCard();
}
function nextCard() {
const cards = EXPLAIN[P.phase] || EXPLAIN.data;
const [h, p] = cards[P.card % cards.length];
const box = $("#wait-card");
box.classList.remove("in"); void box.offsetWidth; box.classList.add("in");
$("#wait-h").textContent = h; $("#wait-p").textContent = p;
P.card += 1; P.cardAt = Date.now();
}
function setPhase(phase) {
if (phase === P.phase) return;
P.phase = phase; P.card = 0; nextCard();
const order = ["data", "valuation", "debate", "report"];
const at = order.indexOf(phase);
$$("#steps li").forEach(li => {
const k = order.indexOf(li.dataset.phase);
li.classList.toggle("is-done", at > k);
li.classList.toggle("is-now", at === k);
});
if (phase === "debate") $('#seats [data-role="neutral"]').classList.add("thinking");
showHood(phase);
}
function onStage(ev) {
if (ev.step === "queue") { P.phase = null; setPhase("queue"); $("#progress-label").textContent = ev.label; return; }
if (ev.pct > P.pct || P.pct === 0) { P.pct = ev.pct; P.since = Date.now(); }
$("#progress-label").textContent = ev.label;
setPhase(phaseOf(ev.pct));
const seat = r => $(`#seats [data-role="${r}"]`);
if (ev.pct === 74 || ev.pct === 82) ["bear", "bull"].forEach(r => { if (!seat(r).classList.contains("done")) seat(r).classList.add("thinking"); });
if (ev.pct === 88) { ["neutral", "bear", "bull"].forEach(r => seat(r).classList.remove("thinking")); if (!seat("pm").classList.contains("done")) seat("pm").classList.add("thinking"); }
if (ev.pct >= 92) $$("#seats em").forEach(e => e.classList.remove("thinking"));
if (P.hood === "debate") markRound();
}
function onFacts(ev) {
P.facts = ev.facts || null;
if (P.phase === "valuation") showHood("valuation");
}
function onStory(ev) {
$$("#story-pips em").forEach((e, i) => e.classList.toggle("on", i < ev.n));
}
function onSeat(ev) {
const el = $(`#seats [data-role="${ev.role}"]`);
if (!el) return;
el.classList.remove("thinking");
el.classList.add(ev.phase === "case" ? "spoke" : "done");
}
function startProgress(ticker) {
Object.assign(P, { pct: 0, since: Date.now(), t0: Date.now(), phase: null, card: 0, cardAt: 0, ticker, done: false, active: true,
facts: null, hood: null });
clearInterval(P.hoodTimer);
$$("#steps li").forEach(li => li.classList.remove("is-done", "is-now"));
$$("#story-pips em").forEach(e => e.classList.remove("on"));
$$("#seats em").forEach(e => e.classList.remove("thinking", "spoke", "done"));
setPhase("data");
clearInterval(P.timer);
P.timer = setInterval(tick, 500);
tick();
}
function stopProgress() {
P.done = true; P.active = false;
clearInterval(P.timer); clearInterval(P.hoodTimer);
$("#progress").style.width = "100%";
$("#mini-run").hidden = true;
document.title = BASE_TITLE;
}
// a slim progress pill for phones once the progress panel scrolls out of view
const miniObserver = "IntersectionObserver" in window ? new IntersectionObserver(entries => {
const vis = entries[0].isIntersecting;
$("#mini-run").hidden = vis || !P.active;
}) : null;
if (miniObserver) miniObserver.observe($("#runbar"));
$("#mini-run").addEventListener("click", () => $("#runbar").scrollIntoView({ behavior: "smooth", block: "center" }));
/* ── notifications (only when the browser supports them and the user asks) ─────────────── */
function setupNotify() {
const b = $("#notify-btn");
b.hidden = !("Notification" in window) || Notification.permission === "denied" || Notification.permission === "granted";
b.onclick = async () => {
const res = await Notification.requestPermission().catch(() => "denied");
b.textContent = res === "granted" ? "We'll notify you" : "Notifications are blocked";
b.disabled = true;
};
}
function notifyDone(ticker, published) {
try {
if ("Notification" in window && Notification.permission === "granted" && document.visibilityState !== "visible")
new Notification(`${ticker} analysis ${published ? "is ready" : "finished"}`, { body: published ? "Tap to read the report." : "The report was not published." });
} catch { /* optional */ }
if (navigator.vibrate) navigator.vibrate(60);
}
/* ── the previous published report, readable while the new one runs ──────────────────── */
async function offerPrevious(ticker) {
const btn = $("#prev-btn");
btn.hidden = true;
const r = await fetch(`/api/report/${encodeURIComponent(ticker)}`).catch(() => null);
if (!r || !r.ok || P.done) return;
const d = await r.json().catch(() => null);
if (!d || d.status !== "PUBLISHED") return;
const when = ageWords(d.cache_age_days);
btn.textContent = `Meanwhile, read the analysis published ${when}`;
btn.hidden = false;
btn.onclick = () => {
const box = $("#prev-report");
box.innerHTML = `<p class="prev-note">Previous analysis, published ${escapeHtml(when)}. The new one replaces it when it is ready.</p>` + d.html;
box.className = ratingClass(d.rating);
box.hidden = false;
btn.hidden = true;
box.scrollIntoView({ behavior: "smooth", block: "start" });
};
}
function ageWords(days) {
if (days == null) return "earlier";
if (days <= 0.5) return "today";
if (days < 1.5) return "yesterday";
return `${Math.round(days)} days ago`;
}
function ratingClass(rt) {
const r = String(rt || "").toUpperCase();
return "rated " + (/STRONG BUY/.test(r) ? "r-strongbuy" : /BUY/.test(r) ? "r-buy" : /STRONG SELL/.test(r) ? "r-strongsell"
: /SELL/.test(r) ? "r-sell" : "r-hold");
}
/* ── views ─────────────────────────────────────────────────────────────────────────────── */
function resetView(ticker) {
document.body.classList.add("has-analysis");
$("#analysis").hidden = false;
$("#v-ticker").hidden = false;
$("#v-ticker").textContent = ticker;
$("#status").textContent = "starting…";
$("#report").innerHTML = ""; $("#report").className = "";
$("#prev-report").innerHTML = ""; $("#prev-report").hidden = true;
$("#notice").hidden = true; $("#notice").textContent = "";
$("#actions").hidden = true;
$("#runbar").hidden = false;
$("#prev-btn").hidden = true;
$("#progress").style.width = "1%";
$("#progress-label").textContent = "Starting…";
}
function showNotice(text) {
$("#notice").textContent = text;
$("#notice").hidden = false;
}
function showReport(rep) {
stopProgress();
$("#runbar").hidden = true;
$("#prev-report").hidden = true; $("#prev-report").innerHTML = "";
if (!rep || rep.status !== "PUBLISHED") {
$("#status").textContent = rep && rep.status === "WITHHELD" ? "analysis withheld" : "analysis unavailable";
showNotice((rep && rep.message) || "The analysis could not be completed. Please try again later.");
return;
}
$("#v-ticker").hidden = true; // the report's own title names the company
$("#report").className = ratingClass(rep.rating);
$("#report").innerHTML = rep.html; // audited document, rendered and escaped by the engine
$("#md-btn").href = `/api/report/${encodeURIComponent(rep.ticker)}/md`;
$("#actions").hidden = false;
if (rep.stale) showNotice(rep.stale);
}
async function run() {
const query = $("#ticker").value.trim();
if (!query) return;
resetView(query.toUpperCase());
$("#run").disabled = true;
$("#ticker").blur();
$("#analysis").scrollIntoView({ behavior: "smooth", block: "start" });
const resp = await fetch(`/api/analyst/${encodeURIComponent(query)}`, { method: "POST", headers: { "X-Crucible-Client": "ui" } });
if (!resp.ok) {
let msg = "The analysis could not be started.";
try { const e = await resp.json(); if (e.detail) msg = e.detail; } catch { /* keep default */ }
$("#status").textContent = "not started";
$("#runbar").hidden = true;
showNotice(msg);
$("#run").disabled = false;
return;
}
const start = await resp.json();
const ticker = start.ticker || query.toUpperCase();
$("#v-ticker").textContent = ticker;
$("#status").textContent = start.resolved
? `${start.resolved.name || ticker} · resolved from “${start.resolved.from}”`
: "analysis running";
startProgress(ticker);
setupNotify();
offerPrevious(ticker);
const es = new EventSource(`/api/analyst/${start.job_id}/stream`);
es.onmessage = e => {
try {
const ev = JSON.parse(e.data);
if (ev.t === "stage") onStage(ev);
else if (ev.t === "story") onStory(ev);
else if (ev.t === "seat") onSeat(ev);
else if (ev.t === "facts") onFacts(ev);
} catch { /* ignore malformed event */ }
};
es.addEventListener("done", async () => {
es.close();
$("#run").disabled = false;
const rep = await fetch(`/api/analyst/${start.job_id}/result`).then(r => r.json()).catch(() => null);
const ok = rep && rep.status === "PUBLISHED";
$("#status").innerHTML = ok ? `published just now · <a href="#" id="rerun-live">run again ›</a>` : $("#status").textContent;
$("#rerun-live")?.addEventListener("click", e => { e.preventDefault(); $("#ticker").value = ticker; run(); });
notifyDone(ticker, ok);
showReport(rep);
$("#analysis").scrollIntoView({ behavior: "smooth", block: "start" });
});
es.onerror = () => { /* keepalive gaps are fine; done closes the stream */ };
}
async function showCached(ticker) {
resetView(ticker);
$("#runbar").hidden = true;
$("#analysis").scrollIntoView({ behavior: "smooth", block: "start" });
const r = await fetch(`/api/report/${encodeURIComponent(ticker)}`);
if (!r.ok) {
$("#status").innerHTML = `no recent published analysis of ${escapeHtml(ticker)} · <a href="#" id="rerun-live">run a new one ›</a>`;
$("#rerun-live")?.addEventListener("click", e => { e.preventDefault(); $("#ticker").value = ticker; run(); });
return;
}
const d = await r.json();
$("#status").innerHTML = `published ${escapeHtml(ageWords(d.cache_age_days))} · <a href="#" id="rerun-live">run a new analysis ›</a>`;
$("#rerun-live")?.addEventListener("click", e => { e.preventDefault(); $("#ticker").value = d.ticker; run(); });
showReport(d);
}
function bindChips() {
$$(".ex-chip").forEach(c => c.addEventListener("click", () => {
if (c.dataset.cached === "1") { showCached(c.dataset.t); return; }
$("#ticker").value = c.dataset.t; run();
}));
}
$("#run").addEventListener("click", run);
$("#ticker").addEventListener("keydown", e => { if (e.key === "Enter") run(); });
$("#print-btn").addEventListener("click", () => window.print());
// printed reports show every section
window.addEventListener("beforeprint", () => $$("#report details").forEach(d => { d.dataset.wasOpen = d.open ? "1" : ""; d.open = true; }));
window.addEventListener("afterprint", () => $$("#report details").forEach(d => { d.open = d.dataset.wasOpen === "1"; }));
(async function recentChips() {
try {
const r = await fetch("/api/recent").then(x => x.json());
const rows = (r.recent || []).slice(0, 4);
if (!rows.length) return;
const cls = rt => /BUY/i.test(rt || "") ? "up" : /SELL/i.test(rt || "") ? "down" : "flat";
$("#examples").innerHTML = `<span class="ex-label">Recent</span>` + rows.map(x =>
`<button type="button" class="ex-chip ex-cached" data-t="${escapeHtml(x.ticker)}" data-cached="1"
title="published ${escapeHtml(x.age_days)}d ago, opens instantly">${escapeHtml(x.ticker)}
<em class="${cls(x.rating)}">${escapeHtml(x.rating || "")}</em></button>`
).join("");
} catch (e) { /* static chips stay */ }
finally { bindChips(); }
})();
loadHealth();