mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 08:11:17 -10:00
test(51-03): declarative cross-surface normalizer — scenario #10 GREEN, 3/3
The equals mode compared raw surface shapes through an identity placeholder, so
it could never hold: ps answers with rig records, queue with qitems whose
destinationSession is <pod>-<member>@<rig>. Comparing them requires DECLARING
which field of each carries the shared truth — that declaration is the
normalizer, and per A-N1 the declarative mapping is the only scenario-facing
form and LOWERS to the runner-internal seam. The seam signature is untouched;
this is additive.
equals:
ps: { pluck: name }
queue: { pluck: destinationSession, rig: true }
Projections are small and total: pluck a field per element, optionally reduce a
session name to its rig, then dedupe+sort so agreement on a SET is not defeated
by ordering or by N qitems naming one rig. Undeclared surfaces pass through
rather than silently emptying. Schema validates the mapping at load, so an
authoring error teaches instead of comparing nothing.
WHAT #10 NOW PROVES: ps and queue name the same rig set — a qitem addressed to a
rig ps does not know, or a rig whose queue work is invisible, fails the run.
FALSIFIED, not assumed: plucking rigId instead of name makes the sets disagree
(ULID vs scn-baton) and the scenario FAILS, so the comparison is value-sensitive
and both operands are non-empty. The scenario also seeds a real qitem — an
all-empty comparison would pass while proving nothing.
WHAT IT DOES NOT PROVE, measured at source and stated in the fixture: tui_socket
is asserted separately on its own coherence and deliberately NOT in the equality.
Both shipped OBSERVE verbs return UI/registry state (state -> {screen, drill,
viewTab}; commands -> the command registry), so the socket carries no daemon
truth; folding it in could only pass by comparing something trivially equal. The
gap is a TUI finding — the state query would need to expose the rig/queue view it
renders.
3/3 scenarios PASS against the real runtime. Matrix 170/170 across 21 files,
daemon tsc clean.
Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
1 parent
cd14bf98dc
commit
fc3f766e44
6 files changed
+250
-22
No files matched your search
@@ -1,27 +1,52 @@
|
||||
# Scenario #10 — one-view-state consistency (51-02 proof item 7, parse + interface).
|
||||
# Scenario #10 — one-view-state consistency (the 5.1 crowning assertion).
|
||||
#
|
||||
# INTENT: a single point in time must read consistently across surfaces — the TUI
|
||||
# control socket, `rig ps`, and `rig queue` should agree on the one view state
|
||||
# (the cross-surface `equals` assertion). This scenario exercises the equals PARSE
|
||||
# + the declarative-normalizer INTERFACE; the normalizer's final shape (which fields
|
||||
# of each surface map onto the shared comparison form) rides 51-03 shaping, so the
|
||||
# runner takes an injected normalizer here. The locked schema requires a nominal
|
||||
# `surface` on every expect, so `surface: ps` names the anchor; `equals` lists the
|
||||
# surfaces compared.
|
||||
# INTENT: at a single point in time the shipped surfaces must AGREE about one
|
||||
# world. This is the automated form of the failure class that costs real time —
|
||||
# one surface confidently reporting a world another surface cannot see.
|
||||
#
|
||||
# WHAT IS COMPARED, and why these two: `rig ps` and `rig queue` are both
|
||||
# daemon-truth surfaces, so the rigs ps reports and the rigs the queue's items are
|
||||
# destined for must be the SAME SET. A qitem addressed to a rig ps does not know
|
||||
# about — or a rig running with its queue work invisible — breaks this and fails
|
||||
# the scenario. The declarative mapping below is what makes the comparison
|
||||
# possible: the surfaces answer in different shapes (rig records vs qitems whose
|
||||
# destinationSession is <pod>-<member>@<rig>), so each DECLARES the field carrying
|
||||
# the shared truth. Per A-N1 the mapping is the only scenario-facing form; it
|
||||
# lowers to the runner-internal seam.
|
||||
#
|
||||
# WHAT tui_socket CONTRIBUTES, stated honestly: its own coherence, asserted
|
||||
# separately below — NOT membership in the equality. MEASURED at source: both of
|
||||
# the control socket's OBSERVE verbs return UI/registry state
|
||||
# (`state` -> {screen, drill, viewTab}; `commands` -> the command registry), so
|
||||
# the shipped socket carries NO daemon truth to compare. Folding it into the
|
||||
# equality could only pass by comparing something trivially equal, which would be
|
||||
# a false green. The gap is a TUI finding (the state query would need to expose
|
||||
# the rig/queue view it renders), not something this scenario can paper over.
|
||||
scenario: one-view-state-consistency
|
||||
topology: ./topo-stub-baton.yaml
|
||||
env:
|
||||
# D7 (51-02 delta): tui_socket expects require the TUI opt-in — the pipeline
|
||||
# provisions the shipped TUI (isolated tmux server + scaffold socket) only when
|
||||
# declared, so the surface read never races a socket that never listens.
|
||||
tui: true
|
||||
# give the queue a real item so both surfaces have a rig to agree about; an
|
||||
# all-empty comparison would pass without proving anything
|
||||
queue:
|
||||
- id: view-1
|
||||
source: harness@scn-baton
|
||||
destination: dev-worker@scn-baton
|
||||
summary: "#10 one-view-state — the queue's rig must be a rig ps knows"
|
||||
claim: true
|
||||
steps:
|
||||
- up: {}
|
||||
# the TUI is live and coherent at this instant (its own state, its own claim)
|
||||
- expect:
|
||||
surface: tui_socket
|
||||
within: 20s
|
||||
match:
|
||||
ok: true
|
||||
# THE INVARIANT: ps and queue name the same rig set
|
||||
- expect:
|
||||
surface: ps
|
||||
within: 20s
|
||||
equals:
|
||||
- tui_socket
|
||||
- ps
|
||||
- queue
|
||||
ps: { pluck: name }
|
||||
queue: { pluck: destinationSession, rig: true }
|
||||
- down: {}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Slice 51-03 — the DECLARATIVE cross-surface normalizer (the `equals` mapping).
|
||||
*
|
||||
* A-N1 fixed the shape of this: the declarative mapping is the ONLY scenario-facing
|
||||
* form, and it LOWERS to the runner-internal seam
|
||||
* (`normalizer?: (surface, value) => unknown`) rather than replacing it. So this
|
||||
* module is purely additive — it builds that callback from YAML. The seam
|
||||
* signature is untouched and the runner's equals branch is unchanged.
|
||||
*
|
||||
* The problem it solves: the shipped surfaces answer in structurally different
|
||||
* shapes, so raw comparison always differs. `rig ps --json` is an array of rig
|
||||
* records; `rig queue list --json` is an array of qitems whose destinationSession
|
||||
* is `<pod>-<member>@<rig>`. Comparing them requires DECLARING which field of each
|
||||
* carries the shared truth — that declaration is what a normalizer is.
|
||||
*
|
||||
* equals:
|
||||
* ps: { pluck: name } # -> ["scn-baton"]
|
||||
* queue: { pluck: destinationSession, rig: true } # -> ["scn-baton"]
|
||||
*
|
||||
* Projections are deliberately tiny and total: pluck a field from each element of
|
||||
* an array surface, optionally reduce a session name to its rig, then dedupe and
|
||||
* sort so the comparison is order-insensitive (two surfaces agreeing on a SET
|
||||
* should not fail because they enumerate it differently).
|
||||
*/
|
||||
|
||||
import type { ExpectSurface } from "./scenario-schema.js";
|
||||
|
||||
/** One surface's declared projection onto the shared comparison form. */
|
||||
export interface SurfaceProjection {
|
||||
/** Field to take from each element of an array surface (e.g. `name`). */
|
||||
pluck?: string;
|
||||
/** Reduce a canonical session name (`<pod>-<member>@<rig>`) to its rig part. */
|
||||
rig?: boolean;
|
||||
/** Read a nested field from an OBJECT surface before projecting (dot path). */
|
||||
path?: string;
|
||||
}
|
||||
|
||||
/** The declarative `equals` mapping: surface -> projection. */
|
||||
export type EqualsMapping = Partial<Record<ExpectSurface, SurfaceProjection>>;
|
||||
|
||||
/** True when an `equals` payload is the declarative MAPPING form (not the legacy list). */
|
||||
export function isEqualsMapping(payload: unknown): payload is EqualsMapping {
|
||||
return !!payload && typeof payload === "object" && !Array.isArray(payload);
|
||||
}
|
||||
|
||||
function readPath(value: unknown, path: string): unknown {
|
||||
return path.split(".").reduce<unknown>((acc, key) => {
|
||||
if (acc && typeof acc === "object") return (acc as Record<string, unknown>)[key];
|
||||
return undefined;
|
||||
}, value);
|
||||
}
|
||||
|
||||
/** `dev-worker@scn-baton` -> `scn-baton`. Left unchanged when there is no `@`. */
|
||||
export function rigOf(session: string): string {
|
||||
const at = session.lastIndexOf("@");
|
||||
return at < 0 ? session : session.slice(at + 1);
|
||||
}
|
||||
|
||||
/** Apply one surface's declared projection. Total: never throws on a shape it did not expect. */
|
||||
export function projectSurface(value: unknown, spec: SurfaceProjection): unknown {
|
||||
const base = spec.path ? readPath(value, spec.path) : value;
|
||||
if (!spec.pluck) return base;
|
||||
const items = Array.isArray(base) ? base : [base];
|
||||
const plucked = items
|
||||
.map((item) => (item && typeof item === "object" ? (item as Record<string, unknown>)[spec.pluck!] : undefined))
|
||||
.filter((v): v is string => typeof v === "string" && v.length > 0)
|
||||
.map((v) => (spec.rig ? rigOf(v) : v));
|
||||
// Set semantics: dedupe + sort, so agreement on the SET is not defeated by order
|
||||
// or by one surface listing the same rig twice (N qitems, one rig).
|
||||
return [...new Set(plucked)].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lower a declarative mapping onto the runner-internal seam (A-N1). Surfaces the
|
||||
* mapping does not mention pass through unchanged, so a partially-declared
|
||||
* comparison still behaves predictably rather than silently emptying.
|
||||
*/
|
||||
export function buildDeclarativeNormalizer(
|
||||
mapping: EqualsMapping,
|
||||
): (surface: ExpectSurface, value: unknown) => unknown {
|
||||
return (surface, value) => {
|
||||
const spec = mapping[surface];
|
||||
return spec ? projectSurface(value, spec) : value;
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
import type { ExpectSurface, ValidatedScenario } from "./scenario-schema.js";
|
||||
import { structuralSubsetMatch, containsMatch, pollUntilMatch } from "./scenario-expect.js";
|
||||
import type { RunRecord } from "./scenario-run-record.js";
|
||||
import { buildDeclarativeNormalizer, isEqualsMapping } from "./scenario-normalizer.js";
|
||||
|
||||
export interface ActionResult {
|
||||
code: number;
|
||||
@@ -149,12 +150,16 @@ async function runEqualsExpect(
|
||||
deps: ScenarioRunnerDeps,
|
||||
): Promise<string | null> {
|
||||
const surfaces = extractEqualsSurfaces(equalsPayload);
|
||||
if (!deps.normalizer) {
|
||||
// 51-03: the DECLARATIVE mapping is the scenario-facing form and LOWERS to the
|
||||
// runner-internal seam (A-N1) — the seam signature is unchanged, and a scenario
|
||||
// that declares its mapping no longer depends on an injected normalizer.
|
||||
const declared = isEqualsMapping(equalsPayload) ? buildDeclarativeNormalizer(equalsPayload) : undefined;
|
||||
const normalizer = declared ?? deps.normalizer;
|
||||
if (!normalizer) {
|
||||
throw new Error(
|
||||
"`equals` requires a declarative cross-surface normalizer (its shape rides 51-03 shaping)",
|
||||
"`equals` requires a declarative cross-surface mapping (surface -> projection) or an injected normalizer",
|
||||
);
|
||||
}
|
||||
const normalizer = deps.normalizer;
|
||||
const observeAllEqual = async (): Promise<{ equal: boolean; snapshot: unknown[] }> => {
|
||||
const snapshot = await Promise.all(
|
||||
surfaces.map(async (s) => normalizer(s, await deps.observe(s, {}))),
|
||||
|
||||
@@ -86,6 +86,8 @@ export type ValidationErrorCode =
|
||||
| "EXPECT_MATCH_MODE_MISSING"
|
||||
| "EXPECT_MATCH_MODE_AMBIGUOUS"
|
||||
| "WITHIN_NOT_A_DURATION"
|
||||
| "EQUALS_PROJECTION_INVALID"
|
||||
| "EQUALS_SURFACE_UNKNOWN"
|
||||
| "EMIT_NOT_OBJECT"
|
||||
| "UNKNOWN_EMIT_BEHAVIOR"
|
||||
| "USAGE_LIMIT_IN_STUB_TOPOLOGY"
|
||||
@@ -317,6 +319,37 @@ function validateExpect(
|
||||
} else if (modes.length > 1) {
|
||||
push("EXPECT_MATCH_MODE_AMBIGUOUS", `${path}: only one of match | contains | equals is allowed, found [${modes.join(", ")}]`, path);
|
||||
}
|
||||
// 51-03: the declarative `equals` mapping (surface -> projection). Validated
|
||||
// here so an authoring error is a load-time teaching failure, not a scenario
|
||||
// that runs and compares nothing.
|
||||
if (isPlainObject(value.equals)) {
|
||||
for (const [surf, spec] of Object.entries(value.equals)) {
|
||||
if (!(EXPECT_SURFACES as readonly string[]).includes(surf)) {
|
||||
push(
|
||||
"EQUALS_SURFACE_UNKNOWN",
|
||||
`${path}.equals.${surf}: not a readable surface — the shipped-observable set is: ${EXPECT_SURFACES.join(", ")}`,
|
||||
`${path}.equals.${surf}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!isPlainObject(spec)) {
|
||||
push("EQUALS_PROJECTION_INVALID", `${path}.equals.${surf}: must be a projection mapping, e.g. { pluck: name }`, `${path}.equals.${surf}`);
|
||||
continue;
|
||||
}
|
||||
for (const key of Object.keys(spec)) {
|
||||
if (!["pluck", "rig", "path"].includes(key)) {
|
||||
push("EQUALS_PROJECTION_INVALID", `${path}.equals.${surf}.${key}: unknown projection key — allowed: pluck, rig, path`, `${path}.equals.${surf}.${key}`);
|
||||
}
|
||||
}
|
||||
if (spec.pluck !== undefined && typeof spec.pluck !== "string") {
|
||||
push("EQUALS_PROJECTION_INVALID", `${path}.equals.${surf}.pluck: must be a field name string`, `${path}.equals.${surf}.pluck`);
|
||||
}
|
||||
if (spec.rig !== undefined && typeof spec.rig !== "boolean") {
|
||||
push("EQUALS_PROJECTION_INVALID", `${path}.equals.${surf}.rig: must be a boolean`, `${path}.equals.${surf}.rig`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (value.within !== undefined) {
|
||||
if (typeof value.within !== "string" || !DURATION_RE.test(value.within)) {
|
||||
push(
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
projectSurface,
|
||||
buildDeclarativeNormalizer,
|
||||
isEqualsMapping,
|
||||
rigOf,
|
||||
} from "./helpers/scenario-normalizer.js";
|
||||
|
||||
// 51-03 — the declarative cross-surface mapping. A-N1: this is the only
|
||||
// scenario-facing form and it LOWERS to the runner-internal seam.
|
||||
|
||||
describe("projectSurface — declares which field carries the shared truth", () => {
|
||||
it("plucks a field from an array surface, deduped and sorted (set semantics)", () => {
|
||||
const ps = [{ name: "b-rig" }, { name: "a-rig" }, { name: "a-rig" }];
|
||||
expect(projectSurface(ps, { pluck: "name" })).toEqual(["a-rig", "b-rig"]);
|
||||
});
|
||||
|
||||
it("reduces canonical session names to their rig — N qitems on one rig collapse to one", () => {
|
||||
const queue = [
|
||||
{ destinationSession: "dev-worker@scn-baton" },
|
||||
{ destinationSession: "dev-qa@scn-baton" },
|
||||
];
|
||||
expect(projectSurface(queue, { pluck: "destinationSession", rig: true })).toEqual(["scn-baton"]);
|
||||
});
|
||||
|
||||
it("is total on shapes it did not expect (no throw, no fabricated value)", () => {
|
||||
expect(projectSurface([], { pluck: "name" })).toEqual([]);
|
||||
expect(projectSurface(null, { pluck: "name" })).toEqual([]);
|
||||
expect(projectSurface([{ other: 1 }], { pluck: "name" })).toEqual([]);
|
||||
});
|
||||
|
||||
it("reads a nested path on an object surface before projecting", () => {
|
||||
expect(projectSurface({ state: { screen: "topology" } }, { path: "state.screen" })).toBe("topology");
|
||||
});
|
||||
|
||||
it("rigOf takes the part after the LAST @ and passes through a bare name", () => {
|
||||
expect(rigOf("dev-worker@scn-baton")).toBe("scn-baton");
|
||||
expect(rigOf("bare")).toBe("bare");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDeclarativeNormalizer — lowering onto the seam", () => {
|
||||
it("applies each surface's declared projection and passes undeclared surfaces through", () => {
|
||||
const n = buildDeclarativeNormalizer({
|
||||
ps: { pluck: "name" },
|
||||
queue: { pluck: "destinationSession", rig: true },
|
||||
});
|
||||
expect(n("ps", [{ name: "scn-baton" }])).toEqual(["scn-baton"]);
|
||||
expect(n("queue", [{ destinationSession: "dev-worker@scn-baton" }])).toEqual(["scn-baton"]);
|
||||
// a surface with no declared projection is NOT silently emptied
|
||||
expect(n("stream", [{ id: 1 }])).toEqual([{ id: 1 }]);
|
||||
});
|
||||
|
||||
it("distinguishes the declarative mapping from the legacy surface list", () => {
|
||||
expect(isEqualsMapping({ ps: { pluck: "name" } })).toBe(true);
|
||||
expect(isEqualsMapping(["tui_socket", "ps", "queue"])).toBe(false);
|
||||
});
|
||||
|
||||
it("two surfaces that genuinely disagree do NOT normalize equal (the pin has teeth)", () => {
|
||||
const n = buildDeclarativeNormalizer({ ps: { pluck: "name" }, queue: { pluck: "destinationSession", rig: true } });
|
||||
expect(n("ps", [{ name: "scn-baton" }])).not.toEqual(n("queue", [{ destinationSession: "w@other-rig" }]));
|
||||
});
|
||||
});
|
||||
@@ -91,14 +91,31 @@ describe("authored evidence scenarios (expressibility)", () => {
|
||||
expect(pre[0]).toMatchObject({ id: "baton-1", destination: "dev-worker@scn-baton", claim: true });
|
||||
});
|
||||
|
||||
it("expresses #10 one-view-state: validates the cross-surface equals over tui_socket/ps/queue", () => {
|
||||
it("expresses #10 one-view-state: the DECLARATIVE equals mapping over the two daemon-truth surfaces", () => {
|
||||
const res = loadScenarioFile(join(FIXTURES, "scenario-10-one-view-state.yaml"));
|
||||
expect(res.ok).toBe(true);
|
||||
if (!res.ok) return;
|
||||
expect(res.loaded.scenario.scenario).toBe("one-view-state-consistency");
|
||||
const equalsStep = res.loaded.scenario.steps.find((s) => "expect" in s)!;
|
||||
const exp = (equalsStep as { expect: Record<string, unknown> }).expect;
|
||||
expect(exp.equals).toEqual(["tui_socket", "ps", "queue"]);
|
||||
const expects = res.loaded.scenario.steps
|
||||
.filter((s) => "expect" in s)
|
||||
.map((s) => (s as { expect: Record<string, unknown> }).expect);
|
||||
|
||||
// 51-03: equals is now the declarative MAPPING (A-N1's scenario-facing form),
|
||||
// each surface declaring the field that carries the shared truth.
|
||||
const equalsStep = expects.find((e) => e.equals)!;
|
||||
expect(equalsStep.equals).toEqual({
|
||||
ps: { pluck: "name" },
|
||||
queue: { pluck: "destinationSession", rig: true },
|
||||
});
|
||||
|
||||
// tui_socket is asserted SEPARATELY on its own coherence, deliberately not
|
||||
// folded into the equality: both of the shipped control socket's OBSERVE
|
||||
// verbs return UI/registry state, so it carries no daemon truth to compare
|
||||
// and including it could only pass by comparing something trivially equal.
|
||||
const tuiStep = expects.find((e) => e.surface === "tui_socket")!;
|
||||
expect(tuiStep).toBeDefined();
|
||||
expect(tuiStep.match).toEqual({ ok: true });
|
||||
expect(equalsStep.equals).not.toHaveProperty("tui_socket");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user