Skip to content

Commit e9b18d8

Browse files
committed
Add automatic dependent transaction playback
1 parent cd2c95c commit e9b18d8

3 files changed

Lines changed: 243 additions & 16 deletions

File tree

visualizer/web/app.js

Lines changed: 155 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { startCore } from "./wasm.js";
22
import { animateAfterFrame, animateBeforeFrame, render, setCoreReady } from "./view.js";
33

4+
const AUTO_COMMAND_LIMIT = 30;
5+
const AUTO_WINDOW = 3;
6+
47
const state = {
58
dispatch: null,
69
catalog: [],
@@ -15,6 +18,13 @@ const state = {
1518
inspectorOpen: false,
1619
tourPlaying: false,
1720
labRunning: false,
21+
financeAuto: false,
22+
autoAnchor: "",
23+
autoPreviousFrom: "",
24+
autoGenerated: 0,
25+
autoCompleted: false,
26+
autoCommandLimit: AUTO_COMMAND_LIMIT,
27+
autoWindow: AUTO_WINDOW,
1828
speed: 1,
1929
busy: false,
2030
error: "",
@@ -201,6 +211,9 @@ async function handleAction(action, control) {
201211
case "transfer":
202212
await transfer();
203213
break;
214+
case "toggle-finance-auto":
215+
await toggleFinanceAuto();
216+
break;
204217
case "bootstrap-node":
205218
await dispatchLab({ kind: "bootstrap", replica: Number(control.dataset.replica) });
206219
break;
@@ -427,6 +440,11 @@ async function resetFinance(confirmIfNeeded, updateURL = true) {
427440
state.labFrames = new Map([[0, result.frame]]);
428441
state.financeDraft = { from: "northwind", to: "contoso", amount: "2500.00" };
429442
state.error = "";
443+
state.financeAuto = false;
444+
state.autoAnchor = "";
445+
state.autoPreviousFrom = "";
446+
state.autoGenerated = 0;
447+
state.autoCompleted = false;
430448
state.inspectorOpen = false;
431449
if (updateURL) {
432450
setURL({ mode: "finance" });
@@ -459,6 +477,108 @@ async function transfer() {
459477
});
460478
}
461479

480+
async function toggleFinanceAuto() {
481+
if (state.mode !== "finance" || state.busy) {
482+
return;
483+
}
484+
if (state.financeAuto) {
485+
state.financeAuto = false;
486+
state.labRunning = false;
487+
state.autoCompleted = false;
488+
clearTimeout(state.timer);
489+
render(state);
490+
return;
491+
}
492+
493+
for (const replica of state.frame.snapshot.replicas.filter((item) => !item.booted)) {
494+
if (!await dispatchLab({ kind: "bootstrap", replica: replica.id })) {
495+
return;
496+
}
497+
}
498+
const eligible = eligibleAutoAccounts();
499+
if (eligible.length < 2 || state.frame.snapshot.commands.length >= AUTO_COMMAND_LIMIT) {
500+
state.error = eligible.length < 2
501+
? "Auto mode needs two accounts whose home pairs have a running node."
502+
: "The bounded automatic trace is full. Reset the simulation to run another batch.";
503+
render(state);
504+
return;
505+
}
506+
507+
state.financeAuto = true;
508+
state.labRunning = true;
509+
state.autoAnchor = eligible[randomIndex(eligible.length)].id;
510+
state.autoPreviousFrom = "";
511+
state.autoGenerated = 0;
512+
state.autoCompleted = false;
513+
state.error = "";
514+
render(state);
515+
scheduleLabStep();
516+
}
517+
518+
function nextAutomaticTransfer() {
519+
const eligible = eligibleAutoAccounts();
520+
if (eligible.length < 2) {
521+
return null;
522+
}
523+
let from = eligible.find((account) => account.id === state.autoAnchor);
524+
if (!from) {
525+
from = eligible.find((account) => account.id === state.autoPreviousFrom);
526+
}
527+
if (!from) {
528+
from = eligible[randomIndex(eligible.length)];
529+
}
530+
const destinations = eligible.filter((account) => account.id !== from.id);
531+
const to = destinations[randomIndex(destinations.length)];
532+
const balance = accountBalance(from.id);
533+
const maxWholeDollars = Math.max(1, Math.min(500, Math.floor(balance / 2000)));
534+
const amount = (1 + randomIndex(maxWholeDollars)) * 100;
535+
536+
state.autoPreviousFrom = from.id;
537+
state.autoAnchor = to.id;
538+
state.financeDraft = { from: from.id, to: to.id, amount: (amount / 100).toFixed(2) };
539+
return { kind: "transfer", from: from.id, to: to.id, amount };
540+
}
541+
542+
function eligibleAutoAccounts() {
543+
const running = new Set(state.frame.snapshot.replicas
544+
.filter((replica) => replica.booted && !replica.paused && !replica.crashed)
545+
.map((replica) => replica.id));
546+
return state.frame.snapshot.accounts.filter((account) => account.home.some((replica) => running.has(replica)));
547+
}
548+
549+
function accountBalance(account) {
550+
const key = `acct_${account}`;
551+
for (const replica of state.frame.snapshot.replicas) {
552+
if (!replica.booted || replica.crashed) {
553+
continue;
554+
}
555+
const item = replica.state.find((entry) => entry.key === key);
556+
if (item) {
557+
return Number(item.value);
558+
}
559+
}
560+
return 10_000;
561+
}
562+
563+
function randomIndex(length) {
564+
return Math.floor(Math.random() * length);
565+
}
566+
567+
function automaticOutstanding() {
568+
const available = state.frame.snapshot.replicas.filter((replica) => replica.booted && !replica.paused && !replica.crashed);
569+
if (available.length === 0) {
570+
return state.frame.snapshot.commands.length;
571+
}
572+
const applied = Math.min(...available.map((replica) => replica.applied.length));
573+
return Math.max(0, state.frame.snapshot.commands.length - applied);
574+
}
575+
576+
function automaticCanGenerate() {
577+
return state.financeAuto &&
578+
state.frame.snapshot.commands.length < AUTO_COMMAND_LIMIT &&
579+
automaticOutstanding() < AUTO_WINDOW;
580+
}
581+
462582
async function dispatchLab(action, { quietBlocked = false } = {}) {
463583
if (!isSessionMode() || state.busy) {
464584
return false;
@@ -482,7 +602,13 @@ async function dispatchLab(action, { quietBlocked = false } = {}) {
482602
} catch (error) {
483603
if (quietBlocked && error.code === "blocked") {
484604
state.labRunning = false;
485-
state.error = "";
605+
if (state.financeAuto) {
606+
state.financeAuto = false;
607+
state.autoCompleted = false;
608+
state.error = "Auto mode stopped because the current account chain has no available coordinator.";
609+
} else {
610+
state.error = "";
611+
}
486612
return;
487613
}
488614
throw error;
@@ -512,6 +638,8 @@ function toggleLabRun() {
512638
}
513639
if (state.labRunning) {
514640
state.labRunning = false;
641+
state.financeAuto = false;
642+
state.autoCompleted = false;
515643
clearTimeout(state.timer);
516644
render(state);
517645
return;
@@ -534,11 +662,21 @@ function scheduleLabStep() {
534662
return;
535663
}
536664
const beforeTicks = state.frame.snapshot.replicas.map((replica) => replica.tick);
537-
const deliverable = state.frame.snapshot.messages.some((message) => !message.blocked);
538665
let moved = false;
539-
if (deliverable) {
666+
if (automaticCanGenerate()) {
667+
const action = nextAutomaticTransfer();
668+
if (action) {
669+
moved = await dispatchLab(action, { quietBlocked: true });
670+
if (moved) {
671+
state.autoGenerated++;
672+
render(state);
673+
}
674+
}
675+
}
676+
const deliverable = state.frame.snapshot.messages.some((message) => !message.blocked);
677+
if (!moved && deliverable) {
540678
moved = await dispatchLab({ kind: "deliver-next" }, { quietBlocked: true });
541-
} else if (state.mode === "finance") {
679+
} else if (!moved && state.mode === "finance") {
542680
const wait = nextNetworkWait();
543681
if (wait > 0) {
544682
moved = await dispatchLab({ kind: "advance-network", milliseconds: wait }, { quietBlocked: true });
@@ -551,7 +689,15 @@ function scheduleLabStep() {
551689
render(state);
552690
return;
553691
}
554-
if (!moved || !state.labRunning || !sessionCanAdvance()) {
692+
const canContinue = state.financeAuto ? automaticCanGenerate() || sessionCanAdvance() : sessionCanAdvance();
693+
if (!moved || !state.labRunning || !canContinue) {
694+
if (state.financeAuto) {
695+
state.autoCompleted = state.frame.snapshot.commands.length >= AUTO_COMMAND_LIMIT && automaticOutstanding() === 0;
696+
state.financeAuto = false;
697+
if (!state.autoCompleted && !state.error) {
698+
state.error = "Auto mode stopped because the cluster cannot make progress.";
699+
}
700+
}
555701
state.labRunning = false;
556702
render(state);
557703
return;
@@ -577,7 +723,7 @@ async function transitionFrame(frame, mode) {
577723
.map(([index, item]) => ({ index, events: item.events }));
578724
}
579725
render(state);
580-
await animateAfterFrame(frame);
726+
await animateAfterFrame(frame, state.speed);
581727
const details = frame.events.map((event) => event.detail).join(" ");
582728
if (details) {
583729
announce(details);
@@ -707,6 +853,9 @@ function setURL(values) {
707853
function stopPlayback() {
708854
state.tourPlaying = false;
709855
state.labRunning = false;
856+
state.financeAuto = false;
857+
state.autoAnchor = "";
858+
state.autoPreviousFrom = "";
710859
clearTimeout(state.timer);
711860
}
712861

visualizer/web/styles.css

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1238,6 +1238,53 @@ select:disabled {
12381238
padding-bottom: 4px;
12391239
}
12401240

1241+
.auto-mode-panel {
1242+
display: grid;
1243+
gap: 8px;
1244+
padding: 12px;
1245+
border: 1px solid var(--border);
1246+
background: rgba(98, 223, 243, 0.035);
1247+
}
1248+
1249+
.auto-mode-panel.running {
1250+
border-color: var(--cyan);
1251+
box-shadow: inset 3px 0 0 var(--cyan);
1252+
}
1253+
1254+
.auto-mode-heading {
1255+
display: flex;
1256+
align-items: center;
1257+
justify-content: space-between;
1258+
gap: 8px;
1259+
}
1260+
1261+
.auto-mode-heading .kicker {
1262+
margin: 0;
1263+
}
1264+
1265+
.auto-mode-heading strong {
1266+
color: var(--green);
1267+
font-family: var(--mono);
1268+
font-size: 9px;
1269+
}
1270+
1271+
.auto-mode-panel p {
1272+
margin: 0;
1273+
color: var(--muted);
1274+
font-size: 12px;
1275+
line-height: 1.55;
1276+
}
1277+
1278+
.auto-mode-panel .auto-mode-note {
1279+
color: var(--amber);
1280+
font-family: var(--mono);
1281+
font-size: 9px;
1282+
}
1283+
1284+
.auto-mode-panel .button {
1285+
width: 100%;
1286+
}
1287+
12411288
.bootstrap-panel .button {
12421289
width: 100%;
12431290
margin-top: 12px;

visualizer/web/view.js

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -160,20 +160,51 @@ function renderFinanceForm(state) {
160160
form.append(bootstrap);
161161
}
162162

163-
const from = accountField("from", "Debit account", state.financeDraft.from, snapshot.accounts, state.busy || !allBooted);
164-
const to = accountField("to", "Credit account", state.financeDraft.to, snapshot.accounts, state.busy || !allBooted);
165-
const amount = textField("amount", "Amount · USD", state.financeDraft.amount, 10, state.busy || !allBooted);
163+
const controlsDisabled = state.busy || !allBooted || state.financeAuto;
164+
const from = accountField("from", "Debit account", state.financeDraft.from, snapshot.accounts, controlsDisabled);
165+
const to = accountField("to", "Credit account", state.financeDraft.to, snapshot.accounts, controlsDisabled);
166+
const amount = textField("amount", "Amount · USD", state.financeDraft.amount, 10, controlsDisabled);
166167
amount.querySelector("input").inputMode = "decimal";
167168
const selected = snapshot.accounts.find((account) => account.id === state.financeDraft.from);
168169
const route = el("div", "route-preview");
169170
route.append(kicker("LOCALITY ROUTER"), el("strong", "", selected ? `${selected.name} → R${selected.home[0]} / R${selected.home[1]}` : "Choose an account"), el("p", "", "Least coordinated running home wins. Consensus messages still use all five voters."));
170171
const submit = actionButton("Route atomic transfer", "transfer", "button primary");
171172
submit.type = "submit";
172-
submit.disabled = state.busy || !allBooted;
173-
form.append(from, to, amount, route, submit);
173+
submit.disabled = controlsDisabled;
174+
form.append(renderFinanceAuto(state, allBooted), from, to, amount, route, submit);
174175
return form;
175176
}
176177

178+
function renderFinanceAuto(state, allBooted) {
179+
const snapshot = state.frame.snapshot;
180+
const panel = el("section", `auto-mode-panel ${state.financeAuto ? "running" : ""}`);
181+
const heading = el("div", "auto-mode-heading");
182+
const status = state.financeAuto
183+
? `RUNNING · ${state.autoGenerated} GENERATED`
184+
: state.autoCompleted
185+
? `BATCH COMPLETE · ${state.autoGenerated} GENERATED`
186+
: "READY";
187+
heading.append(kicker("DEPENDENCY AUTO MODE"), el("strong", "", status));
188+
panel.append(
189+
heading,
190+
el("p", "", `Random account chains keep each credit account as the next debit account. Up to ${state.autoWindow} overlapping transfers force real dependency discovery.`),
191+
el("p", "auto-mode-note", `The bounded teaching trace stops at ${state.autoCommandLimit} total commands.`),
192+
);
193+
const atLimit = snapshot.commands.length >= state.autoCommandLimit;
194+
const label = state.financeAuto
195+
? "Stop automatic mode"
196+
: atLimit
197+
? "Reset for another auto batch"
198+
: allBooted
199+
? "Start automatic mode"
200+
: "Bootstrap + start automatic mode";
201+
const control = actionButton(label, "toggle-finance-auto", `button ${state.financeAuto ? "danger" : "primary"}`);
202+
control.disabled = state.busy || (!state.financeAuto && atLimit);
203+
control.setAttribute("aria-pressed", String(state.financeAuto));
204+
panel.append(control);
205+
return panel;
206+
}
207+
177208
function accountField(name, labelText, value, accounts, disabled) {
178209
const field = el("div", "field");
179210
const label = el("label", "", labelText);
@@ -657,12 +688,12 @@ function renderPlayback(state, snapshot) {
657688
left.append(advance);
658689
}
659690
left.append(
660-
playbackButton(state.labRunning ? "Pause" : "Run", "toggle-run", !state.labRunning && (state.busy || !runnable), "Space"),
691+
playbackButton(state.financeAuto ? "Pause auto" : state.labRunning ? "Pause" : "Run", "toggle-run", !state.labRunning && (state.busy || !runnable), "Space"),
661692
playbackButton("Tick", "tick", state.busy || allUnavailable, ""),
662693
playbackButton("Reset", "reset-lab", state.busy, "R"),
663694
);
664695
}
665-
for (const speed of [0.5, 1, 2]) {
696+
for (const speed of [0.5, 1, 2, 10, 20]) {
666697
const control = actionButton(`${speed}×`, "set-speed", `button ${state.speed === speed ? "active" : ""}`);
667698
control.dataset.speed = String(speed);
668699
control.disabled = state.busy;
@@ -772,7 +803,7 @@ export async function animateBeforeFrame(frame, speed) {
772803
await Promise.allSettled(animations);
773804
}
774805

775-
export async function animateAfterFrame(frame) {
806+
export async function animateAfterFrame(frame, speed) {
776807
if (reducedMotion()) {
777808
return;
778809
}
@@ -784,7 +815,7 @@ export async function animateAfterFrame(frame) {
784815
const animation = row?.animate([
785816
{ borderColor: "#62dff3", boxShadow: "0 0 22px rgba(98,223,243,.4)" },
786817
{ borderColor: "rgba(38,49,61,.75)", boxShadow: "none" },
787-
], { duration: 500, easing: "ease-out" });
818+
], { duration: 500 / speed, easing: "ease-out" });
788819
if (animation) {
789820
animations.push(animation.finished);
790821
}
@@ -793,7 +824,7 @@ export async function animateAfterFrame(frame) {
793824
const animation = replicaElement(event.replica)?.animate([
794825
{ backgroundColor: "rgba(112,233,154,.34)" },
795826
{ backgroundColor: "rgba(13,18,25,.96)" },
796-
], { duration: 620, easing: "ease-out" });
827+
], { duration: 620 / speed, easing: "ease-out" });
797828
if (animation) {
798829
animations.push(animation.finished);
799830
}

0 commit comments

Comments
 (0)