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
+10
View File
@@ -77,12 +77,22 @@ const DOMAIN_BRIEFING = `How this business works, so the figures mean what you s
* `totalLabel`, so the instruction has a field to point at rather than a
* principle to hold — and that is the only form of this rule that has survived
* contact with a 30B model. Terse on purpose: it rides on every request.
*
* The `totalLabel` bullet is the same lesson learnt from the other direction.
* Measured in production on /accounts: asked how many accounts were on the
* book, nemotron quoted the one count in front of it — seven demand deals — and
* wrote "7 demand deals (accounts)". The substitution is fixed in the payload,
* where the summary now counts accounts; the bullet exists for the pages that
* still have no figure for what is being asked, because there the only correct
* answer is a refusal and the model needs a test it can apply to reach one.
* One line, naming the field and the failure, and no more.
*/
const GROUNDING_RULE = `Grounding, which overrides everything else:
- NEVER state a number, name, date or status about this business unless it appeared in a tool result in THIS conversation. Not from memory, not from what a figure "should" be, not by inference from the page you are on.
- If the tool you were given does not answer the question, do not guess and do not stop: pig_search_records finds a record by name and pig_get_record_by_id opens it. Reach for those before concluding anything.
- Every result says what it covers. Read its scope object first: matched is how many passed a filter, total is the whole set they were drawn from, totalLabel names what total counts, listed is how many rows the payload carries, filters names every threshold applied.
- Asked how many there are, quote total, never matched and never the length of a list you can see. matched answers "how many are unsold" or "how many match"; it is never the size of the book. If total does not cover the question as asked, say what the result does cover and what is missing.
- Every figure counts the noun in its own totalLabel and no other. If nothing in the result counts the thing you were asked about, say it is not available — never answer with a figure labelled as something else. A count of deals is not a count of accounts.
- Two tools can report different counts of the same thing because they applied different thresholds. Say which threshold produced the figure you quote; it is in filters.
- If no tool can answer it, say exactly that and name what you would need. "I cannot see that from here" is a correct answer. An invented figure is not, and is worse than silence — someone will act on it.
- Never use LaTeX or mathematical notation. No \\boxed{}, no \\(...\\). Write plain prose and plain numbers.`;
+101 -14
View File
@@ -62,19 +62,50 @@ const GUIDES: Partial<Record<PiggyPageRoute, PiggyPageGuide>> = {
label: 'the growth view — attention-ranked accounts, and the idle supply behind them',
tool: 'pig_get_idle_capacity',
},
'/margin': { label: 'the margin report, commitment by commitment', tool: 'pig_get_margin_summary' },
'/calendar': { label: 'the calendar of dated work', tool: 'pig_get_calendar_ahead' },
'/capacity': { label: 'the capacity book', tool: 'pig_get_idle_capacity' },
/*
* "Commitment by commitment" was a promise the tool does not keep: it returns
* book totals and the eight largest blocks by cost, so a question about the
* ninth is answered from a list that does not contain it.
*/
'/margin': {
label: 'the margin report — book totals, and the largest commitments by cost',
tool: 'pig_get_margin_summary',
},
/*
* A window, not the calendar. `pig_get_calendar_ahead` projects the next 30
* days by default and what has lapsed in the last 90; anything dated outside
* that is not in the payload at all, and "the calendar of dated work" invited
* the model to report the window as the whole of it.
*/
'/calendar': {
label: 'the calendar of dated work — Piggy reads a window of it, not the whole calendar',
tool: 'pig_get_calendar_ahead',
},
/*
* The tool lists only the blocks that are at least 25% unsold. It carries the
* size of the book beside them now, so the count is safe, but the rows are
* still the idle ones and the label should not promise the book.
*/
'/capacity': {
label: 'the capacity book — Piggy reads the idle blocks and how many commitments are live',
tool: 'pig_get_idle_capacity',
},
'/demand': { label: 'the demand pipeline board', tool: 'pig_get_pipeline' },
'/supply': { label: 'the supply pipeline board', tool: 'pig_get_pipeline' },
/*
* No page tool reads account rows, so this is the fallback said out loud.
* Told it is "looking at the accounts list" and handed book totals, the model
* answered questions about accounts from utilisation and margin; naming the
* gap is what makes it say the row is not available instead.
* This label used to say Piggy could not read accounts at all, which was true
* of the tool and produced the defect anyway. Measured in production: asked
* "How many accounts are on the book in total?" here, Piggy answered "The book
* contains 7 demand deals (accounts) in total" — the book held 17 accounts and
* 7 demand deals. A label admitting a gap does not stop a model filling it; it
* only tells the model which gap to fill. So the summary now counts accounts
* and contacts, and the label promises exactly that and no more: the counts
* are there, the rows are not, and `pig_search_records` is how a row is found.
*/
'/accounts': {
label: 'the accounts list — Piggy reads the book here, not the account rows',
label:
'the accounts list — Piggy reads how many accounts (by side) and contacts are on the ' +
'book, not the rows themselves',
tool: 'pig_get_workspace_summary',
},
/*
@@ -97,13 +128,69 @@ const GUIDES: Partial<Record<PiggyPageRoute, PiggyPageGuide>> = {
label: 'the contracts list — Piggy reads its dates here, not its terms',
tool: 'pig_get_calendar_ahead',
},
'/imports': { label: 'the CSV import page', tool: 'pig_get_workspace_summary' },
'/team': { label: 'the team and permissions page', tool: 'pig_get_workspace_summary' },
'/facts': { label: 'the fact review queue', tool: 'pig_get_workspace_summary' },
'/settings': { label: 'the settings page', tool: 'pig_get_workspace_summary' },
'/piggy': { label: 'the full-page Piggy chat', tool: 'pig_get_workspace_summary' },
/*
* Four pages with no data tool of their own, and the four labels that were
* most dangerous: each named a subject — imports, the team, the fact queue,
* the settings — while handing the model book totals about something else
* entirely. That is precisely the shape that produced the /accounts answer,
* where a figure about deals was relabelled as a figure about accounts, and
* here there is no figure to add: nothing in the workspace summary counts an
* import run, a person, a pending fact or a setting.
*
* So each label states the refusal rather than the subject. "I cannot see that
* from here" is an answer the grounding rule already sanctions; what it needed
* was something specific enough to recognise the question by.
*/
'/imports': {
label:
'the CSV import page — Piggy can see book totals only, and nothing about import runs, ' +
'column mappings or file contents',
tool: 'pig_get_workspace_summary',
},
'/team': {
label:
'the team and permissions page — Piggy can see book totals only, and no users, roles, ' +
'invitations or permissions at all',
tool: 'pig_get_workspace_summary',
},
'/facts': {
label:
'the fact review queue — Piggy can see book totals only, and no facts and no count of ' +
'what is pending review',
tool: 'pig_get_workspace_summary',
},
'/settings': {
label:
'the settings page — Piggy can see book totals only, and no settings, integrations, ' +
'API keys or connected accounts',
tool: 'pig_get_workspace_summary',
},
/*
* The one route whose label promises nothing about a page, because there is no
* page behind it: the full-page chat is wherever the conversation goes. The
* summary is the widest tool available, so naming what it covers is the only
* useful thing to say here.
*/
'/piggy': {
label: 'the full-page Piggy chat, with the book-level workspace summary behind it',
tool: 'pig_get_workspace_summary',
},
};
/**
* The fallback carries the same warning the four data-less pages carry.
*
* A route in `PIGGY_PAGE_ROUTES` with no entry above — /learn today, and every
* page added later — was described to the model as "the /learn page" and handed
* the workspace summary, which is the /accounts failure with a different noun.
* A generic label cannot say what the page holds, but it can say what the tool
* does not, and that is the half that stops an answer being invented.
*/
export function piggyPageGuide(route: PiggyPageRoute): PiggyPageGuide {
return GUIDES[route] ?? { label: `the ${route} page`, tool: 'pig_get_workspace_summary' };
return (
GUIDES[route] ?? {
label: `the ${route} page — Piggy can see book totals only, and nothing that is on this page`,
tool: 'pig_get_workspace_summary',
}
);
}
+155 -6
View File
@@ -26,6 +26,7 @@
* leaves this process.
*/
import {
ACCOUNT_SIDES,
CONSUMING_ALLOCATION_STATUSES,
DEMAND_OPEN_STAGES,
RESERVING_ALLOCATION_STATUSES,
@@ -41,14 +42,16 @@ import {
type PiggyPageRoute,
} from '@pig/core';
import {
accounts,
allocations,
capacityCommitments,
contacts,
demandDeals,
supplyDeals,
type Database,
} from '@pig/db';
import { CalendarService } from '@pig/api/src/services/calendar';
import { and, count, gte, inArray, isNull } from 'drizzle-orm';
import { and, count, gte, inArray, isNotNull, isNull } from 'drizzle-orm';
import { z } from 'zod';
import { piggyPageGuide, type PiggyPageToolName } from './page-routes';
import { defineTool, type AgentTool } from './provider';
@@ -182,6 +185,10 @@ const COMMITMENTS_LABEL = 'live capacity commitment(s) on the book';
const DEMAND_DEALS_LABEL = 'demand deal(s) on the book';
const SUPPLY_DEALS_LABEL = 'supply deal(s) on the book';
/** The denominators the two party tables are drawn from. */
const ACCOUNTS_LABEL = 'account(s) on the book';
const CONTACTS_LABEL = 'contact(s) in the CRM';
/** One whole-table count, for use as a denominator. */
function rowCount(rows: readonly { value: number }[]): number {
return rows[0]?.value ?? 0;
@@ -264,9 +271,11 @@ function pageTool(db: Database, name: PiggyPageToolName): AgentTool {
return defineTool({
name,
description:
'Read a bounded overview of the PIG workspace: book margin and utilisation, open ' +
'deal counts on both sides, and the worst idle capacity. This cannot inspect the ' +
'filesystem or external systems.',
'Read a bounded overview of the PIG workspace: how many accounts (by side) and ' +
'contacts are on the book, book margin and utilisation, how many deals exist and how ' +
'many are open on each side, and the worst idle capacity. Counts only — it returns no ' +
'account, contact or deal rows, and it cannot see the team, settings, imports or ' +
'facts. This cannot inspect the filesystem or external systems.',
inputSchema: noInput,
execute: async () => readWorkspaceSummary(db),
});
@@ -814,6 +823,94 @@ function countByState(events: readonly CalendarEvent[]): Record<string, number>
return counts;
}
// ---------------------------------------------------------------------------
// The parties
// ---------------------------------------------------------------------------
interface BookParties {
/** What the /accounts list shows: every account that is not archived. */
onBook: number;
/** Archived accounts, excluded from `onBook` and counted so the gap is visible. */
archived: number;
/** The book partitioned by side. The three buckets sum to `onBook`. */
bySide: Record<string, number>;
/** Every contact row, which is what the contacts tab lists. */
contacts: number;
}
/**
* How many accounts and contacts the book holds.
*
* Measured in production on /accounts, minutes before this was written. Asked
* "How many accounts are on the book in total? One sentence.", Piggy answered
* "The book contains 7 demand deals (accounts) in total." The book held 17
* accounts and 7 demand deals — so the figure was real, the payload had
* correctly scoped it as deals, and the prose relabelled it as accounts.
*
* This tool is the fallback for /accounts and five other routes, and it carried
* commitments, deals, margin and idle hours: no count of accounts or contacts
* anywhere. Asked about accounts with no account figure in front of it, the
* model reached for the nearest countable thing. That is the sibling of the
* defect `ResultScope` was built for — one substitutes the size of a filter for
* a total, this one substitutes another noun's total for a total that is simply
* absent — and the cure for an absent number is not a firmer instruction. It is
* the number.
*
* Counted in SQL rather than by measuring a list, so these figures are exact
* and cannot truncate; every other count in this file rides on a capped read.
*
* Archived accounts are excluded because `/api/accounts` excludes them, and
* Piggy contradicting the list the user is looking at is the failure that costs
* the tool its credibility. They are counted rather than silently dropped, so a
* figure that differs from a raw table count can still be reconciled. Contacts
* are deliberately NOT filtered the same way: `/api/contacts` applies no archive
* filter, so every contact row is the denominator that matches the screen.
*/
async function readParties(db: Database): Promise<BookParties> {
const [sides, archived, contactRows] = await Promise.all([
db
.select({ side: accounts.side, value: count() })
.from(accounts)
.where(isNull(accounts.archivedAt))
.groupBy(accounts.side),
db.select({ value: count() }).from(accounts).where(isNotNull(accounts.archivedAt)),
db.select({ value: count() }).from(contacts),
]);
// Every side is present at zero rather than absent: a missing key reads as
// "not known" to a model quoting the payload, and this breakdown is only
// trustworthy if it visibly adds up.
const bySide: Record<string, number> = Object.fromEntries(
ACCOUNT_SIDES.map((side) => [side, 0]),
);
for (const row of sides) bySide[row.side] = row.value;
// Summed from the same grouped read the breakdown is printed from. A total
// read by a second query can disagree with its own parts under a concurrent
// write, and a breakdown that does not add up invites the reader to pick.
const onBook = Object.values(bySide).reduce((sum, value) => sum + value, 0);
return { onBook, archived: rowCount(archived), bySide, contacts: rowCount(contactRows) };
}
/**
* The side split as prose, for the headline.
*
* `supply`, `demand` and `both` partition the book, so these three figures sum
* to the total and no account is counted twice. The /accounts side tabs do not
* partition it — each tab matches `side = X or side = both`, so the two tabs
* overlap — which is why the note below travels with the numbers rather than
* being left for the reader to work out from a screen that disagrees.
*/
function sideClause(bySide: Record<string, number>): string {
return ACCOUNT_SIDES.map((side) => `${bySide[side] ?? 0} ${side}`).join(', ');
}
const BY_SIDE_NOTE =
'supply, demand and both partition the book: these three figures sum to the total and ' +
'no account is counted twice. An account whose side is both trades on each side of the ' +
'market and is counted once, under both. The side tabs on the /accounts page instead show ' +
'supply plus both, and demand plus both, so those two figures overlap and do not sum.';
// ---------------------------------------------------------------------------
// The fallback
// ---------------------------------------------------------------------------
@@ -826,8 +923,9 @@ function countByState(events: readonly CalendarEvent[]): Record<string, number>
* answered from the fragment it happened to receive.
*/
async function readWorkspaceSummary(db: Database): Promise<unknown> {
const [book, demandRead, supplyRead, demandAll, supplyAll] = await Promise.all([
const [book, parties, demandRead, supplyRead, demandAll, supplyAll] = await Promise.all([
readLiveBlocks(db),
readParties(db),
db
.select({ id: demandDeals.id })
.from(demandDeals)
@@ -870,8 +968,22 @@ async function readWorkspaceSummary(db: Database): Promise<unknown> {
const worstIdle = withIdle.slice(0, 3);
return {
/**
* The parties lead the headline, and that ordering is the fix.
*
* This sentence is what a small model quotes, and the production answer was
* assembled by taking the first countable thing in it. Every count in it now
* states the noun it counts immediately beside the figure, and the noun the
* six fallback routes are most often asked about — accounts — is no longer
* missing from it.
*/
headline:
`All ${atLeast(blocks.length, truncated.commitments)} ${COMMITMENTS_LABEL} at ` +
`${parties.onBook} ${ACCOUNTS_LABEL} (${sideClause(parties.bySide)}) and ` +
`${parties.contacts} ${CONTACTS_LABEL}` +
(parties.archived > 0
? `, with a further ${parties.archived} account(s) archived and off the book`
: '') +
`. All ${atLeast(blocks.length, truncated.commitments)} ${COMMITMENTS_LABEL} at ` +
`${percent(totals.utilisation)} utilisation; ` +
`gross margin ${formatCents(totals.grossMarginCents)}; ` +
`${demand.length} of ${atLeast(demandTotal, demandTruncated)} ${DEMAND_DEALS_LABEL} ` +
@@ -890,6 +1002,43 @@ async function readWorkspaceSummary(db: Database): Promise<unknown> {
truncated: truncated.commitments,
}),
truncated,
/**
* The two figures whose absence produced the /accounts defect, first in the
* payload as well as first in the headline, each with its own scope. Both
* are exact: they are SQL counts, so neither can be a lower bound the way
* the capped reads below can.
*/
accounts: {
scope: resultScope({
covers: 'are on the book and not archived',
matched: parties.onBook,
total: parties.onBook,
totalLabel: ACCOUNTS_LABEL,
listed: 0,
}),
onBook: parties.onBook,
/** Excluded from `onBook`, and from the /accounts list, but not hidden. */
archived: parties.archived,
bySide: parties.bySide,
bySideNote: BY_SIDE_NOTE,
/**
* Said in the payload because the route guide cannot say it often enough:
* this tool counts accounts, it does not read them. A question about a
* named account is a `pig_search_records` question.
*/
rows: 'not available from this tool — counts only, no account rows',
},
contacts: {
scope: resultScope({
covers: 'are in the CRM',
matched: parties.contacts,
total: parties.contacts,
totalLabel: CONTACTS_LABEL,
listed: 0,
}),
total: parties.contacts,
rows: 'not available from this tool — counts only, no contact rows',
},
book: {
liveCommitments: blocks.length,
revenueCents: totals.revenueCents,