agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
157 lines (156 loc) • 8.62 kB
TypeScript
/**
* column-types — the tool declared what its rows contain, and the rows say
* otherwise. Filed at the WRITE seam, at the moment the tool answers.
*
* Pattern: pure function over (one declaration, one finished rowset); the
* SHAPE check, one storey below `empty-lookup` — that one asks
* whether the answer had any rows, this one asks what is in them.
* Role: the decidable fragment of "is this rowset the rowset it claims to
* be?".
*
* ── THE MEASURED FAILURES. Three, and they are one shape ────────────────
*
* 1. A mapping report wrote `str(m.get("logical_unit_number") or "")`. LUN 0
* is falsy, so on 2,094 mappings LUN 0 was stored as an EMPTY STRING —
* and a host group missing the LUN an initiator probes first became
* indistinguishable from one that had it. The column was numeric; the
* value was `''`; nothing anywhere disagreed.
* 2. A capacity view rendered `round(mib / 1024, 1)`, so an 8 MiB disk came
* out as `0.0 GB` — which reads as NO DISK, i.e. a provisioning failure,
* during a live desktop-fleet incident.
* 3. Earlier in the same application, a whole family of tools returned their
* numbers as quoted strings (`"1240"`). Every chart silently went blank,
* because nothing downstream could tell a measure from a label.
*
* All three are "a number became something else, and nothing noticed at the
* seam". The library already lets a tool declare what its result IS
* (`resultKind`, 9.70.0). It did not let a tool declare what its result
* CONTAINS, so there was nothing for a rowset to be wrong against.
*
* ── THE CEILING, and case 2 is the whole argument for stating it ────────
* This judges TYPE, never MEANING. It can see that a column declared
* `number` holds a string. It can NEVER see that the string should have been
* `0`, or that `0.0` should have been `0.0078`. Case 2 above passes this
* check cleanly — `0.0` is a perfectly good number — and the check says so
* out loud rather than letting a green row imply otherwise. The ceiling ships
* as {@link COLUMN_TYPE_CEILING} and is quoted verbatim into every finding,
* the `EMPTY_LOOKUP_CEILING` law: one owner for the bound, so it cannot drift
* out of a message and leave a reader believing the library knows more than
* it does.
*
* ── TWO FINDINGS, because the field bug turned on the difference ────────
* • `column-type-mismatch` — the column is THERE and holds the wrong thing.
* Cases 1 and 3.
* • `missing-column` — the column the author declared is in NO row of the
* result. Nothing to type-check; the promise was broken one level up.
* Collapsing them would recreate the exact ambiguity the LUN report died of:
* "the value is not what it should be" and "the value is not there" send a
* person to two different files, and a checker that says only "something is
* off with logical_unit_number" has helped with neither.
*
* ── DECLARED, NEVER INFERRED ────────────────────────────────────────────
* A tool is this check's subject only because its author wrote
* `resultColumns`. Nothing here sniffs a type off the data — sniffing is
* precisely what the consumers do today, and precisely what produced the
* failures above: one stray `''` demotes a numeric column to text, and the
* demotion is silent.
*
* ── WHAT IS NOT JUDGED ──────────────────────────────────────────────────
* See {@link readRowset}. A result is read only when it is an array of plain
* objects with at least one row. Everything else — prose, a `null`, a bespoke
* `{ rows: [...] }` wrapper, a claim ticket, AND the zero-row result — is
* `not-applicable`, filed as a ROW. The zero-row case belongs to the
* neighbour (`empty-lookup`) and is deliberately not stolen: an empty result
* has no columns to be wrong about, and filing `missing-column` for every
* declared column of an empty answer would turn one honest emptiness into a
* pile of false accusations.
*/
import type { Disposition } from '../disposition/types.js';
import type { ContextError } from '../finding/types.js';
import { type ColumnType, type ToolResultColumns } from './types.js';
/**
* THE CEILING, as one string with one owner.
*
* Quoted verbatim into every finding's message, into this folder's README and
* into the docs page, so the bound cannot drift out of one of them.
*/
export declare const COLUMN_TYPE_CEILING: string;
/** How the boundary was told to act on what this finds. */
export type ColumnCheckMode = 'warn' | 'enforce';
/**
* What the library could read about a finished result: the rows, or nothing.
*
* Deliberately thin. The check needs the rows and nothing else, and a reading
* that carried a verdict would be this file judging in two places.
*/
export interface RowsetReading {
readonly rows: readonly Readonly<Record<string, unknown>>[];
}
/**
* READ a finished result as a rowset, or decline to.
*
* The one readable shape, and why only this one: an ARRAY OF PLAIN OBJECTS
* with at least one row. That is what a rowset is on this wire, it is what
* every consumer named in the docs page already expects, and it is the same
* `Array.isArray` law the neighbouring check reads by — the two must never
* disagree about what a rowset is.
*
* `undefined` (⇒ `not-applicable`, a ROW) for everything else:
* • a non-array — prose, a `null`, a `{ rows: [...] }` wrapper, a ticket;
* • an array holding anything that is not a plain object — a list of
* strings has no columns, and inventing some is how a checker starts
* lying;
* • an array of ZERO rows — an empty answer has no columns to be wrong
* about, and it is the neighbour's subject, not this one's.
*/
export declare function readRowset(value: unknown): RowsetReading | undefined;
/** One finished call to a tool whose author declared its result's columns. */
export interface ColumnTypesCall {
readonly toolName: string;
/** The provider's id for this call — what the witness points a reader at. */
readonly toolCallId: string;
/** `Tool.resultColumns`, exactly as the author wrote it. */
readonly columns: ToolResultColumns;
/** What the library could read of the result — `undefined` = not a rowset. */
readonly reading: RowsetReading | undefined;
/** What the boundary will do with a finding — carried so the message can
* say what actually happened rather than guess. */
readonly mode: ColumnCheckMode;
}
/** One declared column the rows disagreed with. */
export interface ColumnViolation {
readonly column: string;
readonly declared: ColumnType;
/** How many rows hold something that is not the declared type. */
readonly rows: number;
/** Total rows read, so a reader can see 3-of-4 rather than a bare 3. */
readonly ofRows: number;
/** The first offending value, rendered and clipped — what a person recognizes. */
readonly sample: string;
/** What that first offending value actually is (`string`, `null`, `missing`, …). */
readonly got: string;
}
/** One encounter's outcome: the findings, the ledger row, and — under
* `enforce` — the sentence that replaces the payload. */
export interface ColumnTypesEncounter {
readonly findings: readonly ContextError[];
/** Computed HERE, beside the rules that decide it, so "the library refused
* to judge this shape" is provable without a live agent. */
readonly disposition: Disposition;
/**
* The teaching refusal, present ONLY under `mode: 'enforce'` with at least
* one finding. The `applyResultCeiling` idiom, for the same reason: the
* model reads a sentence that says what was wrong and how to fix it, never
* a stack trace and never a truncated result that reads as complete.
*/
readonly refusal?: string;
}
/**
* Judge one finished call to a tool that declared its result's columns.
*
* @param call the call, the declaration, and what the library could read of
* the result. The caller has already established the tool declared
* `resultColumns` and that the dial is on; nothing here re-decides arming.
* @param epoch the run iteration, stamped on every witness.
*/
export declare function columnTypesOf(call: ColumnTypesCall, epoch: number): ColumnTypesEncounter;