// RuleChef Live: replays data/replay.json (written by sim.py) and runs the rules in the browser (engine.js).
(function () {
"use strict";
const E = window.RCEngine;
const $ = (s) => document.querySelector(s);
const fmt = (n) => n.toLocaleString("en-US");
const pct = (a, b) => (b ? (100 * a / b).toFixed(1) + " %" : "–");
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
const NAMES = { DATETIME: "date/time", CODE: "code", QUANTITY: "quantity" };
let R, REQ = {}, EV, VERS, GUARD, META, N, AT, S = {}, LAB = [], HOLD = [];
let k = 0, timer = null;
function cum(xs) { const out = [0]; let s = 0; for (const x of xs) { s += x; out.push(s); } return out; }
function versionAt(i) { let lo = 0; for (let j = 0; j < AT.length; j++) if (AT[j] <= i) lo = j; return VERS[lo]; }
function blockedAt(i) { return new Set(GUARD.filter((g) => g.at < i).map((g) => g.key)); }
function activeRules(v, i) { const off = blockedAt(i); return v.rules.filter((r) => r.trusted && !off.has(r.key)); }
function card(t, v, s) { return `
`; }
function renderKpis() {
const v = versionAt(k);
const nr = S.rules[k], ns = S.spans[k], nc = S.check[k], na = S.agree[k], calls = S.call[k];
const lat = k ? S.lat[k] / k : 0, tlat = k ? S.tlat[k] / k : 0;
const rm = EV.slice(0, k).filter((e) => e.by === "rules").map((e) => e.rule_ms).sort((a, b) => a - b);
const med = rm.length ? rm[rm.length >> 1].toFixed(2) + " ms (median)" : "–";
$("#kpis").innerHTML = [
card("Requests", `${fmt(k)} / ${fmt(N)}`, `rule set v${v.v}`),
card("Answered by rules", pct(nr, k), `${fmt(nr)}: ${fmt(ns)} with spans, ${fmt(nr - ns)} with nothing to extract`),
card("Model calls", fmt(calls), `${fmt(k - calls)} saved (${pct(k - calls, k)}); spot checks included`),
card("Mean latency", `${Math.round(lat)} ms`, `teacher on every request: ${Math.round(tlat)} ms; a rule answer: ${med}`),
card("Spot-check agreement", pct(na, nc), `${na} of ${nc} checked rule answers equal the teacher's`),
card("Rules answering", String(activeRules(v, k).length), `of ${v.rules.length} in v${v.v}; ${blockedAt(k).size} switched off by the guard`),
].join("");
}
// ------------------------------------------------------------------ charts (SVG, two series each, crosshair tooltip)
const W = 360, H = 190, P = { l: 40, r: 10, t: 8, b: 22 };
function lineChart(el, cfg) {
const { series, yMax, yTicks, yFmt, markers } = cfg;
const x = (v) => P.l + (W - P.l - P.r) * v / N, y = (v) => H - P.b - (H - P.t - P.b) * v / yMax;
let g = ``;
for (const t of yTicks) g += `${yFmt(t)} `;
for (let t = 0; t <= N; t += 1000) g += `${fmt(t)} `;
for (const m of markers || []) if (m.at <= k && m.at > 0) g += ` `;
g += ` `;
for (const s of series) {
if (s.pts.length < 2) continue;
g += ` `;
}
g += ` `;
g += ` `;
g += `${series.map((s) => ` ${esc(s.name)} `).join("")}
`;
el.innerHTML = g;
const svg = el.querySelector("svg"), xh = el.querySelector(".xh"), tip = $("#tip");
svg.addEventListener("mousemove", (ev) => {
const b = svg.getBoundingClientRect();
const vx = (ev.clientX - b.left) / b.width * W;
const req = Math.max(0, Math.min(k, Math.round((vx - P.l) / (W - P.l - P.r) * N)));
const rows = series.map((s) => { let best = null; for (const p of s.pts) if (!best || Math.abs(p[0] - req) < Math.abs(best[0] - req)) best = p; return [s, best]; }).filter((r) => r[1]);
if (!rows.length) return;
xh.setAttribute("x1", x(rows[0][1][0])); xh.setAttribute("x2", x(rows[0][1][0])); xh.setAttribute("visibility", "visible");
tip.innerHTML = `request ${fmt(rows[0][1][0])} ` + rows.map(([s, p]) => `▬ ${esc(s.name)}: ${cfg.tipFmt(p[1])} `).join(" ");
tip.hidden = false; tip.style.left = Math.min(ev.clientX + 12, window.innerWidth - 230) + "px"; tip.style.top = (ev.clientY + 12) + "px";
});
svg.addEventListener("mouseleave", () => { tip.hidden = true; xh.setAttribute("visibility", "hidden"); });
}
function windowPts(arr, w = 200, step = 20) {
const pts = [];
for (let i = step; i <= k; i += step) { const lo = Math.max(0, i - w); pts.push([i, (arr[i] - arr[lo]) / (i - lo)]); }
return pts;
}
const C1 = () => getComputedStyle(document.documentElement).getPropertyValue("--s1").trim();
const C2 = () => getComputedStyle(document.documentElement).getPropertyValue("--s2").trim();
function renderCharts() {
const markers = VERS.slice(1);
lineChart($("#c-cov"), { label: "Share of requests answered by rules", yMax: 1, yTicks: [0, 0.25, 0.5, 0.75, 1], yFmt: (t) => Math.round(t * 100) + "%",
tipFmt: (v) => (100 * v).toFixed(1) + " %", markers,
series: [{ name: "answered by rules", color: C1(), pts: windowPts(S.rules) }, { name: "rules found spans", color: C2(), pts: windowPts(S.spans) }] });
const step = 20, a = [], b = [];
for (let i = 0; i <= k; i += step) { a.push([i, S.call[i]]); b.push([i, i]); }
lineChart($("#c-calls"), { label: "Model calls", yMax: N, yTicks: [0, 1000, 2000, 3000], yFmt: (t) => fmt(t), tipFmt: (v) => fmt(v), markers,
series: [{ name: "with rules", color: C1(), pts: a }, { name: "teacher on every request", color: C2(), pts: b }] });
lineChart($("#c-lat"), { label: "Latency per request", yMax: 500, yTicks: [0, 100, 200, 300, 400, 500], yFmt: (t) => String(t), tipFmt: (v) => Math.round(v) + " ms", markers,
series: [{ name: "cascade", color: C1(), pts: windowPts(S.lat) }, { name: "teacher on every request", color: C2(), pts: windowPts(S.tlat) }] });
}
// ------------------------------------------------------------------ tables
function renderVersions() {
const cur = versionAt(k).v;
const head = `version from request labelled examples rules answering / learned
synthesis s holdout answered by rules same answer as teacher
rule spans P / R vs teacher cascade F1 vs teacher TAB annotation F1: teacher / cascade `;
const rows = VERS.filter((v) => v.at <= k).map((v) => {
const h = v.holdout, ra = h.rules_answer;
return `v${v.v} ${fmt(v.at)} ${fmt(v.n_labelled)}
${v.rules.filter((r) => r.trusted).length} / ${v.rules.length} ${v.synth_s.toFixed(1)}
${pct(ra, h.n)} ${ra ? pct(h.exact_agree, ra) : "–"}
${ra ? h.routed_t.P.toFixed(2) + " / " + h.routed_t.R.toFixed(2) : "–"}
${h.casc_t.F1.toFixed(3)} ${h.teach_g.F1.toFixed(3)} / ${h.casc_g.F1.toFixed(3)} `;
});
$("#t-vers").innerHTML = `${head} ${rows.join("")} `;
}
function renderRules() {
const v = versionAt(k), off = blockedAt(k), used = {};
for (let i = 0; i < k; i++) if (EV[i].by === "rules") for (const key of EV[i].fired) used[key] = (used[key] || 0) + 1;
const rows = v.rules.map((r) => {
const n = r.tp + r.fp;
const st = off.has(r.key) ? ["cut", "switched off"] : r.trusted ? ["on", "answering"] : ["off", "not trusted"];
return { r, st, n, used: used[r.key] || 0 };
}).sort((a, b) => (a.st[0] !== "on") - (b.st[0] !== "on") || b.used - a.used || b.n - a.n);
$("#rules-help").textContent = v.v === 0
? "No rule set yet: every request goes to the teacher until 50 examples are labelled."
: `v${v.v}, learned from ${fmt(v.n_labelled)} labelled examples in ${v.synth_s.toFixed(1)} s. "Answering" rules matched at least ${META.trust_min} times on the labelled data with precision at least ${META.trust_prec}; the others are kept but do not answer. The last column counts requests this exact rule answered so far.`;
const head = `status type regex output labelled matches (precision) requests answered `;
const tr = ({ r, st, n, used: u }) => `${st[1]} ${r.type} ${esc(r.regex)}
${esc(r.template)} ${n ? `${n} (${(r.tp / n).toFixed(2)})` : "0"} ${u} `;
const on = rows.filter((x) => x.st[0] !== "off"), rest = rows.filter((x) => x.st[0] === "off");
$("#t-rules").innerHTML = head + "" + (on.map(tr).join("") || 'No answering rules. ') + " ";
const det = $("#rules-rest");
const open = det.open;
det.innerHTML = rest.length ? `${rest.length} learned rules that do not answer (precision below ${META.trust_prec} or too few matches) ${head}${rest.map(tr).join("")}
` : "";
det.open = open;
const g = v.gate;
let html = "";
if (g) {
html += `Gate of v${v.v}. A request goes to the rules only if every trigger token is covered by an answering rule or sits in a skip context.
Trigger tokens: anything with a digit, plus ${g.trigger_words.length} learned words: ${g.trigger_words.map((w) => `${esc(w)}`).join(", ")}.
Skip contexts (previous word + digit shape, 9 = digits) that the teacher labels at most ${Math.round(META.skip_ratio * 100)} % of the time:
${g.skip.map(([p, s]) => `${esc(p || "(start)")} ${esc(s)}`).join(", ") || "none"}.
`;
}
const gl = GUARD.filter((x) => x.at < k);
html += gl.length
? `Drift guard. ${gl.map((x) => `at request ${x.at}: ${esc(x.key.split("\t")[1])} switched off (spot checks ${x.tp} right, ${x.fp} wrong)`).join("; ")}.
`
: `Drift guard. No rule switched off so far. A rule is switched off when its spot-check precision drops below ${META.guard_prec} after ${META.guard_min} checked spans; in this run none did.
`;
$("#gate").innerHTML = html;
}
function highlight(text, spans) {
const ss = spans.slice().sort((a, b) => a.start - b.start || b.end - a.end);
let out = "", pos = 0;
for (const s of ss) {
if (s.start < pos) continue;
out += esc(text.slice(pos, s.start)) + `${esc(text.slice(s.start, s.end))}${NAMES[s.type] || s.type} `;
pos = s.end;
}
return out + esc(text.slice(pos));
}
function renderRecent() {
const items = [];
for (let i = k - 1; i >= Math.max(0, k - 6); i--) {
const e = EV[i], q = REQ[e.id];
let spans, by;
if (e.by === "rules") {
spans = E.applyRules(activeRules(versionAt(i), i).map((r) => r.dict), q.text);
by = `rules (v${e.v}, ${e.rule_ms.toFixed(2)} ms)` + (e.checked ? `, spot-checked: ${e.agree ? "same as the teacher" : "differs from the teacher"}` : "");
} else {
spans = q.teacher;
by = `teacher (${Math.round(e.teacher_ms)} ms), answer added to the labelled examples`;
}
items.push(`#${fmt(i + 1)} answered by ${by}
${highlight(q.text, spans)}${spans.length ? "" : ' (nothing to extract) '}
`);
}
$("#recent").innerHTML = items.join("") || "No requests yet. Press Play. ";
}
function render() {
$("#pos").value = k;
$("#posout").textContent = `${fmt(k)} requests`;
renderKpis(); renderCharts(); renderVersions(); renderRules(); renderRecent();
}
// ------------------------------------------------------------------ playback
function stop() { if (timer) clearTimeout(timer); timer = null; $("#play").textContent = "Play"; }
function tick() {
k = Math.min(N, k + parseInt($("#speed").value, 10));
render();
if (k >= N) { stop(); return; }
timer = setTimeout(tick, 280);
}
$("#play").addEventListener("click", () => {
if (timer) { stop(); return; }
if (k >= N) k = 0;
$("#play").textContent = "Pause";
tick();
});
$("#pos").addEventListener("input", (ev) => { stop(); k = parseInt(ev.target.value, 10); render(); });
// ------------------------------------------------------------------ try your own text
function tryText(text, recorded) {
text = (text || "").trim().slice(0, 2000);
if (!text) { $("#status").textContent = "Type or paste a sentence."; return; }
let v = versionAt(k);
if (!v.gate) v = VERS[VERS.length - 1];
const rules = activeRules(v, Math.max(k, v.at)).map((r) => r.dict);
const t0 = performance.now();
const spans = E.applyRules(rules, text);
const un = E.unexplained(v.gate, text, spans);
const ms = performance.now() - t0;
const rec = recorded || HOLD.find((q) => q.text === text);
if (!un.length) {
$("#status").innerHTML = `Answered by rules (v${v.v}, ${rules.length} answering rules) in ${ms.toFixed(2)} ms in your browser. No model call.`;
$("#out").innerHTML = `Answer (rules) ${highlight(text, spans)}`;
$("#out-teacher").innerHTML = rec ? `Teacher's recorded answer for this holdout sentence, for comparison ${highlight(rec.text, rec.teacher)}` : "";
} else {
$("#status").innerHTML = `The gate sends this request to the teacher : uncovered trigger tokens ${un.slice(0, 12).map((u) => `${esc(u)}`).join(", ")}. ` +
`Rules took ${ms.toFixed(2)} ms. The teacher does not run on this static page` + (rec ? "; its recorded answer for this holdout sentence is shown." : ".");
$("#out").innerHTML = rec ? `Answer (teacher, recorded offline) ${highlight(rec.text, rec.teacher)}` : "";
$("#out-teacher").innerHTML = `What the rules found ${highlight(text, spans)}`;
}
}
$("#go").addEventListener("click", () => tryText($("#inp").value));
$("#pick").addEventListener("click", () => { const q = HOLD[Math.floor(Math.random() * HOLD.length)]; $("#inp").value = q.text; tryText(q.text, q); });
document.querySelectorAll("button.ex").forEach((b) => b.addEventListener("click", () => { $("#inp").value = b.dataset.ex; tryText(b.dataset.ex); }));
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => R && renderCharts());
// ------------------------------------------------------------------ load
Promise.all([fetch("data/replay.json").then((r) => r.json()), fetch("data/requests.jsonl").then((r) => r.text())]).then(([rep, txt]) => {
R = rep; META = R.meta; VERS = R.versions; EV = R.events; GUARD = R.guard; N = EV.length; AT = VERS.map((v) => v.at);
for (const line of txt.trim().split("\n")) { const q = JSON.parse(line); REQ[q.id] = q; if (q.part === "holdout") HOLD.push(q); }
S.rules = cum(EV.map((e) => +(e.by === "rules")));
S.spans = cum(EV.map((e) => +(e.by === "rules" && e.fired.length > 0)));
S.check = cum(EV.map((e) => +!!e.checked));
S.agree = cum(EV.map((e) => +!!e.agree));
S.call = cum(EV.map((e) => +(e.by === "teacher" || !!e.checked)));
S.lat = cum(EV.map((e) => (e.by === "rules" ? e.rule_ms : e.rule_ms + e.teacher_ms)));
S.tlat = cum(EV.map((e) => e.teacher_ms));
document.querySelectorAll("[data-n-stream]").forEach((x) => (x.textContent = fmt(META.n_stream)));
document.querySelectorAll("[data-n-holdout]").forEach((x) => (x.textContent = fmt(META.n_holdout)));
const set = (sel, v) => document.querySelectorAll(sel).forEach((x) => (x.textContent = v));
set("[data-trust-min]", META.trust_min); set("[data-trust-prec]", META.trust_prec); set("[data-spot]", META.spot_every);
set("[data-guard-prec]", META.guard_prec); set("[data-guard-min]", META.guard_min);
$("#pos").max = N; k = N;
render();
tryText($("#inp").value);
}).catch((err) => { $("#kpis").textContent = "Could not load the replay data: " + err; });
})();