fix(folio): only disambiguate counts that came from merged text

the equal-length digit rule exists because web merges the card and an
account named -1 makes '12' ambiguous. a dedicated count node has
nothing to disambiguate, so applying it there threw away real evidence.
This commit is contained in:
pj committed 2026-08-14 19:03:21 +05:30
1 parent 9efed1f669
commit 21ae7fde83
4 files changed
+214 -48

No files matched your search

+61 -27
View File
@@ -211,27 +211,45 @@ export function cardAccountName(args: {
return head.slice(0, label.index).trim(); return head.slice(0, label.index).trim();
} }
// The digit run in front of a card's "transaction(s)" label, kept as TEXT. // One card's transaction count, in the strongest form its SOURCE supports. The
// two forms are the whole reason this is not just a number:
// //
// A string rather than a number because web merges the card into one node and // - a NUMBER when the count came from the card's own AccountTxnCount node.
// an account whose name ends in digits runs them into the count: the account // That node's text is the count label and nothing else, so its digits are
// named "-1" holding 2 transactions merges to "-1-12 transactions", whose // the count and subtracting two readings is exact arithmetic;
// maximal digit run reads 12. That prefix is fixed for a given account, so two // - the digit RUN as TEXT when the count had to be recovered from merged card
// readings whose runs are the SAME LENGTH still differ by exactly the true // text. Web merges the AccountCard subtree into one node, and an account
// difference (19 to 120 is impossible; 19 to 110 is a length change). Two runs // whose name ends in digits runs them into the count: the account named
// of different lengths do not, and 9 to 10 would read as 19 to 110, a delta of // "-1" holding 2 transactions merges to "-1-12 transactions", whose maximal
// 91 out of a delta of 1. Keeping the run as text is what lets the predicate // digit run reads 12. That prefix is fixed for a given account, so two
// see the length change and drop the pair instead of convicting on it. // readings whose runs are the SAME LENGTH still differ by exactly the true
// difference (19 to 120 is impossible; 19 to 110 is a length change), while
// two of different lengths do not: 9 to 10 reads as 19 to 110, a delta of
// 91 out of a delta of 1. Keeping the run as text is what lets
// committedTransactionsExceedSubmits see the length change and drop the
// pair instead of convicting on it.
// //
// Android and iOS expose AccountTxnCount as its own node, where the run is just // Carrying the distinction is what keeps that length rule where it belongs.
// the count and the length rule costs nothing but a window per decade. // There is no name in front of a dedicated node's digits for it to protect
export function cardTxnCountDigits(args: { // against, so applied there it buys nothing and throws away real evidence every
// time an account crosses a decade: of the three android seed-9 runs that
// finished without convicting, two had dropped a window on this rule, one
// reading 7 against 12 and the other 4 against 10.
//
// This is a fact about the reading, not about the platform. A platform that
// starts exposing the node gets exact counts by exposing it, and one that stops
// falls back to the text rule on the same step it stops.
export type TxnCount = number | string;
export function cardTxnCount(args: {
childText: string | undefined; childText: string | undefined;
cardText: string | undefined; cardText: string | undefined;
}): string | undefined { }): TxnCount | undefined {
const { childText, cardText } = args; const { childText, cardText } = args;
const source = childText ?? cardText?.replace(TRAILING_BALANCE, ""); const source = childText ?? cardText?.replace(TRAILING_BALANCE, "");
return source?.match(TRAILING_TXN_COUNT)?.[1]; const digits = source?.match(TRAILING_TXN_COUNT)?.[1];
if (digits === undefined) return undefined;
return childText === undefined ? digits : parseInt(digits, 10);
} }
export interface Account { export interface Account {
@@ -243,7 +261,7 @@ export interface Account {
// One Home card, already parsed by the three helpers above. // One Home card, already parsed by the three helpers above.
export interface CardReading extends Account { export interface CardReading extends Account {
digits: string | undefined; count: TxnCount | undefined;
} }
// The two readings Home's card list yields, each null when there is nothing in // The two readings Home's card list yields, each null when there is nothing in
@@ -262,10 +280,10 @@ export function homeAccountsOf(cards: readonly CardReading[]): Account[] | null
// A card whose name or count is unreadable is left out rather than guessed at; // A card whose name or count is unreadable is left out rather than guessed at;
// committedTransactionsExceedSubmits treats a missing account as no evidence. // committedTransactionsExceedSubmits treats a missing account as no evidence.
// Every card being unreadable leaves nothing to compare, which is unknown. // Every card being unreadable leaves nothing to compare, which is unknown.
export function homeTxnCountsOf(cards: readonly CardReading[]): Record<string, string> | null { export function homeTxnCountsOf(cards: readonly CardReading[]): Record<string, TxnCount> | null {
const counts: Record<string, string> = {}; const counts: Record<string, TxnCount> = {};
for (const card of cards) { for (const card of cards) {
if (card.name !== "" && card.digits !== undefined) counts[card.name] = card.digits; if (card.name !== "" && card.count !== undefined) counts[card.name] = card.count;
} }
return Object.keys(counts).length === 0 ? null : counts; return Object.keys(counts).length === 0 ? null : counts;
} }
@@ -329,8 +347,8 @@ export function createdAccountHasNonZeroBalance(args: {
// per-account count only ever rises; the max(0, ...) is defensive, not load // per-account count only ever rises; the max(0, ...) is defensive, not load
// bearing. // bearing.
export function committedTransactionsExceedSubmits(args: { export function committedTransactionsExceedSubmits(args: {
countsBefore: Record<string, string> | null; countsBefore: Record<string, TxnCount> | null;
countsAfter: Record<string, string> | null; countsAfter: Record<string, TxnCount> | null;
submitsInWindow: number; submitsInWindow: number;
}): boolean { }): boolean {
const { countsBefore, countsAfter, submitsInWindow } = args; const { countsBefore, countsAfter, submitsInWindow } = args;
@@ -341,16 +359,32 @@ export function committedTransactionsExceedSubmits(args: {
const before = countsBefore[name]; const before = countsBefore[name];
const after = countsAfter[name]; const after = countsAfter[name];
if (before === undefined || after === undefined) continue; if (before === undefined || after === undefined) continue;
// Different run lengths are not comparable: see cardTxnCountDigits. const rise = countRise(before, after);
if (before.length !== after.length) continue; if (rise !== null && rise > 0) committed += rise;
const from = parseInt(before, 10);
const to = parseInt(after, 10);
if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to)) continue;
if (to > from) committed += to - from;
} }
return committed > submitsInWindow; return committed > submitsInWindow;
} }
// How far one account's count rose between two readings, or null when the pair
// is not comparable. Not comparable is not zero: the account drops out of the
// sum entirely, which can only cost a detection.
function countRise(before: TxnCount, after: TxnCount): number | null {
if (typeof before === "number" && typeof after === "number") {
if (!Number.isSafeInteger(before) || !Number.isSafeInteger(after)) return null;
return after - before;
}
// Recovered from merged card text, where the run may carry an account-name
// prefix, so only equal-length runs subtract to the true difference: see
// TxnCount. A pair whose two readings came from different sources is one
// neither rule can vouch for, and it is dropped with them.
if (typeof before !== "string" || typeof after !== "string") return null;
if (before.length !== after.length) return null;
const from = parseInt(before, 10);
const to = parseInt(after, 10);
if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to)) return null;
return to - from;
}
// Parses raw user input in the transaction amount field into integer cents. // Parses raw user input in the transaction amount field into integer cents.
// Mirrors the Folio app's parseCents (app/shared/.../util/Format.kt): whole // Mirrors the Folio app's parseCents (app/shared/.../util/Format.kt): whole
// numbers like "50" become 5000 cents, decimals like "5.50" become 550, more // numbers like "50" become 5000 cents, decimals like "5.50" become 550, more
+5 -5
View File
@@ -16,7 +16,7 @@ import { defaultActions, doubleTaps } from "@sanderling/spec/defaults";
import { import {
cardAccountName, cardAccountName,
cardBalanceText, cardBalanceText,
cardTxnCountDigits, cardTxnCount,
committedTransactionsExceedSubmits, committedTransactionsExceedSubmits,
countSubmitsInWindow, countSubmitsInWindow,
createdAccountHasNonZeroBalance, createdAccountHasNonZeroBalance,
@@ -29,7 +29,7 @@ import {
routeOfFrame, routeOfFrame,
submitChangesBalanceByTypedAmount, submitChangesBalanceByTypedAmount,
} from "./predicates"; } from "./predicates";
import type { Account, CardReading } from "./predicates"; import type { Account, CardReading, TxnCount } from "./predicates";
// Screen markers, and the route each one names. Detection is by testTag // Screen markers, and the route each one names. Detection is by testTag
// (resource-id on Android, accessibilityIdentifier on iOS). // (resource-id on Android, accessibilityIdentifier on iOS).
@@ -84,7 +84,7 @@ const homeCards = (s: State): CardReading[] =>
childText: card.find({ testTag: "AccountBalance" })?.text, childText: card.find({ testTag: "AccountBalance" })?.text,
cardText: card.text, cardText: card.text,
})), })),
digits: cardTxnCountDigits({ count: cardTxnCount({
childText: card.find({ testTag: "AccountTxnCount" })?.text, childText: card.find({ testTag: "AccountTxnCount" })?.text,
cardText: card.text, cardText: card.text,
}), }),
@@ -144,8 +144,8 @@ const accounts = extract<Account[] | null>("accounts", s => {
}); });
// Transactions committed per account, same carrier rule. // Transactions committed per account, same carrier rule.
let lastHomeTxnCounts: Record<string, string> | null = null; let lastHomeTxnCounts: Record<string, TxnCount> | null = null;
const homeTxnCounts = extract<Record<string, string> | null>("homeTxnCounts", s => { const homeTxnCounts = extract<Record<string, TxnCount> | null>("homeTxnCounts", s => {
const reading = readHomeCards({ const reading = readHomeCards({
route: routeOf(s), route: routeOf(s),
reading: homeTxnCountsOf(homeCards(s)), reading: homeTxnCountsOf(homeCards(s)),
@@ -11,12 +11,13 @@ import {
import type { import type {
CardReading, CardReading,
HomeCardReading, HomeCardReading,
TxnCount,
} from "../../../examples/folio/sanderling/predicates.ts"; } from "../../../examples/folio/sanderling/predicates.ts";
const card = (name: string, balance: number | null, digits: string | undefined) => ({ const card = (name: string, balance: number | null, count: TxnCount | undefined) => ({
name, name,
balance, balance,
digits, count,
}); });
test("a laid-out card list reads as an account list and a count map", () => { test("a laid-out card list reads as an account list and a count map", () => {
@@ -83,11 +84,11 @@ test("an un-laid-out Home reports unknown but leaves the carrier intact", () =>
// Home it lands on has not drawn its list yet, and the counting invariant must // Home it lands on has not drawn its list yet, and the counting invariant must
// still be able to see the pair once a real Home comes back. // still be able to see the pair once a real Home comes back.
function run(steps: { route: string | null; cards: CardReading[]; lastAction: unknown }[]) { function run(steps: { route: string | null; cards: CardReading[]; lastAction: unknown }[]) {
let carrier: Record<string, string> | null = null; let carrier: Record<string, TxnCount> | null = null;
let submits = 0; let submits = 0;
const out: { counts: Record<string, string> | null; submits: number }[] = []; const out: { counts: Record<string, TxnCount> | null; submits: number }[] = [];
for (const step of steps) { for (const step of steps) {
const reading: HomeCardReading<Record<string, string>> = readHomeCards({ const reading: HomeCardReading<Record<string, TxnCount>> = readHomeCards({
route: step.route, route: step.route,
reading: homeTxnCountsOf(step.cards), reading: homeTxnCountsOf(step.cards),
previousCarrier: carrier, previousCarrier: carrier,
+142 -11
View File
@@ -2,29 +2,32 @@ import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { import {
cardTxnCountDigits, cardTxnCount,
committedTransactionsExceedSubmits, committedTransactionsExceedSubmits,
homeTxnCountsOf,
} from "../../../examples/folio/sanderling/predicates.ts"; } from "../../../examples/folio/sanderling/predicates.ts";
// Android and iOS give the count its own node; web merges the card into one // Android and iOS give the count its own node; web merges the card into one
// string, where the count sits between the name and the balance. // string, where the count sits between the name and the balance. The reading
test("structured child wins over the merged card text", () => { // carries which of the two it came from: a number is a count nothing else could
// have leaked into, a string is a digit run that may have.
test("a dedicated count node reads as a number, not a digit run", () => {
assert.equal( assert.equal(
cardTxnCountDigits({ childText: "12 transactions", cardText: "INInvestments12 transactions$2,589.00" }), cardTxnCount({ childText: "12 transactions", cardText: "INInvestments12 transactions$2,589.00" }),
"12", 12,
); );
}); });
test("merged card text: the count is taken from in front of the balance", () => { test("merged card text: the count is taken from in front of the balance", () => {
assert.equal( assert.equal(
cardTxnCountDigits({ childText: undefined, cardText: "INInvestments12 transactions$2,589.00" }), cardTxnCount({ childText: undefined, cardText: "INInvestments12 transactions$2,589.00" }),
"12", "12",
); );
}); });
test("merged card text: the singular label parses too", () => { test("merged card text: the singular label parses too", () => {
assert.equal( assert.equal(
cardTxnCountDigits({ childText: undefined, cardText: "SASavings1 transaction$118.00" }), cardTxnCount({ childText: undefined, cardText: "SASavings1 transaction$118.00" }),
"1", "1",
); );
}); });
@@ -32,16 +35,16 @@ test("merged card text: the singular label parses too", () => {
// The balance has to come off first, or a name ending in digits would be read // The balance has to come off first, or a name ending in digits would be read
// as the count. // as the count.
test("a card with no readable count is unknown, not zero", () => { test("a card with no readable count is unknown, not zero", () => {
assert.equal(cardTxnCountDigits({ childText: undefined, cardText: undefined }), undefined); assert.equal(cardTxnCount({ childText: undefined, cardText: undefined }), undefined);
assert.equal(cardTxnCountDigits({ childText: undefined, cardText: "no digits here" }), undefined); assert.equal(cardTxnCount({ childText: undefined, cardText: "no digits here" }), undefined);
assert.equal(cardTxnCountDigits({ childText: "", cardText: "AA" + "a".repeat(198) }), undefined); assert.equal(cardTxnCount({ childText: "", cardText: "AA" + "a".repeat(198) }), undefined);
}); });
// Measured on a real web run: the account named "-1" holding 2 transactions // Measured on a real web run: the account named "-1" holding 2 transactions
// merges to "-1-12 transactions-$119.00", and the maximal digit run reads 12. // merges to "-1-12 transactions-$119.00", and the maximal digit run reads 12.
test("merged text runs a digit-ending name into the count", () => { test("merged text runs a digit-ending name into the count", () => {
assert.equal( assert.equal(
cardTxnCountDigits({ childText: undefined, cardText: "-1-12 transactions-$119.00" }), cardTxnCount({ childText: undefined, cardText: "-1-12 transactions-$119.00" }),
"12", "12",
); );
}); });
@@ -229,3 +232,131 @@ test("an unreadably long run is not evidence", () => {
false, false,
); );
}); });
// The other side of that rule, and the reason it is scoped to merged text: a
// count read off its own node has no account name in front of it, so its digits
// ARE the count and a decade crossing is just a number getting longer. Both
// windows below are real android seed-9 readings that the unscoped length rule
// threw away, in runs that then finished clean.
test("a dedicated node's count crossing a decade is usable evidence", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { Checking: 7 },
countsAfter: { Checking: 12 },
submitsInWindow: 1,
}),
true,
);
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { Savings: 4 },
countsAfter: { Savings: 10 },
submitsInWindow: 1,
}),
true,
);
});
// Recovering the window is only worth anything if it still acquits the healthy
// case, so the same crossing under a submit that earned it must not fire.
test("a dedicated node's healthy decade crossing does not convict", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { Checking: 9 },
countsAfter: { Checking: 10 },
submitsInWindow: 1,
}),
false,
);
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { Checking: 9 },
countsAfter: { Checking: 11 },
submitsInWindow: 1,
}),
true,
);
});
// The same numbers off merged text, where the digits may not be the count at
// all: still dropped.
test("the merged-text equivalent of that crossing is still dropped", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { Checking: "9" },
countsAfter: { Checking: "10" },
submitsInWindow: 0,
}),
false,
);
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { Checking: "7" },
countsAfter: { Checking: "12" },
submitsInWindow: 1,
}),
false,
);
});
// The boundary itself. A pair whose two readings came from different sources is
// vouched for by neither rule: the string may carry a name prefix the number
// does not, so subtracting them is not a transaction count.
test("a pair straddling the two sources is not comparable", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { Checking: 7 },
countsAfter: { Checking: "12" },
submitsInWindow: 1,
}),
false,
);
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { Checking: "7" },
countsAfter: { Checking: 12 },
submitsInWindow: 1,
}),
false,
);
});
// End to end from the two accessibility shapes, which is where the distinction
// is actually made: the same account, the same true counts, read once off a
// dedicated node and once off merged card text.
const dedicated = (name: string, count: number) => ({
name,
balance: 0,
count: cardTxnCount({ childText: `${count} transactions`, cardText: undefined }),
});
const merged = (initials: string, name: string, count: number) => ({
name,
balance: 0,
count: cardTxnCount({
childText: undefined,
cardText: `${initials}${name}${count} transactions$0.00`,
}),
});
test("a dedicated-node card list convicts across a decade", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: homeTxnCountsOf([dedicated("Checking", 9)]),
countsAfter: homeTxnCountsOf([dedicated("Checking", 11)]),
submitsInWindow: 1,
}),
true,
);
});
test("the merged-text card list drops the same pair", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: homeTxnCountsOf([merged("CH", "Checking", 9)]),
countsAfter: homeTxnCountsOf([merged("CH", "Checking", 11)]),
submitsInWindow: 1,
}),
false,
);
});