mirror of
https://github.com/morluto/rea.git
synced 2026-10-27 09:55:46 +00:00
chore: merge environment state writes (7cef2fb2) into environment list lines
Brings the fixes for two findings Greptile raised here against the state-writes code: zero page sizes erasing positive bounds, and interference timed by a tool's call on another collection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
8eea9f6d55
10 files changed
+134
-52
No files matched your search
@@ -108,8 +108,9 @@ value the application ignored it is accepted with that value and stores
|
||||
nothing, and a stored id that changed is `unexplained-change`. A plan whose
|
||||
calls use a member both as the identity and as a fixed member, or fix it at
|
||||
different values, is `unexplained-change`, as serving could not take every
|
||||
recorded call. So is an update whose observation drops a field the entity
|
||||
held, since serving keeps every field an update does not write.
|
||||
recorded call. So is an update a later read shows dropped a field the
|
||||
entity held, since serving keeps every field an update does not write; a write's
|
||||
own response may leave fields out.
|
||||
`PUT` requires every member the exemplar sent; `PATCH` and `POST` require at
|
||||
least one. A field the exemplar's update changed without sending it is
|
||||
explained as one of:
|
||||
@@ -201,7 +202,8 @@ from the state:
|
||||
repeating a name with one value is, as a call naming it matches both. A list
|
||||
is compared as served from the collection of that line. A read on a page
|
||||
(`limit`, `size`, `per_page` and the like, or a `_start`–`_end` range; a size
|
||||
of zero bounds nothing on a line whose recording listed items under it) of a
|
||||
of zero is no bound on a line whose recording listed items under it, while its
|
||||
other sizes and range still bound the page) of a
|
||||
whole list checks nothing once the collection holds more entities than the
|
||||
page, which serving then answers from its recording.
|
||||
- A read made while a modelled write to its entity or collection was still
|
||||
@@ -239,7 +241,8 @@ refuses to answer with.
|
||||
an update's changes confirms nothing of it, and leaves it for a later check
|
||||
of that entity or collection.
|
||||
- A mismatch contradicts each exercised write that no unmodelled write came
|
||||
after, whatever earlier write is still pending. When an unmodelled write
|
||||
after its latest call on the read's entity or collection, whatever earlier
|
||||
write is still pending. When an unmodelled write
|
||||
followed some of them, an item read checks those only on the members they
|
||||
sent and a list is not checked again: such a write may explain the rest.
|
||||
- A mismatch on an entity or collection that no write changed contradicts,
|
||||
|
||||
@@ -338,3 +338,15 @@ five of them on the previous rounds' own changes:
|
||||
|
||||
Each new test fails on the previous head. The verdicts over the 50 captures are unchanged plan by plan, the smoke
|
||||
applies all 31 served write tools, and the gates passed again.
|
||||
|
||||
A sixteenth round settled four findings Greptile raised on the fifteenth round's changes, here and on the fidelity,
|
||||
list-lines and list-queries branches:
|
||||
|
||||
- a size of zero is no bound only by itself: a range or another size on the same line still bounds the page;
|
||||
- an update is refused for a dropped field only when a later read shows it dropped, since a write's own response may
|
||||
leave fields out;
|
||||
- interference is weighed against a tool's latest call on the read's entity or collection, not its latest call
|
||||
anywhere.
|
||||
|
||||
Each new test fails on the previous head. The verdicts over the 50 captures are unchanged plan by plan, the smoke
|
||||
applies all 31 served write tools, and the gates passed again.
|
||||
@@ -766,6 +766,23 @@ describe("paged lines", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a range's bound beside a zero size its recording shows unbounded", () => {
|
||||
const books = servedFrom([
|
||||
["GET", "/api/books?limit=0&_start=0&_end=2", [FIRST_BOOK, SECOND_BOOK]],
|
||||
["GET", "/api/books/1", FIRST_BOOK],
|
||||
["GET", "/api/books/2", SECOND_BOOK],
|
||||
["POST", "/api/books", THIRD_BOOK, 201, { name: "Third Book" }],
|
||||
["GET", "/api/books/3", THIRD_BOOK],
|
||||
]);
|
||||
books.answer("post_books", CREATE_NEW_BOOK);
|
||||
expect(
|
||||
books.answer("get_books", { limit: "0", _start: "0", _end: "2" }),
|
||||
).toMatchObject({
|
||||
body: [FIRST_BOOK, SECOND_BOOK],
|
||||
state: { source: "recording", reason: "page-exceeded" },
|
||||
});
|
||||
});
|
||||
|
||||
it("answers a range the collection has outgrown from its recording", () => {
|
||||
const books = servedFrom([
|
||||
["GET", "/api/books?_start=0&_end=2", [FIRST_BOOK, SECOND_BOOK]],
|
||||
|
||||
@@ -1012,6 +1012,29 @@ describe("what a write's effect keeps", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("weighs an unmodelled write against a tool's call on the entity a read checks, not its latest call elsewhere", () => {
|
||||
const tagged = (id: number, name: string, tag: string) => ({
|
||||
id,
|
||||
name,
|
||||
tag,
|
||||
});
|
||||
const rows: TimedCaptureRow[] = [
|
||||
["GET", "/api/books", [tagged(1, "A", "x"), tagged(2, "B", "x")]],
|
||||
["GET", "/api/books/1", tagged(1, "A", "x")],
|
||||
["GET", "/api/books/2", tagged(2, "B", "x")],
|
||||
["PATCH", "/api/books/1", tagged(1, "A2", "x"), 200, { name: "A2" }],
|
||||
["POST", "/api/exports", { queued: true }, 202, { format: "csv" }],
|
||||
["PATCH", "/api/books/2", tagged(2, "B2", "x"), 200, { name: "B2" }],
|
||||
// The export came after book 1's rename, so it may explain the changed tag; the rename itself shows.
|
||||
["GET", "/api/books/1", tagged(1, "A2", "y")],
|
||||
];
|
||||
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
||||
served: true,
|
||||
contradicted_by: null,
|
||||
checked_record_ordinals: [6],
|
||||
});
|
||||
});
|
||||
|
||||
it("contradicts a write made after an unmodelled one, though an earlier pending write came before it", () => {
|
||||
const summary = (name: string) => [
|
||||
{ id: 1, name },
|
||||
@@ -1046,33 +1069,42 @@ describe("what a write's effect keeps", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses an update whose recorded response dropped a field serving would keep", () => {
|
||||
const subtitled = (id: number, name: string, subtitle?: string) =>
|
||||
subtitle === undefined ? { id, name } : { id, name, subtitle };
|
||||
const summaries = (first: string): TimedCaptureRow => [
|
||||
"GET",
|
||||
"/api/books",
|
||||
[subtitled(1, first), subtitled(2, "B")],
|
||||
];
|
||||
|
||||
it("refuses an update a later read shows dropped a field serving would keep", () => {
|
||||
const rows: TimedCaptureRow[] = [
|
||||
[
|
||||
"GET",
|
||||
"/api/books",
|
||||
[
|
||||
{ id: 1, name: "A" },
|
||||
{ id: 2, name: "B" },
|
||||
],
|
||||
],
|
||||
["GET", "/api/books/1", { id: 1, name: "A", subtitle: "s" }],
|
||||
["GET", "/api/books/2", { id: 2, name: "B", subtitle: "t" }],
|
||||
["PATCH", "/api/books/1", { id: 1, name: "Z" }, 200, { name: "Z" }],
|
||||
[
|
||||
"GET",
|
||||
"/api/books",
|
||||
[
|
||||
{ id: 1, name: "Z" },
|
||||
{ id: 2, name: "B" },
|
||||
],
|
||||
],
|
||||
summaries("A"),
|
||||
["GET", "/api/books/1", subtitled(1, "A", "s")],
|
||||
["GET", "/api/books/2", subtitled(2, "B", "t")],
|
||||
["PATCH", "/api/books/1", "", 204, { name: "Z" }],
|
||||
["GET", "/api/books/1", subtitled(1, "Z")],
|
||||
summaries("Z"),
|
||||
];
|
||||
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
||||
served: false,
|
||||
reason: "unexplained-change",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an update whose partial response leaves out a field a later read still shows", () => {
|
||||
const rows: TimedCaptureRow[] = [
|
||||
summaries("A"),
|
||||
["GET", "/api/books/1", subtitled(1, "A", "s")],
|
||||
["GET", "/api/books/2", subtitled(2, "B", "t")],
|
||||
["PATCH", "/api/books/1", subtitled(1, "Z"), 200, { name: "Z" }],
|
||||
["GET", "/api/books/1", subtitled(1, "Z", "s")],
|
||||
];
|
||||
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
||||
served: true,
|
||||
checked_record_ordinals: [4],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("what later calls must keep consistent", () => {
|
||||
|
||||
@@ -361,7 +361,7 @@ export class EnvironmentStateWriteSimulation {
|
||||
return;
|
||||
const exercised = exercisedBy(this.#touched, keys, borrowers);
|
||||
if (borrowers !== undefined) {
|
||||
this.#contradictOthers(outcome.pass, exercised, record);
|
||||
if (!outcome.pass) this.#contradictOthers(keys, exercised, record);
|
||||
return;
|
||||
}
|
||||
const pass =
|
||||
@@ -372,14 +372,14 @@ export class EnvironmentStateWriteSimulation {
|
||||
: undefined;
|
||||
if (!pass) {
|
||||
const interfered = [...exercised].filter((name) =>
|
||||
this.#interferedWith(name),
|
||||
this.#interferedWith(name, keys),
|
||||
);
|
||||
if (interfered.length === 0)
|
||||
this.#mismatch({ record, index, keys, exercised, view });
|
||||
// An unmodelled write after some of these writes may explain the difference: those are checked on their own members only.
|
||||
else if (view.shape === "item") this.#checkMembers(view, record);
|
||||
else {
|
||||
this.#contradictOthers(false, exercised, record);
|
||||
this.#contradictOthers(keys, exercised, record);
|
||||
for (const key of keys) this.#taint.fail(key);
|
||||
}
|
||||
if (view.shape === "item") this.#adopt(view, record);
|
||||
@@ -397,13 +397,12 @@ export class EnvironmentStateWriteSimulation {
|
||||
|
||||
/** A failed read contradicts the writes it exercised that no unmodelled write came after, as a read a write's effect was learned from still does the others. */
|
||||
#contradictOthers(
|
||||
pass: boolean,
|
||||
keys: readonly string[],
|
||||
exercised: ReadonlySet<string>,
|
||||
record: StateRecordFacts,
|
||||
): void {
|
||||
if (pass) return;
|
||||
for (const name of exercised)
|
||||
if (!this.#interferedWith(name))
|
||||
if (!this.#interferedWith(name, keys))
|
||||
this.#drafts.get(name)!.contradictedBy ??= record.ordinal;
|
||||
}
|
||||
|
||||
@@ -431,10 +430,11 @@ export class EnvironmentStateWriteSimulation {
|
||||
this.#unmodelledAt.push(index);
|
||||
}
|
||||
|
||||
/** Whether a write the gate does not model came after a tool's latest modelled call, so it may explain what a read shows of that call. */
|
||||
#interferedWith(tool: string): boolean {
|
||||
const last =
|
||||
this.#log.findLast((write) => write.tool === tool)?.index ?? -1;
|
||||
/** Whether a write the gate does not model came after a tool's latest modelled call on these targets, so it may explain what a read shows of it. */
|
||||
#interferedWith(tool: string, keys: readonly string[]): boolean {
|
||||
const last = Math.max(
|
||||
...keys.map((key) => this.#touchedSince.get(key)?.at.get(tool) ?? -1),
|
||||
);
|
||||
return this.#unmodelledAt.some((index) => index > last);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,26 +35,28 @@ const PAGE_SIZE_PARAMETERS = new Set([
|
||||
"size",
|
||||
]);
|
||||
|
||||
/** The most items a query's page holds: its smallest whole-number page-size value or `_start`–`_end` range width, or undefined without one. */
|
||||
export const pageSize = (query: string | undefined): number | undefined => {
|
||||
/** The most items a query's page holds: its smallest whole-number page-size value or `_start`–`_end` range width, or undefined without one; with `unboundedZero`, a size of zero is no bound. */
|
||||
const pageLimit = (
|
||||
query: string | undefined,
|
||||
unboundedZero: boolean,
|
||||
): number | undefined => {
|
||||
let size = Infinity;
|
||||
const range = { start: 0, end: Infinity };
|
||||
for (const [name, value] of iterateUrlQueryParameters(query ?? "")) {
|
||||
const key = name.toLowerCase();
|
||||
if (!/^\d+$/u.test(value)) continue;
|
||||
if (PAGE_SIZE_PARAMETERS.has(key)) size = Math.min(size, Number(value));
|
||||
else if (key === "_start") range.start = Number(value);
|
||||
else if (key === "_end") range.end = Number(value);
|
||||
const number = Number(value);
|
||||
if (PAGE_SIZE_PARAMETERS.has(key)) {
|
||||
if (number > 0 || !unboundedZero) size = Math.min(size, number);
|
||||
} else if (key === "_start") range.start = number;
|
||||
else if (key === "_end") range.end = number;
|
||||
}
|
||||
const width = Math.min(size, Math.max(range.end - range.start, 0));
|
||||
return Number.isFinite(width) ? width : undefined;
|
||||
};
|
||||
|
||||
/** The most items a line's call lists: its page size, but none for a size of zero the line's own recording listed items under, as an application reading zero as no limit does. */
|
||||
/** The most items a line's call lists. A size of zero is no bound on a line whose own recording listed items under it, as an application reading zero as no limit does; every other size and range still bounds the page. */
|
||||
export const pageBound = (
|
||||
query: string | undefined,
|
||||
recorded: number,
|
||||
): number | undefined => {
|
||||
const size = pageSize(query);
|
||||
return size === 0 && recorded > 0 ? undefined : size;
|
||||
};
|
||||
): number | undefined => pageLimit(query, recorded > 0);
|
||||
@@ -17,8 +17,11 @@ export const shownFields = (
|
||||
/** Modelled writes per target since its last check, with the fields each wrote (null: all). */
|
||||
export type TouchedTargets = Map<string, Map<string, readonly string[] | null>>;
|
||||
|
||||
/** Per target, when the latest modelled write since its last check ended. */
|
||||
export type PendingTargets = Map<string, { until: number }>;
|
||||
/** Per target, when the latest modelled write since its last check ended, and each tool's latest call there. */
|
||||
export type PendingTargets = Map<
|
||||
string,
|
||||
{ until: number; readonly at: Map<string, number> }
|
||||
>;
|
||||
|
||||
/** Whether a modelled write to the target was still running when a read began at `start`, so the read may show either state. */
|
||||
export const runningAt = (
|
||||
@@ -40,19 +43,22 @@ export const exercisedBy = (
|
||||
return exercised;
|
||||
};
|
||||
|
||||
/** Record a modelled write on a target: the fields it wrote (undefined: all) and when it ended. */
|
||||
/** Record a modelled write on a target: the fields it wrote (undefined: all), when it was made and when it ended. */
|
||||
export const touchTarget = (
|
||||
touched: TouchedTargets,
|
||||
pending: PendingTargets,
|
||||
target: string,
|
||||
write: {
|
||||
readonly index: number;
|
||||
readonly tool: string;
|
||||
readonly end: number;
|
||||
readonly fields: readonly string[] | undefined;
|
||||
},
|
||||
): void => {
|
||||
const held = pending.get(target);
|
||||
pending.set(target, {
|
||||
until: Math.max(pending.get(target)?.until ?? -Infinity, write.end),
|
||||
until: Math.max(held?.until ?? -Infinity, write.end),
|
||||
at: (held?.at ?? new Map()).set(write.tool, write.index),
|
||||
});
|
||||
const tools = touched.get(target) ?? new Map();
|
||||
const before = tools.get(write.tool);
|
||||
@@ -303,6 +309,11 @@ export const forgetChecked = (
|
||||
pending.since.delete(key);
|
||||
return;
|
||||
}
|
||||
for (const name of writes.keys()) if (!unseen.has(name)) writes.delete(name);
|
||||
const at = pending.since.get(key)?.at;
|
||||
for (const name of writes.keys())
|
||||
if (!unseen.has(name)) {
|
||||
writes.delete(name);
|
||||
at?.delete(name);
|
||||
}
|
||||
pending.effects.checked(key, unseen);
|
||||
};
|
||||
@@ -395,6 +395,7 @@ export const applyUpdate = (
|
||||
idField,
|
||||
id,
|
||||
startMs: startOf(context, step),
|
||||
read: after.ordinal !== step.record.ordinal,
|
||||
});
|
||||
if (explained.unexplained.length > 0 && !unknown) {
|
||||
refuse(draft, step, "unexplained-change");
|
||||
|
||||
@@ -58,6 +58,7 @@ const updated = (
|
||||
method: "PATCH",
|
||||
idField: "id",
|
||||
id: "42",
|
||||
read: false,
|
||||
startMs: START,
|
||||
...context,
|
||||
});
|
||||
|
||||
@@ -245,6 +245,8 @@ export interface UpdateEvidence {
|
||||
readonly idField: string;
|
||||
readonly id: string;
|
||||
readonly startMs: number | undefined;
|
||||
/** Whether a read, not the write's own response, observed the entity, so a field it lacks was removed rather than left out. */
|
||||
readonly read: boolean;
|
||||
}
|
||||
|
||||
/** The rule a field the update changed without sending follows, if any. */
|
||||
@@ -301,9 +303,10 @@ export const explainUpdate = (
|
||||
value: evidence.after[member]!,
|
||||
});
|
||||
}
|
||||
// Serving keeps every field an update does not write, so one the update removed is a change no rule models.
|
||||
for (const field of Object.keys(evidence.before))
|
||||
if (!Object.hasOwn(evidence.after, field)) unexplained.push(field);
|
||||
// Serving keeps every field an update does not write, so one a read shows the update removed is a change no rule models.
|
||||
if (evidence.read)
|
||||
for (const field of Object.keys(evidence.before))
|
||||
if (!Object.hasOwn(evidence.after, field)) unexplained.push(field);
|
||||
for (const field of Object.keys(evidence.after)) {
|
||||
if (
|
||||
Object.hasOwn(body, field) ||
|
||||
|
||||
Reference in new issue
Block a user