mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
fix(folio-web): enumerate authored targets and values, declare the llm generator
The two edge-case typing leaves become the typing builtin at their combined weight: that text is deliberately not domain-specific, so naming the field and leaving the text to the policy is the designed path, and it keeps the seeded arm on the corpus while the model writes its own. Total weight is unchanged at 165, so every surviving branch keeps its share and submitTxn stays at 9.70 percent. Claude-Session: https://claude.ai/code/session_01A5KmftdEJ49A9z5mF5ESrX
This commit is contained in:
1 parent
4c5932b4be
commit
b6dabaeeca
1 file changed
+51
-46
@@ -3,14 +3,13 @@ import {
|
||||
Tap,
|
||||
actions,
|
||||
always,
|
||||
edgeCaseText,
|
||||
eventually,
|
||||
extract,
|
||||
from,
|
||||
integers,
|
||||
llm,
|
||||
next,
|
||||
now,
|
||||
taps,
|
||||
typing,
|
||||
waitOnce,
|
||||
weighted,
|
||||
} from "@sanderling/spec";
|
||||
@@ -178,8 +177,10 @@ const openAddAccount = actions(() => {
|
||||
});
|
||||
|
||||
// Readable enumeration keeps the demo legible; repeats over a run still
|
||||
// exercise duplicate-name handling.
|
||||
const accountNames = from([
|
||||
// exercise duplicate-name handling. One action per name rather than one action
|
||||
// for a drawn name: a draw reads the seeded picker's stream, which the model
|
||||
// policy never enters, so the two would explore different action spaces.
|
||||
const accountNames = [
|
||||
"Checking",
|
||||
"Savings",
|
||||
"Travel",
|
||||
@@ -188,18 +189,14 @@ const accountNames = from([
|
||||
"Investments",
|
||||
"Groceries",
|
||||
"Petty Cash",
|
||||
]);
|
||||
];
|
||||
|
||||
function typeAccountNameWith(sampler: { generate(): string }) {
|
||||
return actions(() => {
|
||||
if (!onAddAccountPage.current) return [];
|
||||
const field = accountNameField.current;
|
||||
return field ? [InputText({ into: field, text: sampler.generate() })] : [];
|
||||
});
|
||||
}
|
||||
|
||||
const typeAccountName = typeAccountNameWith(accountNames);
|
||||
const typeAccountNameEdge = typeAccountNameWith(edgeCaseText());
|
||||
const typeAccountName = actions(() => {
|
||||
if (!onAddAccountPage.current) return [];
|
||||
const field = accountNameField.current;
|
||||
if (!field) return [];
|
||||
return accountNames.map((name) => InputText({ into: field, text: name }));
|
||||
});
|
||||
|
||||
const submitAddAccount = actions(() => {
|
||||
if (!onAddAccountPage.current) return [];
|
||||
@@ -209,9 +206,7 @@ const submitAddAccount = actions(() => {
|
||||
|
||||
const openAccount = actions(() => {
|
||||
if (!onHomePage.current) return [];
|
||||
const cards = accountCards.current;
|
||||
if (cards.length === 0) return [];
|
||||
return [Tap({ on: from(cards).generate().element })];
|
||||
return accountCards.current.map((card) => Tap({ on: card.element }));
|
||||
});
|
||||
|
||||
const openAddTxn = actions(() => {
|
||||
@@ -220,41 +215,37 @@ const openAddTxn = actions(() => {
|
||||
return btn ? [Tap({ on: btn })] : [];
|
||||
});
|
||||
|
||||
// Valid happy-path amounts keep the balance properties exercised; the edge
|
||||
// branch (weighted in actionsRoot) stresses parsing with the adversarial corpus.
|
||||
const validAmounts = integers().between(1, 99999);
|
||||
// Valid happy-path amounts keep the balance properties exercised; the `typing`
|
||||
// branch in actionsRoot stresses parsing with the adversarial corpus. Four
|
||||
// authored values rather than a range, because a range cannot be enumerated:
|
||||
// the smallest accepted amount, one with cents, an everyday one, and one wide
|
||||
// enough to format with a thousands separator.
|
||||
const validAmounts = ["1", "12.34", "250", "99999"];
|
||||
|
||||
function typeAmountWith(sampler: { generate(): string }) {
|
||||
return actions(() => {
|
||||
if (!onAddTxnPage.current) return [];
|
||||
const field = txnAmountField.current;
|
||||
return field ? [InputText({ into: field, text: sampler.generate() })] : [];
|
||||
});
|
||||
}
|
||||
const typeAmount = actions(() => {
|
||||
if (!onAddTxnPage.current) return [];
|
||||
const field = txnAmountField.current;
|
||||
if (!field) return [];
|
||||
return validAmounts.map((amount) => InputText({ into: field, text: amount }));
|
||||
});
|
||||
|
||||
const typeAmount = typeAmountWith({ generate: () => String(validAmounts.generate()) });
|
||||
const typeAmountEdge = typeAmountWith(edgeCaseText());
|
||||
|
||||
const noteSampler = from([
|
||||
"Coffee",
|
||||
"Paycheck",
|
||||
"Gas",
|
||||
"Refund",
|
||||
"",
|
||||
"Groceries for the week",
|
||||
]);
|
||||
const notes = ["Coffee", "Paycheck", "Gas", "Refund", "", "Groceries for the week"];
|
||||
|
||||
const typeNote = actions(() => {
|
||||
if (!onAddTxnPage.current) return [];
|
||||
const field = txnNoteField.current;
|
||||
return field ? [InputText({ into: field, text: noteSampler.generate() })] : [];
|
||||
if (!field) return [];
|
||||
return notes.map((note) => InputText({ into: field, text: note }));
|
||||
});
|
||||
|
||||
const toggleTxnType = actions(() => {
|
||||
if (!onAddTxnPage.current) return [];
|
||||
const targets = [txnCreditButton.current, txnDebitButton.current].filter(Boolean);
|
||||
if (targets.length === 0) return [];
|
||||
return [Tap({ on: from(targets).generate() })];
|
||||
const credit = txnCreditButton.current;
|
||||
const debit = txnDebitButton.current;
|
||||
const options = [];
|
||||
if (credit) options.push(Tap({ on: credit }));
|
||||
if (debit) options.push(Tap({ on: debit }));
|
||||
return options;
|
||||
});
|
||||
|
||||
const submitTxn = actions(() => {
|
||||
@@ -274,22 +265,36 @@ const logoutAction = actions(() => {
|
||||
return btn ? [Tap({ on: btn })] : [];
|
||||
});
|
||||
|
||||
// `typing` carries the adversarial input the two authored edge-case leaves used
|
||||
// to: it names the field and leaves the value to whichever policy is driving,
|
||||
// so the seeded picker draws from the edge-case corpus and the model writes its
|
||||
// own text. It holds the 8 weight those two leaves shared, so every other
|
||||
// branch keeps the share it had.
|
||||
export const actionsRoot = weighted(
|
||||
[30, loginHelper],
|
||||
[2, adversarialLogin],
|
||||
[14, openAddAccount],
|
||||
[14, typeAccountName],
|
||||
[4, typeAccountNameEdge],
|
||||
[14, submitAddAccount],
|
||||
[14, openAccount],
|
||||
[12, openAddTxn],
|
||||
[14, typeAmount],
|
||||
[4, typeAmountEdge],
|
||||
[8, typeNote],
|
||||
[6, toggleTxnType],
|
||||
[16, submitTxn],
|
||||
[6, goBack],
|
||||
[1, logoutAction],
|
||||
[4, taps],
|
||||
[8, typing],
|
||||
[2, waitOnce],
|
||||
);
|
||||
|
||||
// The LLM generator is orthogonal to actionsRoot: with `--generator llm` a model
|
||||
// picks from the SAME weighted candidate set above, reading the screenshot and a
|
||||
// numbered, weight-annotated list; the default `--generator seeded` ignores it.
|
||||
// instructions describe only WHAT the app is, never HOW to test it.
|
||||
export const generator = llm({
|
||||
model: "gpt-5.4-nano",
|
||||
instructions:
|
||||
"Folio is a personal-finance ledger app in the browser. Sign in with the demo credentials shown on the login screen. The home screen lists accounts, each with a balance. You can create accounts, open an account to see its ledger, and add credit or debit transactions; each transaction has an amount and changes that account's balance and the overall total.",
|
||||
});
|
||||
Reference in new issue
Block a user