Count the accounts, so Piggy stops counting deals instead
CI / verify (push) Successful in 7m9s
CI / publish (push) Has been skipped

Asked how many accounts were on the book, Piggy answered "7 demand deals
(accounts)". Production holds 17 accounts and 7 demand deals. The number was
real and the payload had scoped it correctly as deals; the prose relabelled
it on the way out.

This is the other half of the scope fix. That one stopped a filtered count
being read as a total. This one is a total that was simply absent being
filled from the nearest available noun: /accounts resolves to the workspace
summary, which carried commitments, deals, margin and idle capacity and no
count of accounts anywhere. The route's own label admitted it — "Piggy reads
the book here, not the account rows" — which named the gap without closing
it, and a model given a question about accounts and a payload with no
account figure will always find something else to count.

So the summary now counts accounts and contacts in SQL, and the headline
leads with them, because the defective answer was assembled from the first
countable thing in that sentence. Archived accounts are excluded to match
what /api/accounts returns — Piggy disagreeing with the list on screen is the
failure that costs the tool its credibility — but they are reported
separately so the difference stays reconcilable. The side breakdown ships
with a note saying the tabs do not partition, since supply and demand tabs
each include "both" and therefore do not sum to the total: that is the next
reconciliation bug, pre-empted.

Five routes that genuinely have no data tool now say so in their guide
rather than naming a subject they cannot reach. Proven live: /accounts
answers 23 of 23; a question about geography is refused rather than guessed;
/team refuses without substituting a nearby number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude
2026-08-14 19:32:54 -07:00
parent f2ef403ee9
commit a3b1298257
5 changed files with 445 additions and 36 deletions
+175 -13
View File
@@ -51,11 +51,26 @@ interface StubBook {
/** The OPEN deals, which is what the row reads in these tools select. */
demandDeals: readonly Record<string, unknown>[];
supplyDeals: readonly Record<string, unknown>[];
/** Every deal on the book, which is what the `count()` reads select. */
counts: { demandDeals: number; supplyDeals: number };
/**
* What the `count()` reads select: every deal on the book, every contact row,
* and the accounts the archive filter removes.
*/
counts: {
demandDeals: number;
supplyDeals: number;
contacts: number;
archivedAccounts: number;
};
accounts?: readonly Record<string, unknown>[];
contacts?: readonly Record<string, unknown>[];
contracts?: readonly Record<string, unknown>[];
/**
* The one grouped count these tools make: accounts per side, archived
* excluded. Fixtured rather than derived from `accounts` above, because the
* stub evaluates no where clause and so cannot tell an archived row from a
* live one — deriving it would quietly test the fixture against itself.
*/
accountsBySide?: readonly { side: string; value: number }[];
}
/**
@@ -68,7 +83,15 @@ interface StubBook {
*/
function stubQuery(rows: readonly unknown[]): Record<string, unknown> {
const builder: Record<string, unknown> = {};
for (const method of ['where', 'limit', 'orderBy', 'leftJoin', 'innerJoin', 'innerJoinLateral']) {
for (const method of [
'where',
'limit',
'orderBy',
'groupBy',
'leftJoin',
'innerJoin',
'innerJoinLateral',
]) {
builder[method] = () => builder;
}
builder.then = (resolve: (value: readonly unknown[]) => unknown) => resolve(rows);
@@ -89,16 +112,23 @@ function stubDatabase(book: StubBook): Database {
const countFor = (table: unknown): number => {
if (table === demandDeals) return book.counts.demandDeals;
if (table === supplyDeals) return book.counts.supplyDeals;
// The only ungrouped count taken against accounts is the archived one; the
// live figure is summed from the grouped read below, so that the total and
// its own breakdown cannot disagree.
if (table === accounts) return book.counts.archivedAccounts;
if (table === contacts) return book.counts.contacts;
return rowsFor(table).length;
};
const select = (projection?: Record<string, unknown>) => ({
from: (table: unknown) => {
// `select({ value: count() })` is the only projection with that shape,
// and it is how every denominator in these tools is read.
const counting =
projection !== undefined &&
Object.keys(projection).length === 1 &&
Object.hasOwn(projection, 'value');
// `count()` always lands in a key called `value`. Alone it is a
// denominator; beside another column it is a grouped count, and accounts
// per side is the only one these tools take.
const counting = projection !== undefined && Object.hasOwn(projection, 'value');
if (counting && Object.keys(projection).length > 1) {
if (table !== accounts) throw new Error('the stub groups counts for accounts only');
return stubQuery(book.accountsBySide ?? []);
}
return stubQuery(counting ? [{ value: countFor(table) }] : rowsFor(table));
},
});
@@ -152,6 +182,27 @@ const BOOK = [
const LIVE_COMMITMENTS = BOOK.length;
const IDLE_BLOCKS = 3;
/**
* The account book, sized as production was when it was measured.
*
* Production held 17 accounts and 7 demand deals, and Piggy answered "The book
* contains 7 demand deals (accounts) in total" to a question about accounts. So
* the fixture keeps the two apart by more than an accident of arithmetic: 17 is
* not the size of any deal figure, any commitment figure or any list in this
* suite, and a payload that reports it can only have got it from the account
* count. `both` is present because the sides must partition the book — 9 + 7 + 1
* is 17, and an account that trades on each side is counted once.
*/
const ACCOUNTS_BY_SIDE = [
{ side: 'supply', value: 9 },
{ side: 'demand', value: 7 },
{ side: 'both', value: 1 },
];
const ACCOUNTS_ON_BOOK = 17;
/** Archived, so on no screen and in no total. The gap is still counted. */
const ARCHIVED_ACCOUNTS = 2;
const CONTACTS = 42;
const stub = stubDatabase({
commitments: BOOK.map(({ sold: _sold, ...row }) => row),
allocations: BOOK.filter((row) => row.sold > 0).map((row) => allocation(row.id, row.sold)),
@@ -171,15 +222,23 @@ const stub = stubDatabase({
gpuCount: 64,
targetCostPerGpuHourCents: 189,
})),
counts: { demandDeals: 13, supplyDeals: 8 },
counts: {
demandDeals: 13,
supplyDeals: 8,
contacts: CONTACTS,
archivedAccounts: ARCHIVED_ACCOUNTS,
},
accounts: [{ id: 'acct', name: 'DEMO — Halcyon Research' }],
contacts: [{ id: 'contact-1', accountId: 'acct', fullName: 'A Person' }],
contracts: [{ id: 'contract-1', accountId: 'acct', title: 'DEMO — MSA' }],
accountsBySide: ACCOUNTS_BY_SIDE,
});
type Reading = Record<string, unknown> & { headline?: string; scope?: ResultScope };
async function read(route: '/margin' | '/capacity' | '/demand' | '/'): Promise<Reading> {
async function read(
route: '/margin' | '/capacity' | '/demand' | '/' | '/accounts' | '/team',
): Promise<Reading> {
const [tool] = createPagePigTools(stub, route);
assert.ok(tool, `no tool for ${route}`);
return (await tool.execute({})) as Reading;
@@ -347,12 +406,115 @@ test('the workspace summary counts open deals against every deal on the book', a
assert.equal((reading.openSupplyDealsScope as ResultScope).total, 8);
});
// ---------------------------------------------------------------------------
// The missing denominator
// ---------------------------------------------------------------------------
/** Both keys the workspace summary carries its party counts under. */
interface Parties {
accounts: {
scope: ResultScope;
onBook: number;
archived: number;
bySide: Record<string, number>;
bySideNote: string;
};
contacts: { scope: ResultScope; total: number };
}
async function parties(route: '/' | '/accounts' | '/team'): Promise<Parties> {
return (await read(route)) as unknown as Parties;
}
test('the workspace summary counts the accounts and contacts on the book', async () => {
const { accounts: book, contacts: people } = await parties('/');
assert.equal(book.onBook, ACCOUNTS_ON_BOOK);
assert.equal(people.total, CONTACTS);
// Nothing was filtered out of either, so `matched` IS the total: these are
// answers to "how many are there", not counts that need a denominator.
assert.equal(book.scope.matched, ACCOUNTS_ON_BOOK);
assert.equal(book.scope.total, ACCOUNTS_ON_BOOK);
assert.equal(people.scope.total, CONTACTS);
// The label is what the grounding rule tells the model to read the figure
// against, so it has to name the noun the question would use.
assert.match(book.scope.totalLabel, /account\(s\)/);
assert.match(people.scope.totalLabel, /contact\(s\)/);
assert.match(book.scope.summary, /All 17 account\(s\) on the book/);
assert.match(people.scope.summary, /All 42 contact\(s\) in the CRM/);
});
test('the headline states the account count, because the headline is what gets quoted', async () => {
const reading = await read('/');
const headline = String(reading.headline);
// The production answer was assembled from the first countable thing in this
// sentence. There is now an account figure in it, and it is first.
assert.match(headline, /^17 account\(s\) on the book/);
assert.match(headline, /42 contact\(s\) in the CRM/);
// A count of deals is not a count of accounts, and no deal figure in this
// fixture can be mistaken for one.
for (const dealFigure of [13, 8, 4, 2]) {
assert.notEqual(ACCOUNTS_ON_BOOK, dealFigure);
}
});
test('the sides partition the account book rather than overlapping it', async () => {
const { accounts: book } = await parties('/');
// Every side present, at zero if need be: an absent key reads as "not known"
// to a model quoting the payload.
assert.deepEqual(book.bySide, { supply: 9, demand: 7, both: 1 });
const summed = Object.values(book.bySide).reduce((sum, value) => sum + value, 0);
assert.equal(summed, ACCOUNTS_ON_BOOK);
// A breakdown that disagrees with the /accounts side tabs, quoted beside that
// screen, is the next version of this bug. The note is what reconciles them:
// the tabs match `side = X or both`, so they overlap and do not sum.
assert.match(book.bySideNote, /counted once, under both/);
assert.match(book.bySideNote, /do not sum/);
});
test('archived accounts are off the total and still counted', async () => {
const reading = await read('/');
const { accounts: book } = await parties('/');
// The /accounts list excludes them, so the total that answers "how many
// accounts are on the book" must exclude them too — Piggy disagreeing with
// the list on screen is worse than Piggy knowing less than it does.
assert.equal(book.archived, ARCHIVED_ACCOUNTS);
assert.equal(book.onBook, ACCOUNTS_ON_BOOK);
assert.notEqual(book.onBook, ACCOUNTS_ON_BOOK + ARCHIVED_ACCOUNTS);
// Excluded, but not invisible: a figure that differs from a raw table count
// has to be reconcilable from the payload alone.
assert.match(String(reading.headline), /a further 2 account\(s\) archived and off the book/);
});
test('/accounts is given a tool that can answer how many accounts there are', async () => {
// The measured defect, at the route it was measured on. Asked "How many
// accounts are on the book in total?" here, Piggy answered "The book contains
// 7 demand deals (accounts) in total" — a real figure, correctly scoped as
// deals by the payload, relabelled as accounts in the prose, because no
// account figure existed anywhere in the result it was handed.
const { accounts: book } = await parties('/accounts');
assert.equal(book.onBook, ACCOUNTS_ON_BOOK);
assert.match(book.scope.summary, /account\(s\) on the book/);
});
test('a page with no data tool still gets the book denominators, never nothing', async () => {
// /team has no tool of its own and falls through to the summary. It must not
// arrive with a payload that is silent about every noun: the guide tells the
// model it can see no users, and the counts it CAN see are all labelled.
const { accounts: book, contacts: people } = await parties('/team');
assert.equal(book.onBook, ACCOUNTS_ON_BOOK);
assert.equal(people.total, CONTACTS);
});
// ---------------------------------------------------------------------------
// The sweep
// ---------------------------------------------------------------------------
test('every page tool result carries at least one scope, and every scope is complete', async () => {
for (const route of ['/margin', '/capacity', '/demand', '/'] as const) {
for (const route of ['/margin', '/capacity', '/demand', '/', '/accounts'] as const) {
const reading = await read(route);
const found = scopes(reading);
assert.ok(found.length > 0, `${route} returned a result with no scope at all`);
@@ -378,7 +540,7 @@ test('every page tool result carries at least one scope, and every scope is comp
});
test('no page headline reports a filtered count without the total beside it', async () => {
for (const route of ['/margin', '/capacity', '/demand', '/'] as const) {
for (const route of ['/margin', '/capacity', '/demand', '/', '/accounts'] as const) {
const reading = await read(route);
const headline = String(reading.headline);
for (const scope of scopes(reading)) {