From 3730c4e1e8998f79f516cf3fe17d74c67c92669d Mon Sep 17 00:00:00 2001 From: Karan Date: Thu, 5 Nov 2026 01:47:15 -0500 Subject: [PATCH 01/10] MCP text: shapes for the explore comparison (relative times, generated field names, routes, how much a line says) timeless() puts one token in place of "13 minutes ago", "an hour ago", "just now", "yesterday", "10m ago", a line that is only "7m", a clock. fieldShape() blanks a name's generated part ("el-id-*-32", "*-input"); routeShape() blanks id-like segments; lineWeight() tells real content from short labels from counts. Nothing uses them yet. Co-Authored-By: Claude Opus 5.5 --- packages/mcp/src/text.ts | 73 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/packages/mcp/src/text.ts b/packages/mcp/src/text.ts index 503312af..72ee208b 100644 --- a/packages/mcp/src/text.ts +++ b/packages/mcp/src/text.ts @@ -95,3 +95,76 @@ export function shape(s: string): string { .trim() .slice(0, 160); } + +/** + * A route segment that names a record, not a place: what pathShape() calls an id, or a long token of mixed-case letters + * ("FlWEvnXpnxjGWWIU": a generated id can come without a digit; "GettingStarted" has too few capitals to look like one). + */ +function routeIdLike(seg: string): boolean { + if (pathShape(seg) === "*") return true; + return seg.length >= 12 && /^[\w-]+$/.test(seg) && (seg.match(/[A-Z]/g)?.length ?? 0) >= 4 && (seg.match(/[a-z]/g)?.length ?? 0) >= 4; +} + +/** + * A route with id-like segments as "*" ("/c/8f3a9c2e-…" → "/c/*"): a page that gets a new path on every load (a fresh + * session, a new document) is one page to a schedule, a scenario group and a finding key. + */ +export function routeShape(route: string): string { + return route + .split("/") + .map((seg) => (seg && routeIdLike(seg) ? "*" : seg)) + .join("/"); +} + +const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi; + +/** + * A form field's name with its generated part as "*": "el-id-2476-32" → "el-id-*-32", "-input" → "*-input", + * ":r1a:" → "*". A name made for this page load compares with itself on the next one. Only a name without spaces is an + * identifier (a UUID is one anywhere): "Address line 2" is a label, and "address-2" keeps its small number, which tells + * two fields apart rather than two loads. + */ +export function fieldShape(name: string): string { + const s = name.replace(UUID, "*").replace(/:[rR][0-9a-zA-Z]*:|«[rR][0-9a-zA-Z]*»/g, "*"); + if (/\s/.test(s)) return s; + return s + .split(/([-_.:])/) + .map((t) => (/^\d{3,}$/.test(t) || /^[0-9a-f]{8,}$/i.test(t) || (t.length >= 5 && (t.match(/\d/g)?.length ?? 0) >= 2 && (t.match(/[a-z]/gi)?.length ?? 0) >= 2) ? "*" : t)) + .join(""); +} + +const SPAN = String.raw`(?:\d+|an?|a few|few|several)\s*(?:s|secs?|seconds?|m|mins?|minutes?|h|hrs?|hours?|d|days?|w|wks?|weeks?|mo|mos|months?|y|yrs?|years?)\b\.?`; +/** "13 minutes ago", "an hour ago", "a few seconds ago", "10m ago", "2h 5m ago", "less than a minute ago". */ +const AGO = new RegExp(String.raw`(?"; + +/** + * A page line with its relative times replaced by one token: "Edited 13 minutes ago" and "Edited 14 minutes ago" are the + * same line, whatever the baselines did. These words move with the clock, not with the order of responses: two baselines + * inside one minute agree, and a run one minute later would differ. A rule about the shape of text, in English (other + * languages are compared as they are, and fall to the baselines' own noise); it says nothing about any bug. + */ +export function timeless(line: string): string { + const t = line.trim(); + if (SHORT_AGE.test(t)) return WHEN; + return line.replace(AGO, WHEN).replace(AHEAD, WHEN).replace(WORDS, WHEN).replace(CLOCK, WHEN); +} + +/** + * How much a page line says, for putting evidence in order: 2 real content (two words or more), 1 a short label + * ("Components", "draft-one", "Total 41"), 0 a count or a symbol ("41", "156k", "33%", "·", "/"). + */ +export function lineWeight(line: string): 0 | 1 | 2 { + const words = line.split(/\s+/).filter((w) => /\p{L}{2,}/u.test(w) && !/^[\d.,$€£]+\p{L}{0,3}%?$/u.test(w)); + return words.length >= 2 ? 2 : words.length === 1 ? 1 : 0; +} From 7b35452007d83dd5783fb2e4eea8ba6860555389 Mon Sep 17 00:00:00 2001 From: Karan Date: Thu, 5 Nov 2026 01:47:15 -0500 Subject: [PATCH 02/10] Explore: a verdict that holds on real apps (relative times, read-only POSTs, generated names, a baseline with no added delay) Four hunts ran explore next to their own page checks: on fixed builds where the page was right it said RACE in 85 of 120 reordered runs, and it missed real bugs. compare.ts, by cause: - Relative times compare by shape whatever the baselines did. - A POST count alone is no race: only with two identical requests in flight, or a user action sending it directly a different number of times (from the run's steps and witnesses, no URL list). PUT, PATCH, DELETE as before. - Generated field names compare with themselves; fields of one shape pool their values. - With slow_ms the plan gets a baseline with no added delay, after the two slow ones (runs + 3 loads). What it differs in from them is reported first as what a slow response alone does (a race finding replaying the slow schedule); a seeded run like it is not listed; it settles what the slow baselines disagree on (two values, a line of real content, an error, a write with evidence). A bare number and a short label only one shows stay noise. - Noise blanks only the tokens that varied, at their position, each standing for its own shape; a line both baselines show is never noise. - Report: page content before labels before counts, in a run's list and in which runs come first; the headline and the title name that line; store values capped with a count of the rest. - Routes group by shape, also when armed and replayed; an action only some loads did no longer splits a session; a report that compared nothing says NO VERDICT. - Requests to other sites leave the counts unless a user action or the page's own origin led to them; a bare count on the page beside the field such a response wrote is internal. They still join the schedule (that needs the runtime). - A race finding's report says to fix only if the user asked for a fix. The header says what is matched now: text shapes, none of them a bug pattern. Tools: only the report's text changes in tools.ts. The replay and the generated test read relative times and generated names by shape too. Co-Authored-By: Claude Opus 5.5 --- packages/mcp/src/compare.ts | 568 ++++++++++++++++++++++++----- packages/mcp/src/index.ts | 4 +- packages/mcp/src/store.ts | 85 +++-- packages/mcp/src/testgen.ts | 18 +- packages/mcp/src/tools.ts | 64 ++-- packages/mcp/test/compare.test.ts | 448 ++++++++++++++++++++++- packages/mcp/test/e2e/race.e2e.mjs | 15 +- packages/mcp/test/mcp.test.ts | 74 ++++ packages/mcp/test/store.test.ts | 57 ++- packages/mcp/test/testgen.test.ts | 16 + 10 files changed, 1184 insertions(+), 165 deletions(-) diff --git a/packages/mcp/src/compare.ts b/packages/mcp/src/compare.ts index ad11f6b2..d24bb953 100644 --- a/packages/mcp/src/compare.ts +++ b/packages/mcp/src/compare.ts @@ -1,38 +1,58 @@ // The explore verdict (docs/runtime/AGENT.md section 5). Runs of the same scenario (the same user actions on the same -// route) differ only in when their responses were delivered. The two fifo runs are the baseline: responses in the -// order their requests started, as on a fast, serial network. Whatever differs between the two baselines is noise and -// is ignored, by shape as well as by value: -// - a store field whose value differs between the baselines; -// - the children of a store object whose keys differ between the baselines (new records get new ids on every run): -// there, records are compared by their values, not by their keys; -// - a page line whose shape (tokens holding digits blanked: times, counters, ids) differs between the baselines. +// route) differ only in when their responses were delivered. The fifo runs are the baselines: responses in the order +// their requests started, as on a fast, serial network. With added latency (`slow_ms`) there are two slow baselines and +// one with no added delay, so a bug that needs only a slow response has something to differ from. Whatever differs +// between the two slow baselines is noise and is ignored, by shape as well as by value: +// - a store field whose value differs between them; +// - the children of a store object whose keys differ between them (new records get new ids on every run): there, +// records are compared by their values, not by their keys; +// - in a page line, the tokens that differ at their position (times, counters, ids), and no other token of that line; +// - a page line only one of them shows. +// Unless the baseline with no added delay settles it: when two slow baselines show two different values and it shows +// one of them, the other is what a slow response did, not noise. (A clock or an id would differ in all three. A bare +// number is left out of this: a counter can land on the same value by chance.) It also decides a line of real content +// only one of them shows, and an error only one of them had; a short label only one of them shows stays noise. // A value the runtime redacted ("[redacted]") equals any value: whether an opaque id is redacted depends on the id // itself, so a redaction is never a difference. // -// Whatever a seeded run ends with that differs from the baseline beyond that depends on network timing. It is sorted by -// where the difference shows, from the evidence the run recorded: -// visible what the page shows (its text, its form values), the unsafe requests it sent (POST, PUT, ...), or an -// error only it had: a race; -// internal store values, counts of safe requests (GET: a cache refetching once more) or announcements (lines in -// ARIA live regions: a toast a slower run let expire) only, while the page showed the same as the -// baseline: reported apart, never as a race (a cache's bookkeeping, such as request ids and lists of -// cache keys, depends on arrival order by construction and never reaches the page); +// A difference between the baseline with no added delay and the slow baselines is reported as what a slow response alone +// does (GroupReport.delay). A seeded run's difference from the slow baselines beyond that depends on network timing. It +// is sorted by where it shows, from the evidence the run recorded: +// visible what the page shows (its text, its form values), the writes it sent, or an error only it had: a race; +// internal store values, counts of safe requests (GET: a cache refetching once more), counts of POST requests with +// nothing recorded that makes them a write (POST often reads) or announcements (lines in ARIA live regions: +// a toast a slower run let expire) only, while the page showed the same as the baseline: reported apart, +// never as a race (a cache's bookkeeping, such as request ids and lists of cache keys, depends on arrival +// order by construction and never reaches the page); // transient the same final state, but an older response overwrote newer data along the way (a later response // repaired it): reported apart, never as a race. -// Scenarios are grouped by their recorded actions: element names with digits blanked and parenthesised counts dropped, -// consecutive typing into one field as one burst; when that leaves reordered runs without two baselines to compare -// with (a label that changed with the order), by action kinds and element roles, then by action kinds alone. A group -// without two baselines cannot tell noise from a race and gives no verdict. No pattern of any kind is matched here. +// A POST count counts as a write only when the run recorded two identical requests in flight together that the baseline +// did not, or a user action sent it directly a different number of times. PUT, PATCH and DELETE counts always do. +// Requests to another site leave the counts unless a user action or a request to the page's own origin led to them. +// +// Scenarios are grouped by route (id-like segments blanked) and recorded actions: element names with digits blanked and +// parenthesised counts dropped, consecutive typing into one field as one burst; when that leaves reordered runs without +// two baselines to compare with (a label that changed with the order), by action kinds and element roles, then by action +// kinds alone, then with up to two actions only some loads did (a dialog to dismiss). A group without two baselines +// cannot tell noise from a race and gives no verdict. +// +// Text shapes are matched here, and none of them is a bug pattern: relative times (text.ts timeless(), compared by shape +// whatever the baselines did, because they move with the clock), generated field names and route segments (fieldShape(), +// routeShape()), tokens holding digits. Whether anything is a bug is only ever asked of the page itself: does what it +// shows depend on the timing of its responses? import type { ActStep, Run, Snapshot } from "./types.js"; -import { pathShape, sigOf } from "./text.js"; +import { fieldShape, lineWeight, pathShape, q, routeShape, sigOf, timeless } from "./text.js"; /** The runtime's redaction placeholder (packages/runtime/src/util.ts REDACTED). */ export const REDACTED = "[redacted]"; -/** How a session's runs were grouped: by element names, by action kinds and element roles, or by action kinds only. */ -export type Grouping = "names" | "roles" | "kinds"; -const GROUPINGS: Grouping[] = ["names", "roles", "kinds"]; +/** + * How a session's runs were grouped: by element names, by action kinds and element roles, by action kinds only, or by + * action kinds with up to two actions only some loads did. + */ +export type Grouping = "names" | "roles" | "kinds" | "optional"; +const GROUPINGS: Grouping[] = ["names", "roles", "kinds", "optional"]; /** Where a seeded run's difference from the baseline shows (see the header). */ export type Tier = "visible" | "internal" | "transient"; @@ -42,9 +62,9 @@ const blankDigits = (s: string): string => s.replace(/\d+/g, "N"); /** Tags whose ARIA role is "generic": which of them takes a click can depend on what the page rendered first. */ const GENERIC = new Set(["div", "span", "b", "i", "u", "s", "small", "bdi", "bdo", "samp", "data"]); -/** A target's name for grouping: parenthesised counts dropped ('combobox "author (1)"' → 'combobox "author"'), digits blanked. */ +/** A target's name for grouping: relative times as one token, parenthesised counts dropped ('combobox "author (1)"' → 'combobox "author"'), digits blanked. */ function targetName(t: string | undefined): string { - return blankDigits((t ?? "").replace(/\s*\(\d+\)/g, "")).trim(); + return blankDigits(timeless(t ?? "").replace(/\s*\(\d+\)/g, "")).trim(); } /** A target's role: 'button "Save"' → button; a generic container (div, span, ...) → element. */ @@ -135,6 +155,8 @@ export interface FieldDiff { name: string; baseline: string; run: string; + /** A POST count: what the run recorded that makes the difference a write (without it, POST counts are internal). */ + evidence?: string; } export interface RunDiff { @@ -161,6 +183,8 @@ export interface RunDiff { witnesses: string[]; /** Responses this run delivered only because their hold ran out (it may not have followed its schedule). */ forced: number; + /** Its page text differs only in a bare count, beside a store field another site's response wrote: internal, not a race. */ + otherSite?: boolean; } /** @@ -176,6 +200,14 @@ export function stateValue(state: Record, name: string): string return list.length === 0 ? undefined : list.length === 1 ? list[0] : `[${list.sort().join(" | ")}]`; } +/** The value keyed() gives a form field's shape in a raw snapshot: fields of one shape pool, sorted and joined. */ +export function fieldValue(fields: Record | undefined, name: string): string | undefined { + const list = Object.entries(fields ?? {}) + .filter(([k]) => fieldShape(k) === name) + .map(([, v]) => v); + return list.length === 0 ? undefined : list.length === 1 ? list[0] : `[${list.sort().join(" | ")}]`; +} + /** The run's snapshot was taken after its last user action (a final state, not the page as it loaded). */ export function settledAfterActs(r: Run): boolean { if (!r.snapshot) return false; @@ -192,8 +224,11 @@ export function endedDifferently(d: RunDiff): boolean { /** Requests with a safe method (RFC 9110: GET, HEAD, OPTIONS) change nothing the server keeps: a cache refetching once more. */ const SAFE = /^(GET|HEAD|OPTIONS)\s/; -/** A difference the page shows or sends: a form value, the count of an unsafe request (POST, PUT, ...). */ -export const shownField = (f: FieldDiff): boolean => f.where === "field" || (f.where === "requests" && !SAFE.test(f.name)); +/** POST is the one unsafe method that often only reads ("POST /counts", GraphQL): its count alone says nothing. */ +const POST = /^POST\s/; + +/** A difference the page shows or sends: a form value, the count of a write (PUT, PATCH, DELETE, a POST with evidence). */ +export const shownField = (f: FieldDiff): boolean => f.where === "field" || (f.where === "requests" && !SAFE.test(f.name) && (!POST.test(f.name) || !!f.evidence)); /** * Did the difference reach what the page shows or sends (its text outside announcements, form values, unsafe @@ -203,6 +238,38 @@ export function visibleDiff(d: Pick 0 || d.textOnlyRun.length > 0 || d.errors.length > 0; } +/** The weight of the most telling line a run's difference shows (real content before labels before counts). */ +function bestLine(d: Pick): number { + return Math.max(-1, ...[...d.textOnlyBaseline, ...d.textOnlyRun].map(lineWeight)); +} + +/** Runs in the order their evidence should be read: real page content first, then errors, form values, labels, writes, counts. */ +export function strongestFirst>(diffs: T[]): T[] { + const score = (d: T): number => (bestLine(d) === 2 ? 5 : d.errors.length ? 4 : d.fields.some((f) => f.where === "field") ? 3 : bestLine(d) === 1 ? 2 : d.fields.some(shownField) ? 1 : 0); + return diffs.map((d, i) => ({ d, i, s: score(d) })).sort((x, y) => y.s - x.s || x.i - y.i).map((x) => x.d); +} + +/** What a run's difference comes down to, in one phrase (the most telling evidence first; page strings quoted). */ +export function nameDifference(d: Pick): string { + const line = (weight: number): string | null => { + const inRun = d.textOnlyRun.find((l) => lineWeight(l) === weight); + if (inRun !== undefined) return `it shows ${q(inRun, 80)}, which the baseline does not`; + const inBase = d.textOnlyBaseline.find((l) => lineWeight(l) === weight); + return inBase !== undefined ? `it does not show ${q(inBase, 80)}, which the baseline does` : null; + }; + const field = d.fields.find((f) => f.where === "field"); + const write = d.fields.find(shownField); + return ( + line(2) ?? + (d.errors.length ? `an error only there: ${q(d.errors[0], 100)}` : null) ?? + (field ? `form field ${q(field.name, 60)} is ${q(field.run, 60)}, ${q(field.baseline, 60)} in the baseline` : null) ?? + line(1) ?? + (write ? `requests sent to ${q(write.name, 80)}: ${q(write.run, 20)}, ${q(write.baseline, 20)} in the baseline` : null) ?? + line(0) ?? + "what it sent or showed differs" + ); +} + export interface GroupReport { route: string; /** The scenario as the group's first baseline (else its first run) recorded it. */ @@ -226,6 +293,18 @@ export interface GroupReport { unfiltered: RunDiff[]; /** Runs whose snapshot left store fields out (its budget), and the most left out of how many. */ truncated?: { runs: number; left: number; total: number }; + /** The baseline with no added delay, when the session has one in this group. */ + plainBaseline?: string; + /** + * What a slow response alone does: the first slow baseline that ended differently from the baseline with no added delay + * (`textOnlyBaseline` is what that baseline shows, `textOnlyRun` what the slow one shows), and how many of them did. + * Only when it reached the page, its form values, its writes or its errors. + */ + delay?: RunDiff & { of: number; differing: number }; + /** Counts of POST requests a slow baseline sent differently from the one with no added delay, with nothing that makes them writes. */ + delayCounts?: FieldDiff[]; + /** Requests to other sites left out of the counts (nothing the user did or the page's own origin asked for led to them). */ + thirdParty?: string[]; } export function multiset(lines: Iterable): Map { @@ -240,8 +319,106 @@ function onlyIn(a: Map, b: Map): string[] { return out; } +const DIGIT_TOKEN = /[\w.:/-]*\d[\w.:/-]*/g; + /** A page line with tokens that hold a digit blanked: "Order #A7F3KQ placed 12:01:04" → "Order #N placed N". */ -export const lineShape = (l: string): string => l.replace(/[\w.:/-]*\d[\w.:/-]*/g, "N"); +export const lineShape = (l: string): string => l.replace(DIGIT_TOKEN, "N"); + +const tokensOf = (l: string): string[] => l.match(DIGIT_TOKEN) ?? []; +const tokenShape = (t: string): string => t.replace(/\d+/g, "N"); +const kindOf = (l: string): string => l.replace(DIGIT_TOKEN, tokenShape); + +/** What may stand at one digit-holding position of a noisy line: this very token, or any token of a shape ("": any). */ +type Slot = { exact: string } | { shape: string }; + +/** Which tokens varied between two lines of one shape; the others stay exact ("12 items, version-1" / "13 items, version-1"). */ +function slotsOf(a: string[], b: string[]): Slot[] { + return a.map((t, i) => (t === b[i] ? { exact: t } : { shape: tokenShape(t) === tokenShape(b[i]) ? tokenShape(t) : "" })); +} + +const fitsSlots = (slots: Slot[], tokens: string[]): boolean => slots.every((s, i) => ("exact" in s ? s.exact === tokens[i] : !s.shape || tokenShape(tokens[i]) === s.shape)); + +/** A noisy line for the report: the tokens that varied as "N" or "*", the others as they are. */ +function slotsText(line: string, slots: Slot[]): string { + let i = 0; + return line.replace(DIGIT_TOKEN, (t) => { + const s = slots[i++]; + return !s ? t : "exact" in s ? s.exact : s.shape || "*"; + }); +} + +/** Page lines the baselines say nothing about: lines only some of them show, and lines with a token that varied. */ +interface LineNoise { + exact: Set; + slots: Map; + /** The noisy lines as the report writes them. */ + labels: Set; + /** Lines every baseline shows the same: a token that varied elsewhere does not make them noise. */ + stable: Set; + /** Lines the baseline with no added delay settled: what a slow baseline showed → what it and the other slow baseline showed. */ + settled: Map; + /** Lines of real content only one slow baseline shows, which it settled too: what they should show or should not. */ + belongs: Set; + doesNotBelong: Set; +} + +const isNoiseLine = (n: LineNoise, l: string): boolean => { + if (n.exact.has(l)) return true; + if (n.stable.has(l)) return false; + const tokens = tokensOf(l); + return !!n.slots.get(lineShape(l))?.some((s) => fitsSlots(s, tokens)); +}; + +/** What differs between the slow baselines' lines, and what the baseline with no added delay (when there is one) settles. */ +function lineNoise(base: Map[], plain: Map | null): LineNoise { + const n: LineNoise = { exact: new Set(), slots: new Map(), labels: new Set(), stable: new Set(), settled: new Map(), belongs: new Set(), doesNotBelong: new Set() }; + const b0 = base[0]; + for (const l of b0.keys()) if (base.every((b) => b.has(l))) n.stable.add(l); + // it shows one of the two values: the other one is not noise (it shows either, or a third: noise) + const settle = (x: string, y: string): boolean => { + if (!plain || plain.has(x) === plain.has(y)) return false; + n.settled.set(plain.has(x) ? y : x, plain.has(x) ? x : y); + return true; + }; + for (const b of base.slice(1)) { + const fromFirst = onlyIn(b0, b); + const unpaired = new Set(fromFirst.keys()); + const left: string[] = []; + for (const y of onlyIn(b, b0)) { + // its own kind first ("3" with "4", not with "version-1"), then any line of the shape (generated ids of two forms) + const i = [...unpaired].find((k) => kindOf(fromFirst[k]) === kindOf(y)) ?? [...unpaired].find((k) => lineShape(fromFirst[k]) === lineShape(y)); + if (i === undefined) { + left.push(y); + continue; + } + unpaired.delete(i); + const x = fromFirst[i]; + const varied = tokensOf(x).filter((t, k) => t !== tokensOf(y)[k]); + // a bare number can land on the same value by chance (a counter): only words are settled + if (varied.some((t) => /\p{L}{2,}/u.test(t)) && settle(x, y)) continue; + const shape = lineShape(x); + const list = n.slots.get(shape) ?? []; + const slots = slotsOf(tokensOf(x), tokensOf(y)); + list.push(slots); + n.slots.set(shape, list); + n.labels.add(slotsText(x, slots)); + } + const rest = [...unpaired].map((k) => fromFirst[k]); + // as many lines gone as came: one value in place of another, for the baseline with no added delay to settle + const swapped = rest.length === left.length; + const keep = rest.flatMap((x, i) => (swapped && settle(x, left[i]) ? [] : swapped ? [x, left[i]] : [x])); + if (!swapped) keep.push(...left); + for (const l of keep) { + // a line only one of them shows has two values at most, so it agreeing with one says little: a short label stays + // noise (a tree one load expanded). A line of real content is a page that ended differently: it decides that too + if (plain && lineWeight(l) === 2) (plain.has(l) ? n.belongs : n.doesNotBelong).add(l); + else n.exact.add(l); + } + } + return n; +} + +const byWeight = (lines: string[]): string[] => lines.map((l, i) => ({ l, i, w: lineWeight(l) })).sort((a, b) => b.w - a.w || a.i - b.i).map((x) => x.l); /** Parents ("todos.entities") whose child keys differ between two key sets (record ids made fresh each run). */ function noisyParents(a: Iterable, b: Iterable): Set { @@ -285,7 +462,7 @@ function underNoisy(path: string, noisy: Set): string | null { * A snapshot as comparable entries: `state `, `field